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,97 @@
import { useCallback, useRef, useState } from "react";
import { insideCode } from "../../utils/editor/text.ts";
// Trigger: ':' not preceded by a letter, digit or another ':', followed by a
// letter then at least one more word char / '+' / '-'. Two characters, not
// one, so emoticons like ':D' or ':P' — and "note:" or "12:30" — don't open
// the picker; Unicode letters so French names ("coeur", "cœur") search too.
const TRIGGER_RE = /(?<![\p{L}\p{N}_:]):(\p{L}[\p{L}\p{N}_+-]+)$/u;
/**
* `inline`: opened by typing `:query` — focus stays in the textarea, the
* picker's search follows what's typed, and a pick replaces the `:query`.
* `browse`: opened from the button — focus goes to the picker's own search,
* and a pick lands at the caret.
*/
export type EmojiMode = "closed" | "inline" | "browse";
interface EmojiState {
mode: EmojiMode;
query: string;
/** The range of the textarea's value a pick replaces. */
start: number;
end: number;
}
const CLOSED: EmojiState = { mode: "closed", query: "", start: 0, end: 0 };
export function useEmojiTrigger(
/** Replaces `[start, end)` of the textarea's value, with a space after
* when `word` is set; see TextEditor. */
replace: (start: number, end: number, text: string, word: boolean) => void,
) {
const [state, setStateRaw] = useState<EmojiState>(CLOSED);
// Mirrored synchronously, so a change and a caret move in the same event
// each see what the other just decided.
const stateRef = useRef(state);
const setState = useCallback((next: EmojiState) => {
stateRef.current = next;
setStateRaw(next);
}, []);
/**
* Call on every change (with `open`) and caret move (without): follows an
* inline `:query` as it's typed, and closes once the caret leaves it.
* Leaves a browse-mode picker alone.
*/
const syncEmojiTrigger = useCallback(
(value: string, cursor: number, open: boolean) => {
const s = stateRef.current;
if (s.mode === "browse") return;
if (!open && s.mode === "closed") return;
const before = value.slice(0, cursor);
const match = insideCode(before) ? null : TRIGGER_RE.exec(before);
if (match) {
setState({
mode: "inline",
query: match[1],
start: match.index,
end: cursor,
});
} else if (s.mode !== "closed") {
setState(CLOSED);
}
},
[setState],
);
const openEmojiBrowser = useCallback((start: number, end: number) => {
setState({ mode: "browse", query: "", start, end });
}, [setState]);
const setEmojiQuery = useCallback((query: string) => {
const s = stateRef.current;
if (s.mode !== "closed") setState({ ...s, query });
}, [setState]);
const selectEmoji = useCallback((native: string) => {
const { mode, start, end } = stateRef.current;
if (mode === "closed") return;
setState(CLOSED);
// An inline pick finishes a word, so it takes a space after it the way a
// mention does; a browse pick goes in exactly where the caret was.
replace(start, end, native, mode === "inline");
}, [replace, setState]);
const closeEmoji = useCallback(() => setState(CLOSED), [setState]);
return {
emojiMode: state.mode,
emojiQuery: state.query,
syncEmojiTrigger,
openEmojiBrowser,
setEmojiQuery,
selectEmoji,
closeEmoji,
};
}

View File

