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:
174
src/hooks/auth/useAuth.ts
Normal file
174
src/hooks/auth/useAuth.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user