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

174
src/hooks/auth/useAuth.ts Normal file
View File

@@ -0,0 +1,174 @@
import { useCallback, useContext } from "react";
import { AuthContext } from "../../contexts/auth/AuthContext.ts";
import { type AuthResponse } from "../../model.ts";
import {
forgetStoredSession,
isTokenExpired,
storeSession,
} from "../../utils/auth/storage.ts";
export const useAuth = () => {
const { authResponse, setAuthResponse } = useContext(AuthContext);
const login = useCallback((response: AuthResponse) => {
setAuthResponse(response);
storeSession(response);
}, [setAuthResponse]);
// Ends the session this render holds. A 401 can land after the session has
// moved on to fresh tokens (a password change, here or in another tab), and
// must not sign those out.
const sessionToken = authResponse?.token;
const logout = useCallback(() => {
setAuthResponse((prev) => prev?.token === sessionToken ? null : prev);
forgetStoredSession(sessionToken);
}, [setAuthResponse, sessionToken]);
/** Merge into the current session, in memory and in storage. */
const updateSession = useCallback((patch: Partial<AuthResponse>) => {
setAuthResponse((prev) => {
if (!prev) return prev;
const next = { ...prev, ...patch };
storeSession(next);
return next;
});
}, [setAuthResponse]);
const authFetch = useCallback(async (
input: RequestInfo,
init: RequestInit = {},
) => {
const token = authResponse?.token;
if (token && isTokenExpired(token)) {
logout();
// Return a synthetic 401 so callers handle it consistently
return new Response(null, { status: 401 });
}
const isFormData = init.body instanceof FormData;
const res = await fetch(input, {
...init,
headers: {
...(init.headers ?? {}),
// Only attach a token when we actually have one — avoids sending a
// `Bearer undefined` header that the server would reject as invalid.
...(token ? { Authorization: `Bearer ${token}` } : {}),
// Let the browser set Content-Type for FormData (it includes the boundary)
...(isFormData ? {} : { "Content-Type": "application/json" }),
},
});
if (res.status === 401) {
logout();
}
return res;
}, [authResponse?.token, logout]);
/**
* `authFetch` for multipart uploads that need a progress bar.
*
* `fetch` reports nothing until the whole body is on the wire, which for a
* 50 MB dump on a phone is a long silence — so this goes through XHR, whose
* `upload.progress` events fire as bytes leave. The result is wrapped back
* into a real `Response` so callers keep using `expectOk` unchanged.
*
* `onProgress` receives a 0–1 fraction, or `null` once the body is fully sent
* and we are waiting on the server (the length is unknown for chunked bodies,
* and "100%, still waiting" reads as a stall).
*/
const authUpload = useCallback((
url: string,
body: FormData,
onProgress: (fraction: number | null) => void,
method = "POST",
): Promise<Response> => {
const token = authResponse?.token;
if (token && isTokenExpired(token)) {
logout();
return Promise.resolve(new Response(null, { status: 401 }));
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
if (token) xhr.setRequestHeader("Authorization", `Bearer ${token}`);
// Content-Type is left to the browser so the multipart boundary is set.
xhr.upload.addEventListener("progress", (e) => {
onProgress(e.lengthComputable ? e.loaded / e.total : null);
});
xhr.upload.addEventListener("load", () => onProgress(null));
xhr.addEventListener("load", () => {
if (xhr.status === 401) logout();
resolve(
new Response(xhr.responseText, {
status: xhr.status,
statusText: xhr.statusText,
}),
);
});
// Mirrors what `fetch` throws on a network failure, so the shared
// `friendlyFetchError` path renders it the same way.
xhr.addEventListener(
"error",
() => reject(new TypeError("Failed to fetch")),
);
xhr.addEventListener(
"abort",
() => reject(new TypeError("Failed to fetch")),
);
xhr.send(body);
});
}, [authResponse?.token, logout]);
return {
user: authResponse?.user ?? null,
token: authResponse?.token ?? null,
// For file and thumbnail URLs, where the token is visible in the query
// string. Sessions from before media tokens fall back to the session token
// until AuthProvider fetches one.
mediaToken: authResponse?.mediaToken ?? authResponse?.token ?? null,
login,
logout,
updateSession,
authFetch,
authUpload,
};
};
export const useRequiredAuth = () => {
const {
user,
token,
mediaToken,
login,
logout,
updateSession,
authFetch,
authUpload,
} = useAuth();
if (!user) {
throw new Error(
"Invariant: useRequiredAuth called outside a protected route",
);
}
return {
user,
token,
mediaToken,
login,
logout,
updateSession,
authFetch,
authUpload,
};
};