v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 19:44:14 +00:00
parent 07d0344be7
commit 2b6e68391b
242 changed files with 4390 additions and 4229 deletions

View File

@@ -0,0 +1,204 @@
import { createContext } from "react";
import type {
ChatMessage,
Comment,
Dump,
Notification,
OnlineUser,
Playlist,
PublicUser,
} from "../../model.ts";
export interface VoteEvent {
dumpId: string;
voterId: string;
action: "cast" | "remove";
}
export interface LikeEvent {
commentId: string;
likerId: string;
action: "cast" | "remove";
}
export interface PlaylistEvent {
type: "created" | "updated" | "deleted" | "dumps_updated";
playlistId: string;
playlist?: Playlist;
userId?: string;
dumpIds?: string[];
dumpCount?: number;
}
export interface CommentEvent {
type: "created" | "deleted" | "updated";
dumpId: string;
comment?: Comment;
commentId?: string;
}
export interface UserEvent {
user: PublicUser;
}
/** The replied-to message, as shown in the reply bar and on a pending send. */
export interface ChatReplyTarget {
id: string;
author: string;
body: string;
}
/** A message this client sent that the server hasn't echoed back yet — shown
* in the list as "sending", or "failed" with a way to retry or discard it. */
export interface PendingChatMessage {
nonce: string;
body: string;
replyTo?: ChatReplyTarget;
createdAt: Date;
status: "sending" | "failed";
error?: string;
}
/** A failed chat edit or delete, surfaced in the chat until dismissed. */
export interface ChatError {
id: number;
message: string;
}
/**
* The socket's state, split three ways by how often each part changes, so a
* consumer re-renders only with what it reads: typing and presence update
* every few seconds while people are around, chat with every message, and a
* feed has no use for either.
*/
export interface WSContextValue {
wsStatus: "connecting" | "connected" | "disconnected";
wsErrorMessage: string | null;
voteCounts: Record<string, number>;
myVotes: Set<string>;
likeCounts: Record<string, number>;
myLikes: Set<string>;
recentDumps: Dump[];
deletedDumpIds: Set<string>;
lastVoteEvent: VoteEvent | null;
lastLikeEvent: LikeEvent | null;
lastDumpEvent: Dump | null;
lastPlaylistEvent: PlaylistEvent | null;
deletedPlaylistIds: Set<string>;
lastCommentEvent: CommentEvent | null;
lastUserEvent: UserEvent | null;
unreadNotificationCount: number;
lastNotification: Notification | null;
/** Increments on each WS reconnect so pages can backfill missed events. */
connectionEpoch: number;
/** `shownCount`: the count on screen, used until a live one arrives. */
castVote: (dumpId: string, shownCount?: number) => void;
removeVote: (dumpId: string, shownCount?: number) => void;
castCommentLike: (commentId: string, shownCount?: number) => void;
removeCommentLike: (commentId: string, shownCount?: number) => void;
injectDump: (dump: Dump) => void;
clearUnreadNotifications: () => void;
}
/** The chat room: its live stream, unread state and what to send into it. */
export interface WSChatContextValue {
/** Live buffer of chat messages received this session (capped). */
chatMessages: ChatMessage[];
/** Ids of chat messages deleted live this session, so fetched history can
* filter them out. */
deletedChatIds: Set<string>;
/** Messages from others since the read marker — kept by the server, so it
* survives reloads and agrees across tabs and devices. */
unreadChatCount: number;
/** The server's read marker: created_at of the newest message the user has
* seen. Null until the socket's welcome. */
chatReadAt: Date | null;
pendingChatMessages: PendingChatMessage[];
chatError: ChatError | null;
/** Users currently composing a chat message (excludes nobody — the chatbox
* filters out the current user itself). Shaped like presence for avatars. */
typingUsers: OnlineUser[];
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
retryChatMessage: (nonce: string) => void;
discardChatMessage: (nonce: string) => void;
editChatMessage: (id: string, body: string) => void;
deleteChatMessage: (id: string) => void;
dismissChatError: () => void;
/** Moves the read marker up to the message created at `at`. */
markChatRead: (at: Date) => void;
/** Folds an authoritative fetch of the stream between `from` and `to` into
* the live buffer after a reconnect: buffered messages in that range which
* the server no longer has were deleted while the socket was down. */
reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void;
/** Inform the server whether the user is currently composing a message, so it
* can show/hide the "is typing" indicator to other clients. */
setChatTyping: (typing: boolean) => void;
/** Inform the server whether the chatbox is open (suppresses redundant
* mention notifications while the user is reading chat). */
setChatOpen: (open: boolean) => void;
}
/** Who's connected. */
export interface WSPresenceContextValue {
onlineUsers: OnlineUser[];
}
/** What a consumer outside any provider gets. */
export const defaultWSValue: WSContextValue = {
wsStatus: "connecting",
wsErrorMessage: null,
voteCounts: {},
myVotes: new Set(),
likeCounts: {},
myLikes: new Set(),
recentDumps: [],
deletedDumpIds: new Set(),
lastVoteEvent: null,
lastLikeEvent: null,
lastDumpEvent: null,
lastPlaylistEvent: null,
deletedPlaylistIds: new Set(),
lastCommentEvent: null,
lastUserEvent: null,
unreadNotificationCount: 0,
lastNotification: null,
connectionEpoch: 0,
castVote: () => {},
removeVote: () => {},
castCommentLike: () => {},
removeCommentLike: () => {},
injectDump: () => {},
clearUnreadNotifications: () => {},
};
export const defaultWSChatValue: WSChatContextValue = {
chatMessages: [],
deletedChatIds: new Set(),
unreadChatCount: 0,
chatReadAt: null,
pendingChatMessages: [],
chatError: null,
typingUsers: [],
sendChatMessage: () => {},
retryChatMessage: () => {},
discardChatMessage: () => {},
editChatMessage: () => {},
deleteChatMessage: () => {},
dismissChatError: () => {},
markChatRead: () => {},
reconcileChat: () => {},
setChatTyping: () => {},
setChatOpen: () => {},
};
export const defaultWSPresenceValue: WSPresenceContextValue = {
onlineUsers: [],
};
export const WSContext = createContext<WSContextValue>(defaultWSValue);
export const WSChatContext = createContext<WSChatContextValue>(
defaultWSChatValue,
);
export const WSPresenceContext = createContext<WSPresenceContextValue>(
defaultWSPresenceValue,
);