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:
20
src/contexts/ws/AvatarOverrideContext.ts
Normal file
20
src/contexts/ws/AvatarOverrideContext.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
/**
|
||||
* Live avatar state learned from `user_updated` WS events, keyed by userId.
|
||||
* Kept in its own context (not WSContext) so that `Avatar` — rendered many
|
||||
* times across feeds and comment threads — only re-renders when an avatar
|
||||
* actually changes, not on every vote/comment/dump event.
|
||||
*
|
||||
* - `hasAvatar` reflects whether the user currently has an avatar (so a freshly
|
||||
* added or removed avatar flips even when the surrounding list data is stale).
|
||||
* - `version` cache-busts the avatar URL (derived from the user's `updatedAt`).
|
||||
*/
|
||||
export interface AvatarOverride {
|
||||
hasAvatar: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export const AvatarOverrideContext = createContext<
|
||||
Record<string, AvatarOverride>
|
||||
>({});
|
||||
204
src/contexts/ws/WSContext.ts
Normal file
204
src/contexts/ws/WSContext.ts
Normal 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,
|
||||
);
|
||||
1240
src/contexts/ws/WSProvider.tsx
Normal file
1240
src/contexts/ws/WSProvider.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user