From bfce3186f8a27aadbbbd4e5527ac370a2debe58a Mon Sep 17 00:00:00 2001 From: khannurien Date: Thu, 24 Sep 2026 20:24:38 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20the=20frontend=20stops=20trusting=20what?= =?UTF-8?q?=20it=20last=20saw=20=E2=80=94=20a=20review=20of=20src/=20turne?= =?UTF-8?q?d=20up=20counts=20that=20fell=20to=20zero,=20stale=20copies=20w?= =?UTF-8?q?inning=20over=20fresh=20ones,=20sessions=20that=20tabs=20trampl?= =?UTF-8?q?ed=20on=20each=20other,=20and=20a=20player=20queue=20that=20out?= =?UTF-8?q?lived=20its=20listener,=20and=20this=20closes=20them.=20Voting?= =?UTF-8?q?=20or=20liking=20something=20with=20no=20live=20count=20yet=20s?= =?UTF-8?q?tarted=20from=200,=20so=20a=20dump=20with=20five=20votes=20read?= =?UTF-8?q?=201=20on=20the=20click=20and,=20with=20the=20socket=20down,=20?= =?UTF-8?q?0=20for=20good=20once=20the=20undo=20fired;=20the=20buttons=20n?= =?UTF-8?q?ow=20pass=20the=20count=20they=20show,=20which=20the=20optimist?= =?UTF-8?q?ic=20update=20and=20its=20undo=20start=20from.=20After=20a=20re?= =?UTF-8?q?connect=20the=20chat=20re-read=20its=20history=20as=20the=20tru?= =?UTF-8?q?th,=20but=20the=20merge=20let=20the=20live=20copy=20of=20a=20me?= =?UTF-8?q?ssage=20win,=20so=20an=20edit=20made=20while=20the=20socket=20w?= =?UTF-8?q?as=20down=20never=20showed;=20the=20resync=20now=20replaces=20l?= =?UTF-8?q?ive=20copies=20with=20what=20it=20fetched.=20Every=20dump=5Fupd?= =?UTF-8?q?ated=20added=20the=20dump=20to=20the=20live=20list=20if=20it=20?= =?UTF-8?q?wasn't=20there,=20so=20retitling=20a=20months-old=20dump=20put?= =?UTF-8?q?=20it=20at=20the=20top=20of=20New,=20Hot=20and=20Journal=20for?= =?UTF-8?q?=20everyone,=20while=20one=20that=20had=20arrived=20live=20kept?= =?UTF-8?q?=20its=20old=20title;=20an=20edit=20now=20refreshes=20a=20live?= =?UTF-8?q?=20entry=20in=20place=20and=20adds=20nothing,=20and=20the=20ser?= =?UTF-8?q?ver=20announces=20a=20dump=20going=20public=20as=20a=20dump=5Fc?= =?UTF-8?q?reated,=20which=20is=20the=20one=20case=20that=20is=20an=20arri?= =?UTF-8?q?val.=20The=20new-dump=20modal=20let=20a=20slow=20preview=20for?= =?UTF-8?q?=20the=20previous=20link=20land=20on=20the=20new=20one,=20title?= =?UTF-8?q?=20suggestion=20included;=20changing=20the=20link=20now=20abort?= =?UTF-8?q?s=20both=20lookups.=20Markdown=20found=20mentions=20by=20rewrit?= =?UTF-8?q?ing=20the=20source=20text=20with=20a=20rule=20that,=20unlike=20?= =?UTF-8?q?the=20server's,=20didn't=20skip=20an=20@=20after=20a=20word=20c?= =?UTF-8?q?haracter,=20so=20an=20email=20address=20linked=20its=20domain,?= =?UTF-8?q?=20a=20URL=20like=20mastodon.social/@bob=20was=20split,=20and?= =?UTF-8?q?=20@=20in=20code=20came=20out=20as=20literal=20link=20syntax;?= =?UTF-8?q?=20mentions=20are=20now=20found=20by=20a=20remark=20step=20over?= =?UTF-8?q?=20text=20nodes=20only,=20by=20the=20server's=20rule.=20Changin?= =?UTF-8?q?g=20the=20password=20in=20one=20tab=20stored=20fresh=20tokens?= =?UTF-8?q?=20that=20any=20other=20open=20tab=20wiped=20the=20moment=20its?= =?UTF-8?q?=20old=20token=20drew=20a=20401;=20the=20session=20now=20lives?= =?UTF-8?q?=20behind=20utils/authStorage.ts,=20tabs=20follow=20each=20othe?= =?UTF-8?q?r=20through=20storage=20events=20(so=20a=20logout=20also=20sign?= =?UTF-8?q?s=20out=20every=20tab),=20a=20401=20only=20forgets=20the=20stor?= =?UTF-8?q?ed=20session=20if=20it=20still=20holds=20that=20token,=20and=20?= =?UTF-8?q?in-place=20session=20updates=20go=20through=20a=20new=20updateS?= =?UTF-8?q?ession()=20rather=20than=20re-reading=20localStorage.=20The=20p?= =?UTF-8?q?ersisted=20player=20queue=20carries=20media-token=20URLs=20yet?= =?UTF-8?q?=20survived=20logout=20for=20the=20next=20person=20to=20sign=20?= =?UTF-8?q?in;=20it=20is=20now=20stamped=20with=20its=20owner,=20not=20res?= =?UTF-8?q?tored=20for=20anyone=20else,=20cleared=20on=20logout=20or=20acc?= =?UTF-8?q?ount=20switch=20(a=20guest's=20queue=20survives=20signing=20in)?= =?UTF-8?q?,=20and=20older=20unstamped=20queues=20holding=20a=20file=20are?= =?UTF-8?q?=20dropped.=20Playlist=20checkboxes=20in=20the=20new-dump=20and?= =?UTF-8?q?=20add-to-playlist=20modals=20flipped=20whether=20or=20not=20th?= =?UTF-8?q?e=20server=20agreed=20and=20now=20wait=20for=20it.=20CommentThr?= =?UTF-8?q?ead=20and=20the=20Dump=20page's=20loads=20use=20authFetch=20lik?= =?UTF-8?q?e=20the=20rest,=20so=20a=20401=20there=20signs=20out=20consiste?= =?UTF-8?q?ntly;=20isTokenExpired=20has=20one=20home;=20and=20the=20useScr?= =?UTF-8?q?ollLock=20imports=20carry=20their=20extension=20so=20deno=20che?= =?UTF-8?q?ck=20passes.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/services/dump-service.ts | 4 ++ src/components/AddToPlaylistModal.tsx | 31 +++++------- src/components/ChangePasswordModal.tsx | 8 +-- src/components/CommentThread.tsx | 22 +++----- src/components/ConfirmModal.tsx | 2 +- src/components/DumpCard.tsx | 4 +- src/components/DumpCreateModal.tsx | 53 ++++++++++--------- src/components/JournalCard.tsx | 4 +- src/components/LikeButton.tsx | 7 +-- src/components/Markdown.tsx | 57 ++++++++++++++++++--- src/components/Modal.tsx | 2 +- src/components/VoteButton.tsx | 6 +-- src/contexts/AuthContext.ts | 4 +- src/contexts/AuthProvider.tsx | 69 ++++++++++--------------- src/contexts/PlayerProvider.tsx | 47 +++++++++++++++-- src/contexts/WSContext.ts | 9 ++-- src/contexts/WSProvider.tsx | 70 +++++++++++++++----------- src/hooks/useAuth.ts | 58 +++++++++++++++------ src/hooks/useScrollLock.ts | 2 +- src/pages/Dump.tsx | 20 +++----- src/pages/UserPublicProfile.tsx | 25 +++------ src/pages/index/FollowedFeed.tsx | 8 +-- src/pages/index/types.ts | 4 +- src/utils/authStorage.ts | 59 ++++++++++++++++++++++ 24 files changed, 360 insertions(+), 215 deletions(-) create mode 100644 src/utils/authStorage.ts diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 15e560ce..32fd2079 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -412,6 +412,8 @@ export async function updateDump( } if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); 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) { notifyMentions( dump.userId, @@ -489,6 +491,8 @@ export async function updateDump( if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); 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) { notifyMentions( dump.userId, diff --git a/src/components/AddToPlaylistModal.tsx b/src/components/AddToPlaylistModal.tsx index c7a50edb..dab125cd 100644 --- a/src/components/AddToPlaylistModal.tsx +++ b/src/components/AddToPlaylistModal.tsx @@ -37,27 +37,22 @@ export function AddToPlaylistModal( const toggleMembership = async (membership: PlaylistMembership) => { const { playlist, hasDump } = membership; - if (hasDump) { - await authFetch( + // The box only changes once the server agrees; a refusal or a dropped + // connection leaves it showing the truth. + try { + const res = await authFetch( `${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`, - { method: "DELETE" }, - ); - 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 - ) + { method: hasDump ? "DELETE" : "POST" }, ); + if (!res.ok) return; + } catch { + return; } + setMemberships((prev) => + prev.map((m) => + m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m + ) + ); }; return ( diff --git a/src/components/ChangePasswordModal.tsx b/src/components/ChangePasswordModal.tsx index 7e734bf6..5b131624 100644 --- a/src/components/ChangePasswordModal.tsx +++ b/src/components/ChangePasswordModal.tsx @@ -3,7 +3,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL, VALIDATION } from "../config/api.ts"; import { useAuth } from "../hooks/useAuth.ts"; -import { deserializeAuthResponse } from "../model.ts"; import { Modal } from "./Modal.tsx"; import { expectOk, @@ -26,7 +25,7 @@ interface Values { } export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) { - const { authFetch, login } = useAuth(); + const { authFetch, updateSession } = useAuth(); const [done, setDone] = useState(false); const form = useApiForm({ mode: "onChange", @@ -43,10 +42,7 @@ export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) { body: JSON.stringify({ currentPassword, newPassword }), }), ); - const storedRaw = localStorage.getItem("authResponse"); - if (storedRaw) { - login({ ...deserializeAuthResponse(JSON.parse(storedRaw)), ...tokens }); - } + updateSession(tokens); setDone(true); }); diff --git a/src/components/CommentThread.tsx b/src/components/CommentThread.tsx index d9f65912..2762550a 100644 --- a/src/components/CommentThread.tsx +++ b/src/components/CommentThread.tsx @@ -22,6 +22,7 @@ import { Tooltip } from "./Tooltip.tsx"; import { ConfirmModal } from "./ConfirmModal.tsx"; import { useHeightTransition } from "../hooks/useHeightTransition.ts"; import { useWS } from "../hooks/useWS.ts"; +import { useAuth } from "../hooks/useAuth.ts"; import { expectOk, FormError, @@ -30,13 +31,6 @@ import { useApiForm, } from "./form/index.ts"; -function authHeaders(token: string | null, json = true): HeadersInit { - const headers: Record = {}; - if (json) headers["Content-Type"] = "application/json"; - if (token) headers["Authorization"] = `Bearer ${token}`; - return headers; -} - interface CommentFormProps { /** Async submit; throw to surface a root error, resolve to clear/close. */ onSubmit: (body: string) => Promise; @@ -208,13 +202,13 @@ function CommentNode({ const editEditorRef = useRef(null); const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS(); + const { authFetch } = useAuth(); const children = tree.get(comment.id) ?? []; 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", - headers: authHeaders(token), body: JSON.stringify( { body, parentId: comment.id } satisfies CreateCommentRequest, ), @@ -225,9 +219,8 @@ function CommentNode({ async function handleDelete() { 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", - headers: { Authorization: `Bearer ${token}` }, }).catch(() => null); if (res?.ok) { onCommentDeleted(comment.id); @@ -235,9 +228,8 @@ function CommentNode({ } 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", - headers: authHeaders(token), body: JSON.stringify({ body } satisfies UpdateCommentRequest), }); onCommentUpdated(deserializeComment(await expectOk(res))); @@ -452,11 +444,11 @@ export function CommentThread({ }: CommentThreadProps) { const tree = useMemo(() => buildTree(comments), [comments]); const roots = tree.get("root") ?? []; + const { authFetch } = useAuth(); 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", - headers: authHeaders(token), body: JSON.stringify({ body } satisfies CreateCommentRequest), }); onCommentCreated(deserializeComment(await expectOk(res))); diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index d6cf79fa..276a9451 100644 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { createPortal } from "react-dom"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { useScrollLock } from "../hooks/useScrollLock"; +import { useScrollLock } from "../hooks/useScrollLock.ts"; interface ConfirmModalProps { message: string; diff --git a/src/components/DumpCard.tsx b/src/components/DumpCard.tsx index 1df299aa..23bd5477 100644 --- a/src/components/DumpCard.tsx +++ b/src/components/DumpCard.tsx @@ -19,8 +19,8 @@ interface DumpCardProps { voteCount: number; voted: boolean; canVote: boolean; - castVote: (id: string) => void; - removeVote: (id: string) => void; + castVote: (id: string, shownCount?: number) => void; + removeVote: (id: string, shownCount?: number) => void; className?: string; isOwner?: boolean; } diff --git a/src/components/DumpCreateModal.tsx b/src/components/DumpCreateModal.tsx index 1e39ee31..14e38f8c 100644 --- a/src/components/DumpCreateModal.tsx +++ b/src/components/DumpCreateModal.tsx @@ -386,12 +386,19 @@ export function DumpCreateModal( // same timing for the user, just not in the same tick. 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(() => { const encoded = encodeURIComponent(canonicalUrl); - authFetch(`${API_URL}/api/preview?url=${encoded}`) + authFetch(`${API_URL}/api/preview?url=${encoded}`, { signal }) .then((res) => res.json()) .then((body) => { + if (signal.aborted) return; const data = body.success ? body.data as UrlPreviewResponse : undefined; @@ -402,14 +409,16 @@ export function DumpCreateModal( }); }) .catch(() => { + if (signal.aborted) return; setUrlFetchState({ status: "done", reached: false, richContent: null }); }); // Signed in, so that a private dump of the poster's own counts as a // 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((body) => { + if (signal.aborted) return; setDuplicateState({ url: canonicalUrl, matches: body.success @@ -417,13 +426,16 @@ export function DumpCreateModal( : [], }); }) - .catch(() => - setDuplicateState({ url: canonicalUrl, matches: [] }) - ); + .catch(() => { + if (!signal.aborted) { + setDuplicateState({ url: canonicalUrl, matches: [] }); + } + }); }, 600); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); + controller.abort(); }; }, [canonicalUrl, authFetch]); @@ -613,27 +625,22 @@ export function DumpCreateModal( const toggleMembership = async (membership: PlaylistMembership) => { if (!createdDump) return; const { playlist, hasDump } = membership; - if (hasDump) { - await authFetch( + // The box only changes once the server agrees; a refusal or a dropped + // connection leaves it showing the truth. + try { + const res = await authFetch( `${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`, - { method: "DELETE" }, - ); - 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 - ) + { method: hasDump ? "DELETE" : "POST" }, ); + if (!res.ok) return; + } catch { + return; } + setMemberships((prev) => + prev.map((m) => + m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m + ) + ); }; const canProceed = !!file || !!canonicalUrl; diff --git a/src/components/JournalCard.tsx b/src/components/JournalCard.tsx index f4b264dd..4e608c81 100644 --- a/src/components/JournalCard.tsx +++ b/src/components/JournalCard.tsx @@ -24,8 +24,8 @@ interface JournalCardProps { voteCount: number; voted: boolean; canVote: boolean; - castVote: (id: string) => void; - removeVote: (id: string) => void; + castVote: (id: string, shownCount?: number) => void; + removeVote: (id: string, shownCount?: number) => void; isOwner?: boolean; } diff --git a/src/components/LikeButton.tsx b/src/components/LikeButton.tsx index 962539fd..f249d5c9 100644 --- a/src/components/LikeButton.tsx +++ b/src/components/LikeButton.tsx @@ -8,8 +8,8 @@ interface LikeButtonProps { count: number; liked: boolean; disabled?: boolean; - onLike: (commentId: string) => void; - onUnlike: (commentId: string) => void; + onLike: (commentId: string, shownCount?: number) => void; + onUnlike: (commentId: string, shownCount?: number) => void; } export function LikeButton( @@ -25,7 +25,8 @@ export function LikeButton( className={`comment-action-btn${ liked ? " comment-action-btn--liked" : "" }`} - onClick={() => liked ? onUnlike(commentId) : onLike(commentId)} + onClick={() => + liked ? onUnlike(commentId, count) : onLike(commentId, count)} disabled={disabled} aria-label={liked ? t`Remove like` : t`Like`} title={disabled ? t`Log in to like` : undefined} diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 7bb83a0b..8f033c8e 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,4 +1,3 @@ -import { useMemo } from "react"; import { Link } from "react-router"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -9,13 +8,57 @@ interface MarkdownProps { 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 = /(? { + 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["components"]; // 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( { children, className, inline = false }: MarkdownProps, ) { - const processed = useMemo(() => preprocessMentions(children), [children]); - return (
- {processed} + {children}
); diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index b6c57b3c..6d80832c 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,7 +1,7 @@ import { type ReactNode, useCallback, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { t } from "@lingui/core/macro"; -import { useScrollLock } from "../hooks/useScrollLock"; +import { useScrollLock } from "../hooks/useScrollLock.ts"; interface ModalProps { title: string; diff --git a/src/components/VoteButton.tsx b/src/components/VoteButton.tsx index f0f907d6..294c49d4 100644 --- a/src/components/VoteButton.tsx +++ b/src/components/VoteButton.tsx @@ -8,8 +8,8 @@ interface VoteButtonProps { count: number; voted: boolean; disabled?: boolean; - onCast: (dumpId: string) => void; - onRemove: (dumpId: string) => void; + onCast: (dumpId: string, shownCount?: number) => void; + onRemove: (dumpId: string, shownCount?: number) => void; } export function VoteButton( @@ -23,7 +23,7 @@ export function VoteButton(