diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 4bd7b71c..00000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - extends: ['react-app'], - rules: { - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': 'warn' - }, - ignorePatterns: ['android/**', 'build/**', 'node_modules/**', 'public/**', 'electron/**'] -}; \ No newline at end of file diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..f049855f --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,13 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "semi": true, + "singleQuote": true, + "printWidth": 170, + "bracketSpacing": true, + "tabWidth": 2, + "trailingComma": "all", + "jsxSingleQuote": true, + "arrowParens": "always", + "useTabs": false, + "ignorePatterns": [] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..748fe7e6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,105 @@ +# AGENTS.md + +## Project Overview + +5chan is a serverless, adminless, decentralized 4chan alternative built on the Plebbit protocol. + +## Stack + +- **React 19** with TypeScript +- **Zustand** for state management +- **React Router v6** for routing +- **Vite** for bundling +- **plebbit-react-hooks** for Plebbit protocol integration +- **i18next** for translations +- **yarn** as package manager + +## Commands + +```bash +yarn install # Install dependencies +yarn start # Start dev server (port 3000) +yarn build # Production build +yarn test # Run tests +yarn prettier # Format code +yarn electron # Run Electron app +``` + +## Code Style + +- TypeScript strict mode +- Prettier for formatting (runs on pre-commit) +- Follow DRY principle—extract reusable components + +## React Patterns (Critical) + +AI tends to overuse `useState` and `useEffect`. This project follows modern React patterns instead. + +### Do NOT + +- Use `useState` for shared/global state → use **Zustand stores** in `src/stores/` +- Use `useEffect` for data fetching → use **plebbit-react-hooks** (already handles caching, loading states) +- Copy-paste logic across components → extract into **custom hooks** in `src/hooks/` +- Use boolean flag soup (`isLoading`, `isError`, `isSuccess`) → use **state machines** with Zustand +- Use `useEffect` to sync derived state → **calculate during render** instead + +### Do + +- Use Zustand for any state shared between components +- Use plebbit-react-hooks (`useComment`, `useFeed`, `useSubplebbit`, etc.) for all Plebbit data +- Extract reusable logic into custom hooks +- Calculate derived values during render, not in effects +- Use `useMemo` only when profiling shows it's needed +- Use React Router for navigation (no manual history manipulation) + +### Quick Reference + +| Concern | ❌ Avoid | ✅ Use Instead | +|---------|----------|----------------| +| Shared state | `useState` + prop drilling | Zustand store | +| Data fetching | `useEffect` + fetch | plebbit-react-hooks | +| Derived state | `useEffect` to sync | Calculate during render | +| Side effects | Effects without cleanup | AbortController or query libs | +| Complex flows | Boolean flags | State machine in Zustand | +| Logic reuse | Copy-paste | Custom hooks | + +## Project Structure + +``` +src/ +├── components/ # Reusable UI components +├── views/ # Page-level components (routes) +├── hooks/ # Custom React hooks +├── stores/ # Zustand stores +├── lib/ # Utilities and helpers +└── data/ # Static data (default subplebbits, etc.) +``` + +## Documentation + +The following docs exist for deeper guidance. **Do not read them automatically**—they are large and will bloat the context window. Instead: +- Be aware they exist +- Consult them when relevant to the task or when the user asks +- Offer to read them if the user seems to need React pattern guidance + +Available docs: +- **[docs/react-guide.md](docs/react-guide.md)** — Bad vs good React patterns with code examples +- **[docs/you-might-not-need-an-effect.md](docs/you-might-not-need-an-effect.md)** — When to avoid useEffect (comprehensive) + +## Context Window Warning + +MCP servers can silently bloat the context window with tool definitions, even when unused. If you notice: +- Responses getting cut off or degraded +- The agent "forgetting" earlier conversation context +- Unusually slow responses + +**Tell the user**: "You may have too many MCP servers enabled. Consider disabling unused ones to free up context window space." The user may not be aware this is happening—MCP tool definitions consume tokens even if you never call them. + +## Boundaries + +- Never commit secrets or API keys +- Use yarn, not npm +- Keep components focused—split large components +- Add comments for complex logic, skip obvious code +- Test on mobile viewport (this is a responsive app) + diff --git a/docs/react-guide.md b/docs/react-guide.md new file mode 100644 index 00000000..957de419 --- /dev/null +++ b/docs/react-guide.md @@ -0,0 +1,813 @@ +# React guide + +A lot of AI-generated React code is heavily biased toward using useState and useEffect, often in verbose or naive ways. That’s because these hooks are: + +1. Explicit, making them easy for LLMs to pattern-match. +2. Safe defaults, requiring less architectural decision-making. +3. Well-documented, so the model’s training data is oversaturated with examples. + +But modern, well-architected React apps rarely rely on raw useState/useEffect beyond simple UI state or lifecycle quirks. Here’s a breakdown of what serious devs use instead—and why this is the real moat for human engineers. + +Alternatives to useState and useEffect + +1. State Machines / Statecharts (e.g. Zustand) +- Why: Replaces ad-hoc local state management with deterministic, declarative logic. +- Advantage: Less bugs, easier to test, clearer state transitions. +- AI gap: LLMs struggle to model state transitions declaratively. LLM Agent must overcome this. + +2. React Query / SWR for Data Fetching +- Why: Avoids useEffect-based data fetching and loading/error state juggling. +- Advantage: Caching, stale-while-revalidate, retries, pagination—all handled out of the box. +- AI gap: AI often uses useEffect + fetch and forgets cleanup, race conditions, or caching. + +3. Global State with Zustand, Jotai, Recoil, or Redux Toolkit +- Why: Local useState doesn’t scale for shared or persistent state. +- Advantage: Better performance, devtools support, persistence. +- AI gap: Defaults to prop-drilling or bloated context APIs. + +4. Custom Hooks and Composition +- Why: Reuse logic cleanly without bloating components or copy-pasting useEffect. +- Advantage: Separation of concerns, encapsulation. +- AI gap: Often fails to factor logic out, keeping everything in one component. + +## Why This Matters + +AI can mimic patterns, but it can’t: +- Architect a system for long-term maintainability. +- Predict runtime performance tradeoffs. +- Refactor spaghetti effects into custom hooks or state machines. +- Decide when state should be colocated vs globalized. +- Avoid footguns like stale closures, unnecessary re-renders, or race conditions. + +In short: AI is great at writing code, but bad at engineering software. + +Takeaway +- Use fewer useEffect and useState hooks. +- Learn to design systems, not just code components. +- Reach for declarative, composable patterns. +- Understand caching, reactivity, and scalability beyond local state. + +Here’s a side-by-side breakdown of common AI-generated React patterns (bad) vs what experienced engineers write (good) across 4 key areas: state management, data fetching, side effects, and logic reuse. + +## 1. State Management + +❌ Bad (AI-style useState) + +```typescript +function TodoApp() { + const [todos, setTodos] = useState([]); + const [input, setInput] = useState(''); + + const addTodo = () => { + setTodos([...todos, { text: input }]); + setInput(''); + }; + + return ( + <> + setInput(e.target.value)} /> + +
{item.title}
)} +{item.title}
)} +
) : (
- +{isReply && state !== 'failed' && isReplyingToReply && !(deleted || removed) &&} {removed ? ( reason ? ( @@ -116,7 +116,7 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => { diff --git a/src/components/edit-menu/edit-menu.tsx b/src/components/edit-menu/edit-menu.tsx index 5a2da8fb..3c46b1ea 100644 --- a/src/components/edit-menu/edit-menu.tsx +++ b/src/components/edit-menu/edit-menu.tsx @@ -32,7 +32,7 @@ const timestampToDays = (timestamp: number) => { const EditMenu = ({ post }: { post: Comment }) => { const { t } = useTranslation(); const isMobile = useIsMobile(); - const { author, cid, content, deleted, isRules, locked, parentCid, pinned, postCid, reason, removed, spoiler, subplebbitAddress } = post || {}; + const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler, subplebbitAddress } = post || {}; const [isEditMenuOpen, setIsEditMenuOpen] = useState(false); const [isContentEditorOpen, setIsContentEditorOpen] = useState(false); @@ -216,7 +216,7 @@ const EditMenu = ({ post }: { post: Comment }) => { setIsEditMenuOpen(!isEditMenuOpen); } else { setIsEditMenuOpen(false); - alert(parentCid || isRules ? t('cannot_edit_reply') : t('cannot_edit_thread')); + alert(parentCid ? t('cannot_edit_reply') : t('cannot_edit_thread')); } }} checked={isEditMenuOpen} diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index af4ca30f..7c4dbcdb 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Link, useLocation, useParams } from 'react-router-dom'; import { Comment, useAuthorAvatar, useEditedComment } from '@plebbit/plebbit-react-hooks'; -import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits'; import Plebbit from '@plebbit/plebbit-js'; import styles from '../../views/post/post.module.css'; import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDimensions } from '../../lib/utils/media-utils'; @@ -53,13 +52,12 @@ const useShowOmittedReplies = create ((set) => ({ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden }: PostProps) => { const { t } = useTranslation(); - const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, shortCid, state, subplebbitAddress, timestamp } = post || {}; + const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, state, subplebbitAddress, timestamp } = post || {}; const title = post?.title?.trim(); const replies = useReplies(post); const { address, shortAddress } = author || {}; const displayName = author?.displayName?.trim(); - const { isDescription, isRules } = post || {}; // custom properties, not from api - const authorRole = roles?.[address]?.role.replace('moderator', 'mod') || (isDescription || isRules ? 'mod' : undefined); + const authorRole = roles?.[address]?.role?.replace('moderator', 'mod'); const stateString = useStateString(post); const isReply = parentCid; const { showOmittedReplies } = useShowOmittedReplies(); @@ -71,10 +69,9 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden }: PostProps) => { const params = useParams(); const location = useLocation(); - const isInAllView = isAllView(location.pathname); const isInPostPageView = isPostPageView(location.pathname, params); - const userID = address && Plebbit.getShortAddress(address); + const userID = address && Plebbit.getShortAddress({ address }); // should not be shortened to less than 12 characters, because users can create unlimited addresses/IDs before authenticating or passing challenges, so if the ID is short enough they can spoof it to troll users with the same ID const userIDBackgroundColor = hashStringToColor(userID); const userIDTextColor = getTextColorForBackground(userIDBackgroundColor); @@ -139,66 +136,60 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden }: PostProps) => { )}{' '} - {!(isDescription || isRules) && ( - <> - {author?.avatar && !(deleted || removed) && !hideAvatars && avatarImageUrl ? ( - - + {author?.avatar && !(deleted || removed) && !hideAvatars && avatarImageUrl ? ( + +
+ + ) : null} + (ID:{' '} + {deleted ? ( + t('deleted') + ) : removed ? ( + t('removed') + ) : ( +
handleUserAddressClick(userID, postCid)} + style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }} + > + {userID} - ) : null} - (ID:{' '} - {deleted ? ( - t('deleted') - ) : removed ? ( - t('removed') - ) : ( -handleUserAddressClick(userID, postCid)} - style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }} - > - {userID} - - } - content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`} - showTooltip={isInPostPageView || showOmittedReplies[postCid] || (postReplyCount < 6 && !pinned)} - /> - )} - ){' '} - > + } + content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`} + showTooltip={isInPostPageView || showOmittedReplies[postCid] || (postReplyCount < 6 && !pinned)} + /> )} + ){' '} - {getFormattedDate(timestamp)} } content={getFormattedTimeAgo(timestamp)} /> - {isDescription || isRules ? '' : ' '} +{getFormattedDate(timestamp)}} content={getFormattedTimeAgo(timestamp)} />{' '} - {!(isDescription || isRules) && - (cid ? ( - - !cid && e.preventDefault()} - > - CID: - - - {shortCid} - + {cid ? ( + + !cid && e.preventDefault()} + > + No. + + + {post?.number || '?'} - ) : ( - <> - CID: - - {state === 'failed' || stateString === 'Failed' ? _.capitalize(t('failed')) : state === 'pending' ? _.capitalize(t('pending')) : ''} - - > - ))} + + ) : ( + <> + CID: + + {state === 'failed' || stateString === 'Failed' ? _.capitalize(t('failed')) : state === 'pending' ? _.capitalize(t('pending')) : ''} + + > + )} {pinned && ( @@ -212,16 +203,7 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden }: PostProps) => { {!isInPostPageView && !isReply && !isHidden && ( [ - !cid && !isDescription && !isRules && e.preventDefault()} - > + !cid && e.preventDefault()}> {_.capitalize(t('reply'))} ] @@ -246,8 +228,6 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden }: PostProps) => { interface PostMediaProps { commentMediaInfo: CommentMediaInfo | undefined; hasThumbnail: boolean; - isDescription: boolean; - isRules: boolean; spoiler: boolean; deleted: boolean; removed: boolean; @@ -262,8 +242,6 @@ interface PostMediaProps { const PostMedia = ({ commentMediaInfo, hasThumbnail, - isDescription, - isRules, spoiler, deleted, removed, @@ -330,14 +308,11 @@ const PostMedia = ({
@@ -356,7 +331,6 @@ const Reply = ({ postReplyCount, reply, roles }: PostProps) => { } const { author, cid, deleted, link, linkHeight, linkWidth, postCid, reason, removed, spoiler, subplebbitAddress, thumbnailUrl, parentCid } = post || {}; - const { isDescription, isRules } = post || {}; // custom properties, not from api const defaultSubplebbits = useDefaultSubplebbits(); const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : undefined; @@ -381,8 +355,6 @@ const Reply = ({ postReplyCount, reply, roles }: PostProps) => { { const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostProps) => { const { t } = useTranslation(); const { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, subplebbitAddress, thumbnailUrl, parentCid } = post || {}; - const { isDescription, isRules } = post || {}; // custom properties, not from api const params = useParams(); const location = useLocation(); const isInPendingPostView = isPendingPostView(location.pathname, params); @@ -427,17 +398,6 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr const stateString = useStateString(post) || t('downloading_board'); - const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]); - - const subplebbitRulesReply = { - isRules: true, - subplebbitAddress, - timestamp: subplebbit?.createdAt, - author: { displayName: _.capitalize(t('anonymous')) }, - content: `${subplebbit?.rules?.map((rule: string, index: number) => `${index + 1}. ${rule}`).join('\n')}`, - replyCount: 0, - }; - const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const hasThumbnail = getHasThumbnail(commentMediaInfo, link); @@ -451,7 +411,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr )} - {!isInPostPageView && !isDescription && !isRules && showReplies && ( + {!isInPostPageView && showReplies && ( @@ -462,8 +422,6 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr- {!isHidden && !isDescription && !isRules && !isInPendingPostView && (replyCount > 5 || (pinned && repliesCount > 0)) && !isInPostPageView && ( + {!isHidden && !isInPendingPostView && (replyCount > 5 || (pinned && repliesCount > 0)) && !isInPostPageView && (} {!isHidden && }