@@ -0,0 +1,210 @@
import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { API_URL } from "../../config/api.ts";
import { insideCode } from "../../utils/editor/text.ts";
export interface UserResult {
id: string;
username: string;
avatarMime: string | null;
}
interface MentionState {
open: boolean;
query: string;
/** Where the `@` sits, and the caret after the query: the range a pick
* replaces. */
start: number;
end: number;
results: UserResult[];
selectedIndex: number;
}
const CLOSED: MentionState = {
open: false,
query: "",
start: 0,
end: 0,
results: [],
selectedIndex: 0,
};
// The server's mention rule (api/services/notification-service.ts): an `@` not
// preceded by `[`, `(` or a word character, so what's offered is exactly what
// would notify.
const TRIGGER_RE = /(?<![[(\w])@(\w+)$/;
function getMentionQuery(
value: string,
pos: number,
): { query: string; start: number } | null {
const textBefore = value.slice(0, pos);
if (insideCode(textBefore)) return null;
const match = TRIGGER_RE.exec(textBefore);
if (!match) return null;
return { query: match[1], start: match.index };
}
/** The parts of a keydown the mention list reads. */
export type MentionKey = Pick<
KeyboardEvent,
| "key"
| "ctrlKey"
| "metaKey"
| "shiftKey"
| "altKey"
| "preventDefault"
| "stopPropagation"
>;
export function useMentionAutocomplete(
/** Replaces `[start, end)` of the textarea's value, with a space after
* when `word` is set; see TextEditor. */
replace: (start: number, end: number, text: string, word: boolean) => void,
) {
const [state, setStateRaw] = useState<MentionState>(CLOSED);
// Mirrored synchronously so the key handler and a fetch landing late both
// read what the last keystroke decided, not the last render.
const stateRef = useRef(state);
const setState = useCallback((next: MentionState) => {
stateRef.current = next;
setStateRaw(next);
}, []);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const cancelLookup = useCallback(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = null;
abortRef.current?.abort();
abortRef.current = null;
}, []);
const close = useCallback(() => {
cancelLookup();
if (stateRef.current !== CLOSED) setState(CLOSED);
}, [cancelLookup, setState]);
/**
* Call on every change (with `lookup`) and caret move (without): tracks the
* `@query` under the caret, searching for it after a short pause, and closes
* once the caret leaves it.
*/
const syncMention = useCallback(
(value: string, pos: number, lookup: boolean) => {
const mention = getMentionQuery(value, pos);
const s = stateRef.current;
if (!mention) {
close();
return;
}
if (!lookup) {
// A caret move within the same @query keeps the list; one that lands
// on some other @word doesn't open anything.
if (mention.start !== s.start || mention.query !== s.query) close();
return;
}
if (mention.query === s.query && mention.start === s.start) return;
setState({
...s,
query: mention.query,
start: mention.start,
end: pos,
selectedIndex: 0,
});
cancelLookup();
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch(
`${API_URL}/api/users/search?q=${
encodeURIComponent(mention.query)
}`,
{ signal: controller.signal },
);
const body = await res.json();
const current = stateRef.current;
// Only the lookup for what's typed now may open or close the list.
if (current.query !== mention.query) return;
if (body.success && body.data.length > 0) {
setState({
...current,
open: true,
results: body.data,
selectedIndex: 0,
});
} else {
setState({ ...current, open: false, results: [] });
}
} catch {
// Aborted by a newer keystroke, or offline: nothing to offer.
}
}, 150);
},
[cancelLookup, close, setState],
);
const select = useCallback(
(username: string) => {
const { start, end } = stateRef.current;
close();
replace(start, end, `@${username}`, true);
},
[close, replace],
);
/** Returns whether it handled the key. */
const handleMentionKeyDown = useCallback(
(e: MentionKey): boolean => {
const s = stateRef.current;
if (!s.open || s.results.length === 0) return false;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const n = s.results.length;
const step = e.key === "ArrowDown" ? 1 : -1;
// Wraps, like any listbox.
setState({ ...s, selectedIndex: (s.selectedIndex + step + n) % n });
return true;
}
if (
(e.key === "Enter" || e.key === "Tab") &&
!e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey
) {
e.preventDefault();
const user = s.results[s.selectedIndex];
if (user) select(user.username);
return true;
}
if (e.key === "Escape") {
// Just the list: whatever Escape means to the editor's owner (cancel
// an edit, close the chat) waits for a second press.
e.preventDefault();
e.stopPropagation();
close();
return true;
}
return false;
},
[close, select, setState],
);
useEffect(() => cancelLookup, [cancelLookup]);
return {
mentionOpen: state.open && state.results.length > 0,
mentionResults: state.results,
mentionSelectedIndex: state.selectedIndex,
syncMention,
closeMention: close,
handleMentionKeyDown,
handleMentionSelect: select,
};
}