v3: the frontend stops trusting what it last saw — a review of src/ turned up counts that fell to zero, stale copies winning over fresh ones, sessions that tabs trampled on each other, and a player queue that outlived its listener, and this closes them. Voting or liking something with no live count yet started from 0, so a dump with five votes read 1 on the click and, with the socket down, 0 for good once the undo fired; the buttons now pass the count they show, which the optimistic update and its undo start from. After a reconnect the chat re-read its history as the truth, but the merge let the live copy of a message win, so an edit made while the socket was down never showed; the resync now replaces live copies with what it fetched. Every dump_updated added the dump to the live list if it wasn't there, so retitling a months-old dump put it at the top of New, Hot and Journal for everyone, while one that had arrived live kept its old title; an edit now refreshes a live entry in place and adds nothing, and the server announces a dump going public as a dump_created, which is the one case that is an arrival. The new-dump modal let a slow preview for the previous link land on the new one, title suggestion included; changing the link now aborts both lookups. Markdown found mentions by rewriting the source text with a rule that, unlike the server's, didn't skip an @ after a word character, so an email address linked its domain, a URL like mastodon.social/@bob was split, and @ in code came out as literal link syntax; mentions are now found by a remark step over text nodes only, by the server's rule. Changing the password in one tab stored fresh tokens that any other open tab wiped the moment its old token drew a 401; the session now lives behind utils/authStorage.ts, tabs follow each other through storage events (so a logout also signs out every tab), a 401 only forgets the stored session if it still holds that token, and in-place session updates go through a new updateSession() rather than re-reading localStorage. The persisted player queue carries media-token URLs yet survived logout for the next person to sign in; it is now stamped with its owner, not restored for anyone else, cleared on logout or account switch (a guest's queue survives signing in), and older unstamped queues holding a file are dropped. Playlist checkboxes in the new-dump and add-to-playlist modals flipped whether or not the server agreed and now wait for it. CommentThread and the Dump page's loads use authFetch like the rest, so a 401 there signs out consistently; isTokenExpired has one home; and the useScrollLock imports carry their extension so deno check passes.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-24 20:24:38 +00:00
parent 2ad8ae73d5
commit bfce3186f8
24 changed files with 360 additions and 215 deletions

View File

@@ -412,6 +412,8 @@ export async function updateDump(
} }
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
// Going public is an arrival for everyone who couldn't see it before.
if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump);
if (updatedDump.comment) { if (updatedDump.comment) {
notifyMentions( notifyMentions(
dump.userId, dump.userId,
@@ -489,6 +491,8 @@ export async function updateDump(
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
// Going public is an arrival for everyone who couldn't see it before.
if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump);
if (updatedDump.comment) { if (updatedDump.comment) {
notifyMentions( notifyMentions(
dump.userId, dump.userId,

View File

@@ -37,27 +37,22 @@ export function AddToPlaylistModal(
const toggleMembership = async (membership: PlaylistMembership) => { const toggleMembership = async (membership: PlaylistMembership) => {
const { playlist, hasDump } = membership; const { playlist, hasDump } = membership;
if (hasDump) { // The box only changes once the server agrees; a refusal or a dropped
await authFetch( // connection leaves it showing the truth.
try {
const res = await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`, `${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{ method: "DELETE" }, { method: hasDump ? "DELETE" : "POST" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: false } : m
)
);
} else {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{ method: "POST" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: true } : m
)
); );
if (!res.ok) return;
} catch {
return;
} }
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m
)
);
}; };
return ( return (

View File

@@ -3,7 +3,6 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts"; import { API_URL, VALIDATION } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { deserializeAuthResponse } from "../model.ts";
import { Modal } from "./Modal.tsx"; import { Modal } from "./Modal.tsx";
import { import {
expectOk, expectOk,
@@ -26,7 +25,7 @@ interface Values {
} }
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) { export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
const { authFetch, login } = useAuth(); const { authFetch, updateSession } = useAuth();
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const form = useApiForm<Values>({ const form = useApiForm<Values>({
mode: "onChange", mode: "onChange",
@@ -43,10 +42,7 @@ export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
body: JSON.stringify({ currentPassword, newPassword }), body: JSON.stringify({ currentPassword, newPassword }),
}), }),
); );
const storedRaw = localStorage.getItem("authResponse"); updateSession(tokens);
if (storedRaw) {
login({ ...deserializeAuthResponse(JSON.parse(storedRaw)), ...tokens });
}
setDone(true); setDone(true);
}); });

View File

@@ -22,6 +22,7 @@ import { Tooltip } from "./Tooltip.tsx";
import { ConfirmModal } from "./ConfirmModal.tsx"; import { ConfirmModal } from "./ConfirmModal.tsx";
import { useHeightTransition } from "../hooks/useHeightTransition.ts"; import { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { import {
expectOk, expectOk,
FormError, FormError,
@@ -30,13 +31,6 @@ import {
useApiForm, useApiForm,
} from "./form/index.ts"; } from "./form/index.ts";
function authHeaders(token: string | null, json = true): HeadersInit {
const headers: Record<string, string> = {};
if (json) headers["Content-Type"] = "application/json";
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}
interface CommentFormProps { interface CommentFormProps {
/** Async submit; throw to surface a root error, resolve to clear/close. */ /** Async submit; throw to surface a root error, resolve to clear/close. */
onSubmit: (body: string) => Promise<void>; onSubmit: (body: string) => Promise<void>;
@@ -208,13 +202,13 @@ function CommentNode({
const editEditorRef = useRef<TextEditorHandle>(null); const editEditorRef = useRef<TextEditorHandle>(null);
const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS(); const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS();
const { authFetch } = useAuth();
const children = tree.get(comment.id) ?? []; const children = tree.get(comment.id) ?? [];
async function handleReply(body: string) { async function handleReply(body: string) {
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, { const res = await authFetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
method: "POST", method: "POST",
headers: authHeaders(token),
body: JSON.stringify( body: JSON.stringify(
{ body, parentId: comment.id } satisfies CreateCommentRequest, { body, parentId: comment.id } satisfies CreateCommentRequest,
), ),
@@ -225,9 +219,8 @@ function CommentNode({
async function handleDelete() { async function handleDelete() {
if (!token) return; if (!token) return;
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, { const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "DELETE", method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
}).catch(() => null); }).catch(() => null);
if (res?.ok) { if (res?.ok) {
onCommentDeleted(comment.id); onCommentDeleted(comment.id);
@@ -235,9 +228,8 @@ function CommentNode({
} }
async function handleEditSave(body: string) { async function handleEditSave(body: string) {
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, { const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "PATCH", method: "PATCH",
headers: authHeaders(token),
body: JSON.stringify({ body } satisfies UpdateCommentRequest), body: JSON.stringify({ body } satisfies UpdateCommentRequest),
}); });
onCommentUpdated(deserializeComment(await expectOk<RawComment>(res))); onCommentUpdated(deserializeComment(await expectOk<RawComment>(res)));
@@ -452,11 +444,11 @@ export function CommentThread({
}: CommentThreadProps) { }: CommentThreadProps) {
const tree = useMemo(() => buildTree(comments), [comments]); const tree = useMemo(() => buildTree(comments), [comments]);
const roots = tree.get("root") ?? []; const roots = tree.get("root") ?? [];
const { authFetch } = useAuth();
async function handleTopLevelSubmit(body: string) { async function handleTopLevelSubmit(body: string) {
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, { const res = await authFetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
method: "POST", method: "POST",
headers: authHeaders(token),
body: JSON.stringify({ body } satisfies CreateCommentRequest), body: JSON.stringify({ body } satisfies CreateCommentRequest),
}); });
onCommentCreated(deserializeComment(await expectOk<RawComment>(res))); onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));

View File

@@ -2,7 +2,7 @@ import { useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useScrollLock } from "../hooks/useScrollLock"; import { useScrollLock } from "../hooks/useScrollLock.ts";
interface ConfirmModalProps { interface ConfirmModalProps {
message: string; message: string;

View File

@@ -19,8 +19,8 @@ interface DumpCardProps {
voteCount: number; voteCount: number;
voted: boolean; voted: boolean;
canVote: boolean; canVote: boolean;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
className?: string; className?: string;
isOwner?: boolean; isOwner?: boolean;
} }

View File

@@ -386,12 +386,19 @@ export function DumpCreateModal(
// same timing for the user, just not in the same tick. // same timing for the user, just not in the same tick.
queueMicrotask(() => setUrlFetchState({ status: "loading" })); queueMicrotask(() => setUrlFetchState({ status: "loading" }));
// A preview can take seconds (the server fetches the page), so the link
// may have changed by the time one lands: the answer for the old link must
// not show, or suggest its title, under the new one.
const controller = new AbortController();
const { signal } = controller;
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
const encoded = encodeURIComponent(canonicalUrl); const encoded = encodeURIComponent(canonicalUrl);
authFetch(`${API_URL}/api/preview?url=${encoded}`) authFetch(`${API_URL}/api/preview?url=${encoded}`, { signal })
.then((res) => res.json()) .then((res) => res.json())
.then((body) => { .then((body) => {
if (signal.aborted) return;
const data = body.success const data = body.success
? body.data as UrlPreviewResponse ? body.data as UrlPreviewResponse
: undefined; : undefined;
@@ -402,14 +409,16 @@ export function DumpCreateModal(
}); });
}) })
.catch(() => { .catch(() => {
if (signal.aborted) return;
setUrlFetchState({ status: "done", reached: false, richContent: null }); setUrlFetchState({ status: "done", reached: false, richContent: null });
}); });
// Signed in, so that a private dump of the poster's own counts as a // Signed in, so that a private dump of the poster's own counts as a
// duplicate for them and for nobody else. // duplicate for them and for nobody else.
authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`) authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`, { signal })
.then((res) => res.json()) .then((res) => res.json())
.then((body) => { .then((body) => {
if (signal.aborted) return;
setDuplicateState({ setDuplicateState({
url: canonicalUrl, url: canonicalUrl,
matches: body.success matches: body.success
@@ -417,13 +426,16 @@ export function DumpCreateModal(
: [], : [],
}); });
}) })
.catch(() => .catch(() => {
setDuplicateState({ url: canonicalUrl, matches: [] }) if (!signal.aborted) {
); setDuplicateState({ url: canonicalUrl, matches: [] });
}
});
}, 600); }, 600);
return () => { return () => {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
controller.abort();
}; };
}, [canonicalUrl, authFetch]); }, [canonicalUrl, authFetch]);
@@ -613,27 +625,22 @@ export function DumpCreateModal(
const toggleMembership = async (membership: PlaylistMembership) => { const toggleMembership = async (membership: PlaylistMembership) => {
if (!createdDump) return; if (!createdDump) return;
const { playlist, hasDump } = membership; const { playlist, hasDump } = membership;
if (hasDump) { // The box only changes once the server agrees; a refusal or a dropped
await authFetch( // connection leaves it showing the truth.
try {
const res = await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`, `${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
{ method: "DELETE" }, { method: hasDump ? "DELETE" : "POST" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: false } : m
)
);
} else {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
{ method: "POST" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: true } : m
)
); );
if (!res.ok) return;
} catch {
return;
} }
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m
)
);
}; };
const canProceed = !!file || !!canonicalUrl; const canProceed = !!file || !!canonicalUrl;

