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

@@ -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 (

View File

@@ -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<Values>({
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);
});

View File

@@ -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<string, string> = {};
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<void>;
@@ -208,13 +202,13 @@ function CommentNode({
const editEditorRef = useRef<TextEditorHandle>(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<RawComment>(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<RawComment>(res)));

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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 = /(?<!\w)@(\w+)/g;
// Convert bare @username (not already inside a markdown link) to clickable links
function preprocessMentions(text: string): string {
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)");
interface MdNode {
type: string;
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"];
// 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 (
<div
className={`md${className ? ` ${className}` : ""}${
@@ -57,7 +98,7 @@ export function Markdown(
? INLINE_MARKDOWN_COMPONENTS
: MARKDOWN_COMPONENTS}
>
{processed}
{children}
</ReactMarkdown>
</div>
);

View File

@@ -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;

View File

@@ -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(
<button
type="button"
className={`vote-btn${voted ? " vote-btn--active" : ""}`}
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)}
onClick={() => voted ? onRemove(dumpId, count) : onCast(dumpId, count)}
disabled={disabled}
aria-label={voted ? t`Remove vote` : t`Upvote`}
title={disabled ? t`Log in to vote` : undefined}