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:
97
src/hooks/editor/useEmojiTrigger.ts
Normal file
97
src/hooks/editor/useEmojiTrigger.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
210
src/hooks/editor/useMentionAutocomplete.ts
Normal file
210
src/hooks/editor/useMentionAutocomplete.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user