View File

@@ -24,8 +24,8 @@ interface JournalCardProps {
voteCount: number; voteCount: number;
voted: boolean; voted: boolean;
canVote: boolean; canVote: boolean;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
isOwner?: boolean; isOwner?: boolean;
} }

View File

@@ -8,8 +8,8 @@ interface LikeButtonProps {
count: number; count: number;
liked: boolean; liked: boolean;
disabled?: boolean; disabled?: boolean;
onLike: (commentId: string) => void; onLike: (commentId: string, shownCount?: number) => void;
onUnlike: (commentId: string) => void; onUnlike: (commentId: string, shownCount?: number) => void;
} }
export function LikeButton( export function LikeButton(
@@ -25,7 +25,8 @@ export function LikeButton(
className={`comment-action-btn${ className={`comment-action-btn${
liked ? " comment-action-btn--liked" : "" liked ? " comment-action-btn--liked" : ""
}`} }`}
onClick={() => liked ? onUnlike(commentId) : onLike(commentId)} onClick={() =>
liked ? onUnlike(commentId, count) : onLike(commentId, count)}
disabled={disabled} disabled={disabled}
aria-label={liked ? t`Remove like` : t`Like`} aria-label={liked ? t`Remove like` : t`Like`}
title={disabled ? t`Log in to like` : undefined} title={disabled ? t`Log in to like` : undefined}

View File

@@ -1,4 +1,3 @@
import { useMemo } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
@@ -9,13 +8,57 @@ interface MarkdownProps {
inline?: boolean; inline?: boolean;
} }
const REMARK_PLUGINS = [remarkGfm]; // The mention rule the server notifies by (see utils/chatText.ts): an `@` not
// preceded by a word character, so an email address isn't a mention.
const MENTION_RE = /(?<!\w)@(\w+)/g;
// Convert bare @username (not already inside a markdown link) to clickable links interface MdNode {
function preprocessMentions(text: string): string { type: string;
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)"); value?: string;
url?: string;
children?: MdNode[];
} }
/**
* Turns bare @username into a link to that user. Works on the parsed tree
* rather than the source text, so code keeps its @s verbatim and a URL that
* contains one (`https://mastodon.social/@bob`) isn't split in two.
*/
function remarkMentions() {
const walk = (node: MdNode) => {
if (!node.children) return;
// Link text is already a link, whatever it says.
if (node.type === "link" || node.type === "linkReference") return;
node.children = node.children.flatMap((child) => {
if (child.type !== "text" || !child.value?.includes("@")) {
walk(child);
return [child];
}
const out: MdNode[] = [];
let last = 0;
for (const m of child.value.matchAll(MENTION_RE)) {
if (m.index > last) {
out.push({ type: "text", value: child.value.slice(last, m.index) });
}
out.push({
type: "link",
url: `/users/${m[1]}`,
children: [{ type: "text", value: m[0] }],
});
last = m.index + m[0].length;
}
if (last === 0) return [child];
if (last < child.value.length) {
out.push({ type: "text", value: child.value.slice(last) });
}
return out;
});
};
return (tree: MdNode) => walk(tree);
}
const REMARK_PLUGINS = [remarkGfm, remarkMentions];
type Components = React.ComponentProps<typeof ReactMarkdown>["components"]; type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
// Static components objects — defined once at module scope to avoid recreation on every render // Static components objects — defined once at module scope to avoid recreation on every render
@@ -43,8 +86,6 @@ const INLINE_MARKDOWN_COMPONENTS: Components = {
export function Markdown( export function Markdown(
{ children, className, inline = false }: MarkdownProps, { children, className, inline = false }: MarkdownProps,
) { ) {
const processed = useMemo(() => preprocessMentions(children), [children]);
return ( return (
<div <div
className={`md${className ? ` ${className}` : ""}${ className={`md${className ? ` ${className}` : ""}${
@@ -57,7 +98,7 @@ export function Markdown(
? INLINE_MARKDOWN_COMPONENTS ? INLINE_MARKDOWN_COMPONENTS
: MARKDOWN_COMPONENTS} : MARKDOWN_COMPONENTS}
> >
{processed} {children}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
import { type ReactNode, useCallback, useEffect, useRef } from "react"; import { type ReactNode, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useScrollLock } from "../hooks/useScrollLock"; import { useScrollLock } from "../hooks/useScrollLock.ts";
interface ModalProps { interface ModalProps {
title: string; title: string;

View File

@@ -8,8 +8,8 @@ interface VoteButtonProps {
count: number; count: number;
voted: boolean; voted: boolean;
disabled?: boolean; disabled?: boolean;
onCast: (dumpId: string) => void; onCast: (dumpId: string, shownCount?: number) => void;
onRemove: (dumpId: string) => void; onRemove: (dumpId: string, shownCount?: number) => void;
} }
export function VoteButton( export function VoteButton(
@@ -23,7 +23,7 @@ export function VoteButton(
<button <button
type="button" type="button"
className={`vote-btn${voted ? " vote-btn--active" : ""}`} className={`vote-btn${voted ? " vote-btn--active" : ""}`}
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)} onClick={() => voted ? onRemove(dumpId, count) : onCast(dumpId, count)}
disabled={disabled} disabled={disabled}
aria-label={voted ? t`Remove vote` : t`Upvote`} aria-label={voted ? t`Remove vote` : t`Upvote`}
title={disabled ? t`Log in to vote` : undefined} title={disabled ? t`Log in to vote` : undefined}

View File

@@ -1,10 +1,10 @@
import { createContext } from "react"; import { createContext, type Dispatch, type SetStateAction } from "react";
import { type AuthResponse } from "../model.ts"; import { type AuthResponse } from "../model.ts";
export interface AuthContextValue { export interface AuthContextValue {
authResponse: AuthResponse | null; authResponse: AuthResponse | null;
setAuthResponse: (authResponse: AuthResponse | null) => void; setAuthResponse: Dispatch<SetStateAction<AuthResponse | null>>;
} }
export const AuthContext = createContext<AuthContextValue>({ export const AuthContext = createContext<AuthContextValue>({

View File

@@ -2,48 +2,31 @@ import { type ReactNode, useEffect, useState } from "react";
import { AuthContext, type AuthContextValue } from "./AuthContext.ts"; import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
import { import { type AuthResponse, deserializePublicUser } from "../model.ts";
type AuthResponse,
deserializeAuthResponse,
deserializePublicUser,
} from "../model.ts";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import {
function isTokenExpired(token: string): boolean { AUTH_STORAGE_KEY,
try { forgetStoredSession,
const payload = JSON.parse(atob(token.split(".")[1])); readStoredSession,
return typeof payload.exp === "number" && payload.exp * 1000 < Date.now(); storeSession,
} catch { } from "../utils/authStorage.ts";
return true;
}
}
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState<AuthResponse | null>(() => { const [authResponse, setAuthResponse] = useState<AuthResponse | null>(
const stored = localStorage.getItem("authResponse"); readStoredSession,
if (!stored) return null; );
let parsed; // Every tab shares the stored session, so follow it: a login, logout or
try { // password change in another tab applies here too, rather than this tab
parsed = deserializeAuthResponse(JSON.parse(stored)); // carrying on with a token the server has since revoked.
} catch { useEffect(() => {
localStorage.removeItem("authResponse"); const onStorage = (e: StorageEvent) => {
return null; if (e.key !== AUTH_STORAGE_KEY && e.key !== null) return;
} setAuthResponse(readStoredSession());
if (isTokenExpired(parsed.token)) { };
localStorage.removeItem("authResponse"); globalThis.addEventListener("storage", onStorage);
return null; return () => globalThis.removeEventListener("storage", onStorage);
} }, []);
// Sessions persisted before roles existed have no `role`; the server also
// rejects their tokens, but discard them here so the UI never renders with
// a roleless user.
if (!parsed.user.role) {
localStorage.removeItem("authResponse");
return null;
}
return parsed;
});
// The `exp` pre-check above only catches expiry; it cannot detect a token the // The `exp` pre-check above only catches expiry; it cannot detect a token the
// server rejects for another reason (e.g. a changed GERBEUR_JWT_SECRET, which // server rejects for another reason (e.g. a changed GERBEUR_JWT_SECRET, which
@@ -62,8 +45,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
cache: "no-store", cache: "no-store",
}); });
if (res.status === 401) { if (res.status === 401) {
localStorage.removeItem("authResponse"); // Only this token is dead; the session may have moved on to a
setAuthResponse(null); // fresh one (another tab's password change) while we asked.
forgetStoredSession(token);
setAuthResponse((prev) => prev?.token === token ? null : prev);
return; return;
} }
// The stored user is a snapshot from login time; the role (and other // The stored user is a snapshot from login time; the role (and other
@@ -77,7 +62,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAuthResponse((prev) => { setAuthResponse((prev) => {
if (!prev || prev.user.role === freshUser.role) return prev; if (!prev || prev.user.role === freshUser.role) return prev;
const updated = { ...prev, user: freshUser }; const updated = { ...prev, user: freshUser };
localStorage.setItem("authResponse", JSON.stringify(updated)); storeSession(updated);
return updated; return updated;
}); });
} }
@@ -116,7 +101,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAuthResponse((prev) => { setAuthResponse((prev) => {
if (!prev || prev.token !== token) return prev; if (!prev || prev.token !== token) return prev;
const updated = { ...prev, mediaToken }; const updated = { ...prev, mediaToken };
localStorage.setItem("authResponse", JSON.stringify(updated)); storeSession(updated);
return updated; return updated;
}); });
}) })

View File

@@ -5,6 +5,7 @@ import {
playerItemKey, playerItemKey,
} from "./PlayerContext.ts"; } from "./PlayerContext.ts";
import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts"; import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts";
import { useAuth } from "../hooks/useAuth.ts";
const STORAGE_KEY = "player"; const STORAGE_KEY = "player";
@@ -20,6 +21,11 @@ interface StoredSession {
queue: PlayerItem[]; queue: PlayerItem[];
index: number; index: number;
time: number; time: number;
/**
* Whose queue this is (null for a guest). File URLs carry the listener's
* media token, so a queue must never be handed to whoever signs in next.
*/
owner?: string | null;
} }
function isPlayerItem(value: unknown): value is PlayerItem { function isPlayerItem(value: unknown): value is PlayerItem {
@@ -28,13 +34,25 @@ function isPlayerItem(value: unknown): value is PlayerItem {
return kind === "embed" || kind === "file" || kind === "stream"; return kind === "embed" || kind === "file" || kind === "stream";
} }
function readSession(): { queue: PlayerItem[]; index: number; time: number } | null { function readSession(
owner: string | null,
): { queue: PlayerItem[]; index: number; time: number } | null {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>; const parsed = JSON.parse(raw) as Record<string, unknown>;
const time = Number(parsed.time) || 0; const time = Number(parsed.time) || 0;
// Another account's queue, or one saved before queues had owners: the
// latter may be anybody's, and only file URLs hold a token worth keeping
// to themselves.
if (parsed.owner === undefined) {
const items = Array.isArray(parsed.queue) ? parsed.queue : [parsed.item];
if (items.some((i) => isPlayerItem(i) && i.kind === "file")) return null;
} else if (parsed.owner !== owner) {
return null;
}
const rawQueue = parsed.queue; const rawQueue = parsed.queue;
if (Array.isArray(rawQueue)) { if (Array.isArray(rawQueue)) {
const queue = rawQueue.filter(isPlayerItem); const queue = rawQueue.filter(isPlayerItem);
@@ -60,8 +78,10 @@ function readSession(): { queue: PlayerItem[]; index: number; time: number } | n
const RERESOLVE_COOLDOWN_MS = 30_000; const RERESOLVE_COOLDOWN_MS = 30_000;
export function PlayerProvider({ children }: { children: React.ReactNode }) { export function PlayerProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
const owner = user?.id ?? null;
// Lazy initialiser: readSession runs exactly once, on first render. // Lazy initialiser: readSession runs exactly once, on first render.
const [restored] = useState(readSession); const [restored] = useState(() => readSession(owner));
const [queue, setQueue] = useState<PlayerItem[]>(restored?.queue ?? []); const [queue, setQueue] = useState<PlayerItem[]>(restored?.queue ?? []);
const [queueIndex, setQueueIndex] = useState(restored?.index ?? 0); const [queueIndex, setQueueIndex] = useState(restored?.index ?? 0);
@@ -323,7 +343,13 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify( JSON.stringify(
{ v: 2, queue, index: queueIndex, time: currentTime } satisfies StoredSession, {
v: 2,
queue,
index: queueIndex,
time: currentTime,
owner,
} satisfies StoredSession,
), ),
); );
} else { } else {
@@ -331,12 +357,23 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
} }
// currentTime intentionally omitted from deps — see pagehide writer below. // currentTime intentionally omitted from deps — see pagehide writer below.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [queue, queueIndex]); }, [queue, queueIndex, owner]);
// Signing out, or in as someone else, ends the listening session: the queue
// was built with the previous account's media token. Signing in keeps a
// guest's queue, which holds no token.
const [queueOwner, setQueueOwner] = useState(owner);
useEffect(() => {
if (owner === queueOwner) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setQueueOwner(owner);
if (queueOwner !== null) stop();
}, [owner, queueOwner, stop]);
const sessionRef = useRef<StoredSession | null>(null); const sessionRef = useRef<StoredSession | null>(null);
useEffect(() => { useEffect(() => {
sessionRef.current = queue.length > 0 sessionRef.current = queue.length > 0
? { v: 2, queue, index: queueIndex, time: currentTime } ? { v: 2, queue, index: queueIndex, time: currentTime, owner }
: null; : null;
}); });

View File

@@ -101,10 +101,11 @@ export interface WSContextValue {
typingUsers: OnlineUser[]; typingUsers: OnlineUser[];
/** Increments on each WS reconnect so pages can backfill missed events. */ /** Increments on each WS reconnect so pages can backfill missed events. */
connectionEpoch: number; connectionEpoch: number;
castVote: (dumpId: string) => void; /** `shownCount`: the count on screen, used until a live one arrives. */
removeVote: (dumpId: string) => void; castVote: (dumpId: string, shownCount?: number) => void;
castCommentLike: (commentId: string) => void; removeVote: (dumpId: string, shownCount?: number) => void;
removeCommentLike: (commentId: string) => void; castCommentLike: (commentId: string, shownCount?: number) => void;
removeCommentLike: (commentId: string, shownCount?: number) => void;
injectDump: (dump: Dump) => void; injectDump: (dump: Dump) => void;
clearUnreadNotifications: () => void; clearUnreadNotifications: () => void;
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void; sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;

View File

@@ -450,9 +450,20 @@ export function WSProvider({ children }: WSProviderProps) {
} }
case "dump_created": { case "dump_created": {
// Also sent when a dump goes from private to public: to everyone
// who couldn't see it, that's an arrival.
const dump = deserializeDump(msg.dump); const dump = deserializeDump(msg.dump);
setDeletedDumpIds((prev) => {
if (!prev.has(dump.id)) return prev;
const next = new Set(prev);
next.delete(dump.id);
return next;
});
setRecentDumps((prev) => setRecentDumps((prev) =>
[dump, ...prev].slice(0, MAX_RECENT_DUMPS) [dump, ...prev.filter((d) => d.id !== dump.id)].slice(
0,
MAX_RECENT_DUMPS,
)
); );
break; break;
} }
@@ -460,19 +471,13 @@ export function WSProvider({ children }: WSProviderProps) {
case "dump_updated": { case "dump_updated": {
const dump = deserializeDump(msg.dump); const dump = deserializeDump(msg.dump);
setLastDumpEvent(dump); setLastDumpEvent(dump);
// Un-delete if this dump was previously removed from the feed // An edit is not an arrival: refresh a live entry in place, but
// (e.g. it was made private, and is now public again). // don't pull an old dump to the top of the feeds (going public
setDeletedDumpIds((prev) => { // comes as a `dump_created`).
if (!prev.has(dump.id)) return prev;
const next = new Set(prev);
next.delete(dump.id);
return next;
});
// Add to live feed if not already present (private→public).
setRecentDumps((prev) => setRecentDumps((prev) =>
prev.some((d) => d.id === dump.id) prev.some((d) => d.id === dump.id)
? prev ? prev.map((d) => d.id === dump.id ? dump : d)
: [dump, ...prev].slice(0, MAX_RECENT_DUMPS) : prev
); );
break; break;
} }
@@ -745,9 +750,10 @@ export function WSProvider({ children }: WSProviderProps) {
token, token,
]); ]);
const castVote = useCallback((dumpId: string) => { const castVote = useCallback((dumpId: string, shownCount?: number) => {
// Optimistic update // Optimistic update. Until a live event arrives there's no count here, only
const prevCount = voteCountsRef.current[dumpId] ?? 0; // the one the caller got over REST and is showing.
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
const prevVoted = myVotesRef.current.has(dumpId); const prevVoted = myVotesRef.current.has(dumpId);
if (prevVoted) return; // already voted if (prevVoted) return; // already voted
@@ -778,9 +784,10 @@ export function WSProvider({ children }: WSProviderProps) {
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingVote]); }, [schedulePendingVote]);
const removeVote = useCallback((dumpId: string) => { const removeVote = useCallback((dumpId: string, shownCount?: number) => {
// Optimistic update // Optimistic update. Until a live event arrives there's no count here, only
const prevCount = voteCountsRef.current[dumpId] ?? 0; // the one the caller got over REST and is showing.
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
const prevVoted = myVotesRef.current.has(dumpId); const prevVoted = myVotesRef.current.has(dumpId);
if (!prevVoted) return; // not voted if (!prevVoted) return; // not voted
@@ -814,9 +821,10 @@ export function WSProvider({ children }: WSProviderProps) {
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingVote]); }, [schedulePendingVote]);
const castCommentLike = useCallback((commentId: string) => { const castCommentLike = useCallback((commentId: string, shownCount?: number) => {
// Optimistic update // Optimistic update. Until a live event arrives there's no count here, only
const prevCount = likeCountsRef.current[commentId] ?? 0; // the one the caller got over REST and is showing.
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
const prevLiked = myLikesRef.current.has(commentId); const prevLiked = myLikesRef.current.has(commentId);
if (prevLiked) return; // already liked if (prevLiked) return; // already liked
@@ -847,9 +855,10 @@ export function WSProvider({ children }: WSProviderProps) {
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingLike]); }, [schedulePendingLike]);
const removeCommentLike = useCallback((commentId: string) => { const removeCommentLike = useCallback((commentId: string, shownCount?: number) => {
// Optimistic update // Optimistic update. Until a live event arrives there's no count here, only
const prevCount = likeCountsRef.current[commentId] ?? 0; // the one the caller got over REST and is showing.
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
const prevLiked = myLikesRef.current.has(commentId); const prevLiked = myLikesRef.current.has(commentId);
if (!prevLiked) return; // not liked if (!prevLiked) return; // not liked
@@ -1038,11 +1047,16 @@ export function WSProvider({ children }: WSProviderProps) {
const reconcileChat = useCallback( const reconcileChat = useCallback(
(messages: ChatMessage[], from: Date, to: Date) => { (messages: ChatMessage[], from: Date, to: Date) => {
const ids = new Set(messages.map((m) => m.id)); // In that stretch the fetched copy is the truth: a live copy of a
// message edited while the socket was down is stale, and the merge in
// the chatbox would let it win.
const byId = new Map(messages.map((m) => [m.id, m]));
setChatMessages((prev) => setChatMessages((prev) =>
prev.filter((m) => prev
ids.has(m.id) || m.createdAt < from || m.createdAt > to .filter((m) =>
) byId.has(m.id) || m.createdAt < from || m.createdAt > to
)
.map((m) => byId.get(m.id) ?? m)
); );
// Failed sends that did land (only the echo was lost) are retired. // Failed sends that did land (only the echo was lost) are retired.
const mine = messages.filter((m) => m.userId === userIdRef.current); const mine = messages.filter((m) => m.userId === userIdRef.current);

View File

@@ -3,27 +3,37 @@ import { useCallback, useContext } from "react";
import { AuthContext } from "../contexts/AuthContext.ts"; import { AuthContext } from "../contexts/AuthContext.ts";
import { type AuthResponse } from "../model.ts"; import { type AuthResponse } from "../model.ts";
import {
function isTokenExpired(token: string): boolean { forgetStoredSession,
try { isTokenExpired,
const payload = JSON.parse(atob(token.split(".")[1])); storeSession,
return typeof payload.exp === "number" && payload.exp * 1000 < Date.now(); } from "../utils/authStorage.ts";
} catch {
return true;
}
}
export const useAuth = () => { export const useAuth = () => {
const { authResponse, setAuthResponse } = useContext(AuthContext); const { authResponse, setAuthResponse } = useContext(AuthContext);
const login = useCallback((response: AuthResponse) => { const login = useCallback((response: AuthResponse) => {
setAuthResponse(response); setAuthResponse(response);
localStorage.setItem("authResponse", JSON.stringify(response)); storeSession(response);
}, [setAuthResponse]); }, [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(() => { const logout = useCallback(() => {
setAuthResponse(null); setAuthResponse((prev) => prev?.token === sessionToken ? null : prev);
localStorage.removeItem("authResponse"); 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]); }, [setAuthResponse]);
const authFetch = useCallback(async ( const authFetch = useCallback(async (
@@ -122,19 +132,37 @@ export const useAuth = () => {
mediaToken: authResponse?.mediaToken ?? authResponse?.token ?? null, mediaToken: authResponse?.mediaToken ?? authResponse?.token ?? null,
login, login,
logout, logout,
updateSession,
authFetch, authFetch,
authUpload, authUpload,
}; };
}; };
export const useRequiredAuth = () => { export const useRequiredAuth = () => {
const { user, token, mediaToken, login, logout, authFetch, authUpload } = const {
useAuth(); user,
token,
mediaToken,
login,
logout,
updateSession,
authFetch,
authUpload,
} = useAuth();
if (!user) { if (!user) {
throw new Error( throw new Error(
"Invariant: useRequiredAuth called outside a protected route", "Invariant: useRequiredAuth called outside a protected route",
); );
} }
return { user, token, mediaToken, login, logout, authFetch, authUpload }; return {
user,
token,
mediaToken,
login,
logout,
updateSession,
authFetch,
authUpload,
};
}; };

View File

@@ -16,7 +16,7 @@ export function useScrollLock() {
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
if (locks++ === 0) { if (locks++ === 0) {
if (window.innerWidth > root.clientWidth) { if (globalThis.innerWidth > root.clientWidth) {
root.style.scrollbarGutter = "stable"; root.style.scrollbarGutter = "stable";
} }
root.style.overflowY = "hidden"; root.style.overflowY = "hidden";

View File

@@ -111,10 +111,9 @@ export function Dump() {
setDumpState({ status: "loading" }); setDumpState({ status: "loading" });
setOp(null); setOp(null);
try { try {
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { const res = await authFetch(`${API_URL}/api/dumps/${selectedDump}`, {
cache: "no-store", cache: "no-store",
signal: controller.signal, signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
const apiResponse = parseAPIResponse<RawDump>(await res.json()); const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (!apiResponse.success) { if (!apiResponse.success) {
@@ -135,7 +134,7 @@ export function Dump() {
} }
})(); })();
return () => controller.abort(); return () => controller.abort();
}, [selectedDump, preloaded, token]); }, [selectedDump, preloaded, authFetch]);
useEffect(() => { useEffect(() => {
if (!lastDumpEvent) return; if (!lastDumpEvent) return;
@@ -154,9 +153,8 @@ export function Dump() {
useEffect(() => { useEffect(() => {
if (!selectedDump) return; if (!selectedDump) return;
const controller = new AbortController(); const controller = new AbortController();
fetch(`${API_URL}/api/dumps/${selectedDump}/comments`, { authFetch(`${API_URL}/api/dumps/${selectedDump}/comments`, {
signal: controller.signal, signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
@@ -168,15 +166,14 @@ export function Dump() {
return () => controller.abort(); return () => controller.abort();
// connectionEpoch: re-fetch on reconnect to backfill comments missed while // connectionEpoch: re-fetch on reconnect to backfill comments missed while
// the socket was down. // the socket was down.
}, [selectedDump, token, connectionEpoch]); }, [selectedDump, authFetch, connectionEpoch]);
// Fetch related dumps (backlinks) when dump loads // Fetch related dumps (backlinks) when dump loads
useEffect(() => { useEffect(() => {
if (!selectedDump) return; if (!selectedDump) return;
const controller = new AbortController(); const controller = new AbortController();
fetch(`${API_URL}/api/dumps/${selectedDump}/related`, { authFetch(`${API_URL}/api/dumps/${selectedDump}/related`, {
signal: controller.signal, signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
@@ -186,16 +183,15 @@ export function Dump() {
}) })
.catch(() => {}); .catch(() => {});
return () => controller.abort(); return () => controller.abort();
}, [selectedDump, token]); }, [selectedDump, authFetch]);
// Fetch the collections this dump appears in (public ones, plus the // Fetch the collections this dump appears in (public ones, plus the
// viewer's own private ones — the server does the filtering) // viewer's own private ones — the server does the filtering)
useEffect(() => { useEffect(() => {
if (!selectedDump) return; if (!selectedDump) return;
const controller = new AbortController(); const controller = new AbortController();
fetch(`${API_URL}/api/dumps/${selectedDump}/playlists`, { authFetch(`${API_URL}/api/dumps/${selectedDump}/playlists`, {
signal: controller.signal, signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
@@ -208,7 +204,7 @@ export function Dump() {
// lastPlaylistEvent: adding/removing this dump, flipping a collection's // lastPlaylistEvent: adding/removing this dump, flipping a collection's
// visibility or deleting it all change the list — playlist events are rare // visibility or deleting it all change the list — playlist events are rare
// enough to just refetch on any of them. // enough to just refetch on any of them.
}, [selectedDump, token, lastPlaylistEvent]); }, [selectedDump, authFetch, lastPlaylistEvent]);
// Scroll to and highlight a comment when navigating to #comment-{id} // Scroll to and highlight a comment when navigating to #comment-{id}
useEffect(() => { useEffect(() => {

View File

@@ -19,7 +19,6 @@ import type {
Role, Role,
} from "../model.ts"; } from "../model.ts";
import { import {
deserializeAuthResponse,
deserializeDump, deserializeDump,
deserializeInviteTreeEntry, deserializeInviteTreeEntry,
deserializePublicUser, deserializePublicUser,
@@ -235,7 +234,7 @@ function buildInviteTree(
export function UserPublicProfile() { export function UserPublicProfile() {
const { username } = useParams(); const { username } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { user: me, authFetch, login, logout, token } = useAuth(); const { user: me, authFetch, updateSession, logout, token } = useAuth();
const { style, colorScheme, setStyle, setColorScheme } = useTheme(); const { style, colorScheme, setStyle, setColorScheme } = useTheme();
const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled(); const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled();
const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab(); const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab();
@@ -734,13 +733,7 @@ export function UserPublicProfile() {
return; return;
} }
const storedRaw = localStorage.getItem("authResponse"); updateSession({ user: deserializePublicUser(body.data) });
if (storedRaw) {
login({
...deserializeAuthResponse(JSON.parse(storedRaw)),
user: deserializePublicUser(body.data),
});
}
setState((prev) => setState((prev) =>
prev.status === "loaded" prev.status === "loaded"
@@ -756,11 +749,7 @@ export function UserPublicProfile() {
}; };
const handleEmailSaved = (email: string) => { const handleEmailSaved = (email: string) => {
const storedRaw = localStorage.getItem("authResponse"); if (me) updateSession({ user: { ...me, email } });
if (storedRaw) {
const prev = deserializeAuthResponse(JSON.parse(storedRaw));
login({ ...prev, user: { ...prev.user, email } });
}
setEmailEditing(false); setEmailEditing(false);
}; };
@@ -1403,8 +1392,8 @@ function DumpList(
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
canVote: boolean; canVote: boolean;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
isOwnProfile?: boolean; isOwnProfile?: boolean;
viewAllHref: string; viewAllHref: string;
}, },
@@ -1482,8 +1471,8 @@ function UpvotedDumpList(
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
canVote: boolean; canVote: boolean;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
viewAllHref: string; viewAllHref: string;
}, },
) { ) {

View File

@@ -38,8 +38,8 @@ interface FollowedFeedProps {
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
user: User; user: User;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
deletedDumpIds: Set<string>; deletedDumpIds: Set<string>;
} }
@@ -50,8 +50,8 @@ interface FollowedSubFeedProps {
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
user: User; user: User;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
deletedDumpIds: Set<string>; deletedDumpIds: Set<string>;
emptyMessage: string; emptyMessage: string;
onLoadMore: () => void; onLoadMore: () => void;

View File

@@ -11,6 +11,6 @@ export interface MainFeedProps {
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
user: User | null; user: User | null;
castVote: (id: string) => void; castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string) => void; removeVote: (id: string, shownCount?: number) => void;
} }

59
src/utils/authStorage.ts Normal file
View File

@@ -0,0 +1,59 @@
import { type AuthResponse, deserializeAuthResponse } from "../model.ts";
// The session is shared by every tab through this one localStorage entry.
export const AUTH_STORAGE_KEY = "authResponse";
export function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return typeof payload.exp === "number" && payload.exp * 1000 < Date.now();
} catch {
return true;
}
}
/** The stored session, or null — discarding one that can no longer be used. */
export function readStoredSession(): AuthResponse | null {
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
if (!stored) return null;
let parsed;
try {
parsed = deserializeAuthResponse(JSON.parse(stored));
} catch {
localStorage.removeItem(AUTH_STORAGE_KEY);
return null;
}
if (isTokenExpired(parsed.token)) {
localStorage.removeItem(AUTH_STORAGE_KEY);
return null;
}
// Sessions persisted before roles existed have no `role`; the server also
// rejects their tokens, but discard them here so the UI never renders with
// a roleless user.
if (!parsed.user.role) {
localStorage.removeItem(AUTH_STORAGE_KEY);
return null;
}
return parsed;
}
export function storeSession(session: AuthResponse): void {
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
}
/**
* Forget the stored session, but only if it is still the one holding `token`.
* Another tab may already have replaced it — a password change there hands out
* fresh tokens — and a 401 for the old token here must not wipe those.
*/
export function forgetStoredSession(token: string | undefined): void {
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
if (!stored) return;
try {
if (JSON.parse(stored)?.token !== token) return;
} catch {
// Unreadable: nobody can use it.
}
localStorage.removeItem(AUTH_STORAGE_KEY);
}