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(