From 80f3292c8f4907ad7d6ba06b09e48bf46d7bd5be Mon Sep 17 00:00:00 2001 From: khannurien Date: Tue, 29 Sep 2026 20:08:00 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20fix=20the=20frontend's=20findings,=20and?= =?UTF-8?q?=20the=20backend=20pieces=20they=20needed=20=E2=80=94=20each=20?= =?UTF-8?q?by=20a=20rule=20the=20code=20now=20keeps=20everywhere.=20Votes?= =?UTF-8?q?=20and=20likes=20go=20through=20one=20useOptimisticToggles=20in?= =?UTF-8?q?=20WSProvider:=20clicks=20on=20one=20item=20before=20any=20ack?= =?UTF-8?q?=20share=20one=20baseline,=20which=20a=20failure=20or=20the=20t?= =?UTF-8?q?imeout=20restores=20(a=20second=20click=20used=20to=20cancel=20?= =?UTF-8?q?the=20first=20one's=20rollback),=20only=20the=20last=20ack's=20?= =?UTF-8?q?count=20is=20applied,=20a=20votes=5Fupdate=20or=20comment=5Flik?= =?UTF-8?q?es=5Fupdate=20waits=20while=20this=20tab=20has=20toggles=20in?= =?UTF-8?q?=20flight,=20and=20a=20welcome=20reconciles=20them=20against=20?= =?UTF-8?q?the=20server's=20sets=20(a=20vote=20cast=20offline=20left=20its?= =?UTF-8?q?=20count=20one=20too=20high).=20lastLikeEvent,=20which=20nothin?= =?UTF-8?q?g=20read,=20is=20gone.=20Follows=20are=20refetched=20when=20the?= =?UTF-8?q?=20new=20follows=5Fchanged=20message,=20sent=20to=20the=20follo?= =?UTF-8?q?wer's=20sockets=20on=20every=20follow=20write,=20says=20another?= =?UTF-8?q?=20tab=20changed=20them;=20a=20failed=20load=20keeps=20the=20Fo?= =?UTF-8?q?llow=20buttons=20disabled=20and=20is=20retried=2010=20s=20later?= =?UTF-8?q?=20instead=20of=20leaving=20them=20disabled=20for=20good=20(suc?= =?UTF-8?q?cess:=20false)=20or=20enabled=20over=20empty=20sets=20(network?= =?UTF-8?q?=20error);=20follow=20and=20unfollow=20resolve=20to=20whether?= =?UTF-8?q?=20they=20worked,=20and=20the=20button=20says=20when=20they=20d?= =?UTF-8?q?idn't.=20CategoriesProvider=20follows=20the=20same=20rule,=20so?= =?UTF-8?q?=20a=20network=20error=20no=20longer=20turns=20every=20category?= =?UTF-8?q?=20URL=20into=20a=20404.=20Every=20list=20is=20kept=20by=20one?= =?UTF-8?q?=20rule:=20useDumpListSync=20and=20usePlaylistListSync=20never?= =?UTF-8?q?=20add=20an=20item,=20and=20usePositionAwareSync=20remembers=20?= =?UTF-8?q?the=20place=20of=20any=20item=20that=20leaves=20a=20list,=20wha?= =?UTF-8?q?tever=20took=20it=20out,=20and=20puts=20it=20back=20when=20its?= =?UTF-8?q?=20next=20event=20shows=20it=20again;=20with=20dump=5Fcreated?= =?UTF-8?q?=20now=20setting=20lastDumpEvent,=20a=20dump=20going=20private?= =?UTF-8?q?=20and=20back=20returns=20to=20its=20place=20on=20another=20use?= =?UTF-8?q?r's=20profile,=20which=20never=20happened=20before,=20and=20an?= =?UTF-8?q?=20updated=20playlist=20missing=20from=20a=20list=20is=20no=20l?= =?UTF-8?q?onger=20prepended.=20useFeedCache=20keys=20every=20cache=20by?= =?UTF-8?q?=20viewer=20(the=20profile=20pages=20restored=20the=20owner's?= =?UTF-8?q?=20private=20dumps=20to=20whoever=20went=20back)=20and=20reads?= =?UTF-8?q?=20it=20again=20when=20its=20key=20changes=20(another=20profile?= =?UTF-8?q?=20in=20the=20same=20page=20showed=20the=20previous=20one's=20l?= =?UTF-8?q?ist).=20Every=20response=20of=20a=20list's=20first=20page=20or?= =?UTF-8?q?=20next=20pages=20goes=20through=20expectOk=20(Index,=20UserPla?= =?UTF-8?q?ylists,=20useUserDumpFeed=20and=20Notifications=20read=20body.d?= =?UTF-8?q?ata=20unchecked).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smaller fixes: the redirects from / and / keep the navigation state, so the index hides the dump DumpEdit just deleted; Index's mainFetchDone ref, which blocked nothing, is gone; DumpEdit refuses someone else's dump up front, as the API does; the Dump page takes a handed-over dump once and clears it from the history entry, so a reload fetches; a newly upvoted dump is fetched with the viewer's token; the notifications page sends read-all once per notification; the profile's followed and invitees tabs finish loading when left mid-fetch; UserListPopover no longer closes when its own list scrolls; the vote and like counts open their lists for visitors too (a disabled button swallowed the click; they are aria-disabled now); the removal of a playlist's dump forgets it (an edit brought it back), a failed removal puts it back where it was, an undo waits for the removal in flight, and a refused undo says so; PlaylistCreateForm tells whether the dump made it into the new playlist; the cover preview's object URL is revoked; the muted icon has its cross; the bell no longer stays rung after two notifications within 700 ms; a stale invite check can't overwrite a newer one; a stored theme or color scheme is checked; the default feed tab is one store every reader follows, across tabs; the file fields enforce the size they state, derived from DUMP_MAX_FILE_SIZE_BYTES; formatBytes, friendlyFetchError and ImagePicker are translated (Intl names the units), and ImagePicker ignores keys while uploading; visited dumps and playlists keep a week of visits and are parsed once; a chime is claimed under a Web Lock, so two tabs can't both play it; a date in the future reads as "now". Dead code goes: PageShell's centerSlot, overLimit, the exports of isSafeHex and hueFromString, the comment tree's token prop and ChatContext's isChatOpen and closeChat. The French catalogue has the 11 new messages. 214 backend and 632 frontend tests pass; tsc, deno lint, ESLint and doc:lint are clean. Co-Authored-By: Claude Opus 5.5 --- api/services/follow-service.ts | 7 +- api/services/ws-service.ts | 3 +- shared/ws.ts | 30 +- src/App.css | 20 +- src/App.tsx | 25 +- src/components/comment/CommentNode.tsx | 6 - src/components/comment/CommentThread.tsx | 3 - .../dump/create/DumpCreateModal.tsx | 9 +- src/components/feed/FollowedFeed.tsx | 30 +- src/components/form/FileDropZone.tsx | 11 +- src/components/form/FileField.tsx | 21 +- src/components/layout/NotificationBell.tsx | 20 +- src/components/layout/PageShell.tsx | 6 +- src/components/player/PlayerIcons.tsx | 12 +- .../playlist/PlaylistCreateForm.tsx | 14 +- .../playlist/PlaylistMembershipPanel.tsx | 4 +- src/components/shared/ImagePicker.tsx | 14 +- src/components/social/FollowButton.tsx | 75 +- src/components/social/LikeButton.tsx | 10 +- src/components/social/UserListPopover.tsx | 10 +- src/components/social/VoteButton.tsx | 10 +- src/contexts/auth/AuthProvider.tsx | 16 +- src/contexts/category/CategoriesProvider.tsx | 59 +- src/contexts/chat/ChatContext.ts | 20 +- src/contexts/chat/ChatProvider.tsx | 5 +- src/contexts/social/FollowContext.ts | 19 +- src/contexts/social/FollowProvider.tsx | 126 +- src/contexts/theme/ThemeContext.ts | 29 +- src/contexts/theme/ThemeProvider.tsx | 34 +- src/contexts/ws/WSContext.ts | 25 +- src/contexts/ws/WSProvider.tsx | 459 ++++--- src/hooks/dump/useDumpListSync.ts | 40 +- src/hooks/dump/useUserDumpFeed.ts | 7 +- src/hooks/feed/useDefaultFeedTab.ts | 49 +- src/hooks/feed/useFeedCache.ts | 62 +- src/hooks/feed/usePositionAwareSync.ts | 36 +- src/hooks/playlist/usePlaylistListSync.ts | 21 +- src/locales/en.js | 2 +- src/locales/en.po | 1118 +++++++++-------- src/locales/fr.js | 2 +- src/locales/fr.po | 1118 +++++++++-------- src/pages/Dump.tsx | 20 +- src/pages/DumpEdit.tsx | 35 +- src/pages/Index.tsx | 34 +- src/pages/Notifications.tsx | 19 +- src/pages/PlaylistDetail.tsx | 74 +- src/pages/UserDumps.tsx | 3 - src/pages/UserPlaylists.tsx | 12 +- src/pages/UserPublicProfile.tsx | 33 +- src/pages/UserRegister.tsx | 11 +- src/pages/UserUpvoted.tsx | 16 +- .../components/comment/CommentThread.test.tsx | 6 +- src/test/components/dump/FilePreview.test.tsx | 2 +- .../components/layout/ChatButton.test.tsx | 2 +- src/test/components/layout/ChatFab.test.tsx | 2 +- .../layout/NotificationBell.test.tsx | 9 + .../shared/ThumbnailPlaceholder.test.tsx | 4 +- .../social/UserListPopover.test.tsx | 5 +- .../components/social/VoteButton.test.tsx | 19 +- .../category/CategoriesProvider.test.tsx | 24 +- src/test/contexts/chat/ChatProvider.test.tsx | 24 +- .../contexts/social/FollowProvider.test.tsx | 44 +- src/test/contexts/ws/WSProvider.test.tsx | 38 +- src/test/hooks/dump/useDumpListSync.test.tsx | 25 +- .../hooks/feed/usePositionAwareSync.test.tsx | 43 +- .../playlist/usePlaylistListSync.test.tsx | 16 +- src/test/pages/Dump.test.tsx | 37 + src/test/pages/Index.test.tsx | 5 +- src/test/pages/Notifications.test.tsx | 6 + src/test/pages/UserPublicProfile.test.tsx | 15 +- src/test/utils/shared/charCount.test.ts | 6 +- src/test/utils/shared/format.test.ts | 8 +- src/test/utils/shared/sound.test.ts | 15 + src/test/utils/shared/thumbnailTint.test.ts | 54 +- src/test/utils/shared/visited.test.ts | 13 + src/themes/brutalist.css | 2 +- src/themes/kanagawa.css | 2 +- src/utils/shared/api.ts | 10 +- src/utils/shared/charCount.ts | 8 - src/utils/shared/format.ts | 22 +- src/utils/shared/relativeTime.ts | 7 +- src/utils/shared/sound.ts | 23 +- src/utils/shared/thumbnailTint.ts | 4 +- src/utils/shared/visited.ts | 71 +- 84 files changed, 2451 insertions(+), 1964 deletions(-) diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index b3a5af4e..272b2988 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -25,10 +25,11 @@ import { isFollowRow } from "../model/follow.ts"; import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { attachCategoryIds } from "./category-service.ts"; +import { sendToUser } from "./ws-service.ts"; /** * Makes `followerId` follow `followedUserId`, and notifies the followed user. - * Following again changes nothing. + * Following again changes nothing. The follower's other tabs are told. * * @throws {APIException} 400 when a user tries to follow themselves, 404 when * there is no such user. @@ -61,6 +62,7 @@ export function followUser(followerId: string, followedUserId: string): void { } } if (isNew) notifyUserNewFollower(followerId, followedUserId); + sendToUser(followerId, { type: "follows_changed" }); } /** Makes `followerId` stop following `followedUserId`, if they did. */ @@ -68,6 +70,7 @@ export function unfollowUser(followerId: string, followedUserId: string): void { db.prepare( `DELETE FROM follows WHERE follower_id = ? AND followed_user_id = ?;`, ).run(followerId, followedUserId); + sendToUser(followerId, { type: "follows_changed" }); } /** @@ -111,6 +114,7 @@ export function followPlaylist(followerId: string, playlistId: string): void { } } if (isNew) notifyPlaylistOwnerNewFollower(followerId, playlistId); + sendToUser(followerId, { type: "follows_changed" }); } /** Makes `followerId` stop following a playlist, if they did. */ @@ -118,6 +122,7 @@ export function unfollowPlaylist(followerId: string, playlistId: string): void { db.prepare( `DELETE FROM follows WHERE follower_id = ? AND followed_playlist_id = ?;`, ).run(followerId, playlistId); + sendToUser(followerId, { type: "follows_changed" }); } /** diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 5a6a055e..b33cdd81 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -129,7 +129,8 @@ function broadcast( /** * Sends `data` to every socket of `userId`. The notification service sends - * `notification_created` with it, and the route `chat_read_update`. + * `notification_created` with it, the follow service `follows_changed`, and + * the route `chat_read_update`. */ export function sendToUser(userId: string, data: ServerToClientMessage): void { broadcast(data, (client) => client.userId === userId); diff --git a/shared/ws.ts b/shared/ws.ts index 02d60ef1..9ae38985 100644 --- a/shared/ws.ts +++ b/shared/ws.ts @@ -249,9 +249,10 @@ export interface CommentLikeAckMessage { } /** - * Sent by the server to every socket when a public dump is posted, or when a - * private one goes public. `WSProvider` puts it at the top of its recent - * dumps. + * Sent by the server when a dump is posted: to every socket if it is public, + * to its poster's alone if private. Also sent to everyone but the poster when + * a private dump goes public, since to them it is new. `WSProvider` puts it at + * the top of its recent dumps. */ export interface DumpCreatedMessage { type: "dump_created"; @@ -259,8 +260,9 @@ export interface DumpCreatedMessage { } /** - * Sent by the server to every socket when a public dump changes. `WSProvider` - * refreshes it in place, without moving it up the feeds. + * Sent by the server when a dump changes: to every socket if it is public, to + * its poster's alone if private, as when it has just gone private. + * `WSProvider` refreshes it in place, without moving it up the feeds. */ export interface DumpUpdatedMessage { type: "dump_updated"; @@ -268,8 +270,10 @@ export interface DumpUpdatedMessage { } /** - * Sent by the server to every socket when a dump is deleted, or when a public - * one goes private. `WSProvider` drops it from every list. + * Sent by the server when a dump is deleted: to every socket if it was + * public, to its poster's alone if private. Also sent to everyone but the + * poster when a public dump goes private. `WSProvider` drops it from every + * list. */ export interface DumpDeletedMessage { type: "dump_deleted"; @@ -439,6 +443,15 @@ export interface ChatReadUpdateMessage { unread: number; } +/** + * Sent by the server to every socket of a user who followed or unfollowed a + * user or a playlist, so that `FollowProvider` in their other tabs refetches + * the follow status. + */ +export interface FollowsChangedMessage { + type: "follows_changed"; +} + /** * Sent by `services/ws-service.ts` and the WebSocket route, and handled by * `WSProvider`'s `onmessage`. @@ -469,4 +482,5 @@ export type ServerToClientMessage = | ChatMessageUpdatedMessage | ChatMessageDeletedMessage | ChatTypingUpdateMessage - | ChatReadUpdateMessage; + | ChatReadUpdateMessage + | FollowsChangedMessage; diff --git a/src/App.css b/src/App.css index 8e9da460..1954beeb 100644 --- a/src/App.css +++ b/src/App.css @@ -1916,7 +1916,7 @@ body.has-player .chat-fab { -webkit-font-smoothing: antialiased; } -.vote-btn:hover:not(:disabled) { +.vote-btn:hover:not([aria-disabled="true"]) { border-color: var(--color-accent); color: var(--color-accent); } @@ -1927,7 +1927,7 @@ body.has-player .chat-fab { color: var(--color-on-accent); } -.vote-btn--active:hover:not(:disabled) { +.vote-btn--active:hover:not([aria-disabled="true"]) { background: var(--color-accent-hover); border-color: var(--color-accent-hover); color: var(--color-on-accent); @@ -1942,7 +1942,7 @@ body.has-player .chat-fab { color-mix(in srgb, var(--color-accent) 35%, transparent); } -.vote-btn:disabled { +.vote-btn[aria-disabled="true"] { opacity: 0.5; cursor: default; } @@ -2281,6 +2281,12 @@ body.has-player .chat-fab { margin-top: 0.5rem; } +.follow-error { + margin-left: 0.5rem; + font-size: 0.85rem; + color: var(--color-danger); +} + /* ── Profile description ── */ .profile-description { width: 100%; @@ -5523,7 +5529,11 @@ body.has-player .chat-fab { var(--dur-fast); } -.comment-action-btn:hover { +.comment-action-btn[aria-disabled="true"] { + cursor: default; +} + +.comment-action-btn:not([aria-disabled="true"]):hover { color: var(--color-text); border-color: var(--color-border-subtle); background: color-mix(in srgb, var(--color-text) 6%, transparent); @@ -6100,7 +6110,7 @@ body.has-player .chat-fab { border-color: rgba(255, 255, 255, 0.4); background: rgba(0, 0, 0, 0.35); } -.journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not(:disabled) { +.journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not([aria-disabled="true"]) { color: var(--color-accent); border-color: var(--color-accent); } diff --git a/src/App.tsx b/src/App.tsx index df27aa02..5f9d6615 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -121,22 +121,33 @@ function useResolvedDefaultTab() { } /** - * Redirects `/` to the default feed tab. The query string must survive: the - * web app manifest's share target opens `/?share_url=…` for an Android share, - * and only the feed acts on it. + * Redirects `/` to the default feed tab. The query string and the navigation + * state must survive: the web app manifest's share target opens + * `/?share_url=…` for an Android share, and the edit page hands the feed the + * id of a dump it just deleted. */ function IndexRedirect() { - const { search } = useLocation(); - return ; + const { search, state } = useLocation(); + return ( + + ); } -/** Redirects a bare `/` to that category's default feed tab. */ +/** + * Redirects a bare `/` to that category's default feed tab, keeping the + * query string and the navigation state as {@link IndexRedirect} does. + */ function CategoryRedirect() { const { categorySlug } = useParams(); - const { search } = useLocation(); + const { search, state } = useLocation(); return ( ); diff --git a/src/components/comment/CommentNode.tsx b/src/components/comment/CommentNode.tsx index 46765bde..16eb5bd5 100644 --- a/src/components/comment/CommentNode.tsx +++ b/src/components/comment/CommentNode.tsx @@ -44,8 +44,6 @@ export interface CommentNodeProps { depth: number; dumpId: string; currentUser: SelfUser | null; - /** Only checked for being set before a delete. */ - token: string | null; onCommentCreated: (comment: Comment) => void; onCommentDeleted: (commentId: string) => void; onCommentUpdated: (comment: Comment) => void; @@ -63,7 +61,6 @@ export function CommentNode({ depth, dumpId, currentUser, - token, onCommentCreated, onCommentDeleted, onCommentUpdated, @@ -96,7 +93,6 @@ export function CommentNode({ } async function handleDelete() { - if (!token) return; setDeleteFailed(false); const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, { method: "DELETE", @@ -157,7 +153,6 @@ export function CommentNode({ depth={depth + 1} dumpId={dumpId} currentUser={currentUser} - token={token} onCommentCreated={onCommentCreated} onCommentDeleted={onCommentDeleted} onCommentUpdated={onCommentUpdated} @@ -312,7 +307,6 @@ export function CommentNode({ depth={depth + 1} dumpId={dumpId} currentUser={currentUser} - token={token} onCommentCreated={onCommentCreated} onCommentDeleted={onCommentDeleted} onCommentUpdated={onCommentUpdated} diff --git a/src/components/comment/CommentThread.tsx b/src/components/comment/CommentThread.tsx index 6d0fea05..45c7509b 100644 --- a/src/components/comment/CommentThread.tsx +++ b/src/components/comment/CommentThread.tsx @@ -32,7 +32,6 @@ export interface CommentThreadProps { */ comments: Comment[]; currentUser: SelfUser | null; - token: string | null; onCommentCreated: (comment: Comment) => void; onCommentDeleted: (commentId: string) => void; onCommentUpdated: (comment: Comment) => void; @@ -59,7 +58,6 @@ export function CommentThread({ dumpId, comments, currentUser, - token, onCommentCreated, onCommentDeleted, onCommentUpdated, @@ -117,7 +115,6 @@ export function CommentThread({ depth={0} dumpId={dumpId} currentUser={currentUser} - token={token} onCommentCreated={onCommentCreated} onCommentDeleted={onCommentDeleted} onCommentUpdated={onCommentUpdated} diff --git a/src/components/dump/create/DumpCreateModal.tsx b/src/components/dump/create/DumpCreateModal.tsx index ac177000..c122ce5c 100644 --- a/src/components/dump/create/DumpCreateModal.tsx +++ b/src/components/dump/create/DumpCreateModal.tsx @@ -32,7 +32,10 @@ import { import { useAuth } from "../../../hooks/auth/useAuth.ts"; import { useWS } from "../../../hooks/ws/useWS.ts"; import { dumpUrl, normalizeUrl } from "../../../utils/shared/urls.ts"; -import { titleFromFileName } from "../../../utils/shared/format.ts"; +import { + formatBytes, + titleFromFileName, +} from "../../../utils/shared/format.ts"; import { clearDumpDraft, type DumpDraft, @@ -413,7 +416,8 @@ export function DumpCreateModal( } else { if (!title.trim()) throw new Error(t`Title is required.`); if (file.size > DUMP_MAX_FILE_SIZE_BYTES) { - throw new Error(t`File too large (max 50 MB).`); + const max = formatBytes(DUMP_MAX_FILE_SIZE_BYTES); + throw new Error(t`File too large (max ${max}).`); } const formData = new FormData(); formData.append("file", file); @@ -683,6 +687,7 @@ export function DumpCreateModal( onValueChange={selectFile} disabled={submitting} hint={t`Drop a file here, or paste one`} + maxBytes={DUMP_MAX_FILE_SIZE_BYTES} /> )} diff --git a/src/components/feed/FollowedFeed.tsx b/src/components/feed/FollowedFeed.tsx index e7213965..0c10aa94 100644 --- a/src/components/feed/FollowedFeed.tsx +++ b/src/components/feed/FollowedFeed.tsx @@ -54,8 +54,8 @@ async function readPage(res: Response): Promise>> { type FollowedSection = "users" | "playlists"; /** - * Passed to {@link FollowedFeed} by `Index`, with the vote state and deleted - * dumps of `WSContext`. Unlike the other tabs, it loads its own dumps. + * Passed to {@link FollowedFeed} by `Index`, with the vote state of + * `WSContext`. Unlike the other tabs, it loads its own dumps. */ export interface FollowedFeedProps { /** Scopes both sections to a category. */ @@ -66,7 +66,6 @@ export interface FollowedFeedProps { user: SelfUser; castVote: (id: string, shownCount?: number) => void; removeVote: (id: string, shownCount?: number) => void; - deletedDumpIds: Set; } interface FollowedSubFeedProps { @@ -76,7 +75,6 @@ interface FollowedSubFeedProps { user: SelfUser; castVote: (id: string, shownCount?: number) => void; removeVote: (id: string, shownCount?: number) => void; - deletedDumpIds: Set; emptyMessage: string; onLoadMore: () => void; } @@ -89,7 +87,6 @@ function FollowedSubFeed({ user, castVote, removeVote, - deletedDumpIds, emptyMessage, onLoadMore, }: FollowedSubFeedProps) { @@ -108,16 +105,14 @@ function FollowedSubFeed({ return ; } - const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id)); - - if (visible.length === 0) { + if (state.dumps.length === 0) { return

{emptyMessage}

; } return ( <>
    - {visible.map((dump) => ( + {state.dumps.map((dump) => ( ( - `feed:followed-users:${scope}:${user.id}`, + `feed:followed-users:${scope}`, hydrateDump, ); const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache< Dump >( - `feed:followed-playlists:${scope}:${user.id}`, + `feed:followed-playlists:${scope}`, hydrateDump, ); @@ -268,22 +262,24 @@ export function FollowedFeed({ } }, [token, usersState.status, playlistsState.status, catQuery]); + // Only the lists are kept here: `Index` saves and restores the page's scroll + // for every tab, this one included. useScrollSave( usersState.status === "loaded", - useCallback((y) => { + useCallback(() => { if (usersState.status !== "loaded") return; - saveUsers(usersState.dumps, usersState.page, usersState.hasMore, y); + saveUsers(usersState.dumps, usersState.page, usersState.hasMore, 0); }, [usersState, saveUsers]), ); useScrollSave( playlistsState.status === "loaded", - useCallback((y) => { + useCallback(() => { if (playlistsState.status !== "loaded") return; savePlaylists( playlistsState.dumps, playlistsState.page, playlistsState.hasMore, - y, + 0, ); }, [playlistsState, savePlaylists]), ); @@ -376,7 +372,6 @@ export function FollowedFeed({ user={user} castVote={castVote} removeVote={removeVote} - deletedDumpIds={deletedDumpIds} emptyMessage={t`Follow some users to see their dumps here.`} onLoadMore={loadMoreUsers} /> @@ -390,7 +385,6 @@ export function FollowedFeed({ user={user} castVote={castVote} removeVote={removeVote} - deletedDumpIds={deletedDumpIds} emptyMessage={t`Follow some public playlists to see their dumps here.`} onLoadMore={loadMorePlaylists} /> diff --git a/src/components/form/FileDropZone.tsx b/src/components/form/FileDropZone.tsx index d984024d..f262d572 100644 --- a/src/components/form/FileDropZone.tsx +++ b/src/components/form/FileDropZone.tsx @@ -32,9 +32,10 @@ export interface FileDropZoneProps { /** Replaces the "Drop a file here" prompt. */ hint?: string; /** - * Shows "Max 50 MB", which is written out rather than read from - * `DUMP_MAX_FILE_SIZE_BYTES`. The zone doesn't enforce it. + * Stated under the prompt when `showLimit` is on. `FileField` refuses a + * larger file. */ + maxBytes?: number; showLimit?: boolean; } @@ -48,9 +49,11 @@ export function FileDropZone({ onChange, disabled, hint, + maxBytes, showLimit = true, }: FileDropZoneProps): React.JSX.Element { const resolvedHint = hint ?? t`Drop a file here`; + const limit = maxBytes === undefined ? null : formatBytes(maxBytes); const inputRef = useRef(null); const [dragging, setDragging] = useState(false); @@ -161,9 +164,9 @@ export function FileDropZone({ or browse files

    - {showLimit && ( + {showLimit && limit && (

    - Max 50 MB + Max {limit}

    )} diff --git a/src/components/form/FileField.tsx b/src/components/form/FileField.tsx index 0a24fa10..6feb7416 100644 --- a/src/components/form/FileField.tsx +++ b/src/components/form/FileField.tsx @@ -13,7 +13,9 @@ import { useFormContext, } from "react-hook-form"; +import { t } from "@lingui/core/macro"; import { FileDropZone } from "./FileDropZone.tsx"; +import { formatBytes } from "../../utils/shared/format.ts"; /** * Passed to {@link FileField} by a dump form. The field it names holds the @@ -23,6 +25,9 @@ export interface FileFieldProps { name: Path; label?: string; hint?: string; + /** Refuses a larger file, as a field error, as soon as it is picked. */ + maxBytes?: number; + /** States `maxBytes` in the drop zone. */ showLimit?: boolean; disabled?: boolean; rules?: RegisterOptions>; @@ -41,13 +46,22 @@ export function FileField({ name, label, hint, + maxBytes, showLimit, disabled, rules, onValueChange, }: FileFieldProps): React.JSX.Element { - const { control, formState: { errors } } = useFormContext(); + const { control, formState: { errors }, trigger } = useFormContext(); const error = errors[name]; + const fitsLimit = (file: File | null) => { + if (maxBytes === undefined || !file || file.size <= maxBytes) return true; + const limit = formatBytes(maxBytes); + return t`That file is over ${limit}.`; + }; + const validate = typeof rules?.validate === "function" + ? { custom: rules.validate, fitsLimit } + : { ...rules?.validate, fitsLimit }; return (
    @@ -55,15 +69,18 @@ export function FileField({ >} render={({ field }) => ( { field.onChange(file); onValueChange?.(file); + // Said at once, not on submit. + trigger(name); }} hint={hint} + maxBytes={maxBytes} showLimit={showLimit} disabled={disabled} /> diff --git a/src/components/layout/NotificationBell.tsx b/src/components/layout/NotificationBell.tsx index cb35e3ae..ac30444c 100644 --- a/src/components/layout/NotificationBell.tsx +++ b/src/components/layout/NotificationBell.tsx @@ -17,7 +17,11 @@ export function NotificationBell(): React.JSX.Element { const { unreadNotificationCount, lastNotification } = useWS(); const navigate = useNavigate(); const [ringing, setRinging] = useState(false); + // The ring's timers outlive the effect that starts them: a notification + // arriving mid-ring must not cancel its end, or the bell stays rung. + const timersRef = useRef[]>([]); const animatingRef = useRef(false); + useEffect(() => () => timersRef.current.forEach(clearTimeout), []); // Each page mounts its own header, so a notification already in context // must not ring the bell again after a navigation. const lastSeenIdRef = useRef(lastNotification?.id ?? null); @@ -29,15 +33,13 @@ export function NotificationBell(): React.JSX.Element { if (animatingRef.current) return; animatingRef.current = true; - const tStart = setTimeout(() => setRinging(true), 0); - const tEnd = setTimeout(() => { - setRinging(false); - animatingRef.current = false; - }, 700); - return () => { - clearTimeout(tStart); - clearTimeout(tEnd); - }; + timersRef.current = [ + setTimeout(() => setRinging(true), 0), + setTimeout(() => { + setRinging(false); + animatingRef.current = false; + }, 700), + ]; }, [lastNotification]); return ( diff --git a/src/components/layout/PageShell.tsx b/src/components/layout/PageShell.tsx index 0eb3ebb6..b9217073 100644 --- a/src/components/layout/PageShell.tsx +++ b/src/components/layout/PageShell.tsx @@ -13,17 +13,15 @@ export interface PageShellProps { children: ReactNode; /** Centers the content, for a lone card such as a form or an error. */ centered?: boolean; - /** Passed on to `AppHeader`. */ - centerSlot?: ReactNode; } /** Renders {@link AppHeader}, then the page's content as the `
    `. */ export function PageShell( - { children, centered = false, centerSlot }: PageShellProps, + { children, centered = false }: PageShellProps, ): React.JSX.Element { return (
    - +
    diff --git a/src/components/player/PlayerIcons.tsx b/src/components/player/PlayerIcons.tsx index 503f5a0f..fafef5ce 100644 --- a/src/components/player/PlayerIcons.tsx +++ b/src/components/player/PlayerIcons.tsx @@ -33,7 +33,17 @@ export const IconVolume = ( {muted ? ( - + <> + + {/* The cross is stroked: the icon's other shapes are fills. */} + + ) : ( diff --git a/src/components/playlist/PlaylistCreateForm.tsx b/src/components/playlist/PlaylistCreateForm.tsx index 064047be..5c24d3e8 100644 --- a/src/components/playlist/PlaylistCreateForm.tsx +++ b/src/components/playlist/PlaylistCreateForm.tsx @@ -33,7 +33,8 @@ import { VisibilityToggle } from "../form/SegmentedField.tsx"; export interface PlaylistCreateFormProps { /** Added to the new playlist, which the submit button says. */ dumpId?: string; - onCreated: (playlist: Playlist) => void; + /** `hasDump` tells whether `dumpId` made it into the new playlist. */ + onCreated: (playlist: Playlist, hasDump: boolean) => void; onCancel: () => void; className?: string; } @@ -46,7 +47,8 @@ interface Values { /** * Renders the form that creates a playlist with `POST /api/playlists`, then - * adds `dumpId` to it when given. A failure to add the dump goes unreported. + * adds `dumpId` to it when given. The playlist exists even if adding the dump + * fails, so `onCreated` runs either way and says which. */ export function PlaylistCreateForm( { dumpId, onCreated, onCancel, className }: PlaylistCreateFormProps, @@ -72,13 +74,15 @@ export function PlaylistCreateForm( }), ), ); + let hasDump = false; if (dumpId) { - await authFetch( + const res = await authFetch( `${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`, { method: "POST" }, - ); + ).catch(() => null); + hasDump = !!res?.ok; } - onCreated(playlist); + onCreated(playlist, hasDump); }); return ( diff --git a/src/components/playlist/PlaylistMembershipPanel.tsx b/src/components/playlist/PlaylistMembershipPanel.tsx index ffd2a3ec..d732e15b 100644 --- a/src/components/playlist/PlaylistMembershipPanel.tsx +++ b/src/components/playlist/PlaylistMembershipPanel.tsx @@ -91,8 +91,8 @@ export function PlaylistMembershipPanel({ { - onPlaylistCreated({ playlist, hasDump: true }); + onCreated={(playlist, hasDump) => { + onPlaylistCreated({ playlist, hasDump }); setShowNewForm(false); }} onCancel={() => setShowNewForm(false)} diff --git a/src/components/shared/ImagePicker.tsx b/src/components/shared/ImagePicker.tsx index 56ef6c3a..33c5fe31 100644 --- a/src/components/shared/ImagePicker.tsx +++ b/src/components/shared/ImagePicker.tsx @@ -6,6 +6,7 @@ */ import { useRef } from "react"; +import { t } from "@lingui/core/macro"; /** * Passed to {@link ImagePicker} by an edit page, which uploads the file and @@ -43,6 +44,9 @@ export function ImagePicker({ }: ImagePickerProps): React.JSX.Element { const inputRef = useRef(null); const sizeStyle = { width: size, height: size, borderRadius }; + const pick = () => { + if (!uploading) inputRef.current?.click(); + }; const handleChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -55,12 +59,16 @@ export function ImagePicker({
    !uploading && inputRef.current?.click()} - title={src ? "Change image" : "Add image"} + onClick={pick} + title={src ? t`Change image` : t`Add image`} role="button" tabIndex={0} + aria-disabled={uploading} onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); + if (e.key !== "Enter" && e.key !== " ") return; + // Or Space would also scroll the page. + e.preventDefault(); + pick(); }} > {src diff --git a/src/components/social/FollowButton.tsx b/src/components/social/FollowButton.tsx index c92ccb23..9c431035 100644 --- a/src/components/social/FollowButton.tsx +++ b/src/components/social/FollowButton.tsx @@ -1,11 +1,12 @@ /** * Renders the Follow buttons of a user's profile and of a playlist's page. * Both go through `FollowContext`, which follows at once and rolls back when - * the server refuses. + * the request fails; the button then says so beside it. * * @module */ +import { useState } from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useAuth } from "../../hooks/auth/useAuth.ts"; @@ -35,24 +36,33 @@ export function FollowUserButton( ): React.JSX.Element | null { const { user } = useAuth(); const { followedUserIds, followUser, unfollowUser, isLoaded } = useFollows(); + const [failed, setFailed] = useState(false); if (!user || user.id === targetUserId) return null; const isFollowing = followedUserIds.has(targetUserId); return ( - + <> + + {failed && } + ); } @@ -68,23 +78,38 @@ export function FollowPlaylistButton( const { user } = useAuth(); const { followedPlaylistIds, followPlaylist, unfollowPlaylist, isLoaded } = useFollows(); + const [failed, setFailed] = useState(false); if (!user || !isPublic) return null; const isFollowing = followedPlaylistIds.has(targetPlaylistId); return ( - + <> + + {failed && } + + ); +} + +function FollowError(): React.JSX.Element { + return ( + + Couldn't update. Try again. + ); } diff --git a/src/components/social/LikeButton.tsx b/src/components/social/LikeButton.tsx index 734e045d..8dcfe4f4 100644 --- a/src/components/social/LikeButton.tsx +++ b/src/components/social/LikeButton.tsx @@ -44,9 +44,13 @@ export function LikeButton( className={`comment-action-btn${ liked ? " comment-action-btn--liked" : "" }`} - onClick={() => - liked ? onUnlike(commentId, count) : onLike(commentId, count)} - disabled={disabled} + // Not `disabled`, as for VoteButton: its count opens the likers. + onClick={() => { + if (disabled) return; + if (liked) onUnlike(commentId, count); + else onLike(commentId, count); + }} + aria-disabled={disabled} aria-label={liked ? t`Remove like` : t`Like`} title={disabled ? t`Log in to like` : undefined} > diff --git a/src/components/social/UserListPopover.tsx b/src/components/social/UserListPopover.tsx index 24be0935..9452d8dd 100644 --- a/src/components/social/UserListPopover.tsx +++ b/src/components/social/UserListPopover.tsx @@ -24,7 +24,7 @@ import type { PublicUser } from "../../../shared/user.ts"; */ export interface UserListPopoverProps { anchorRef: React.RefObject; - /** Called on an outside click, on Escape, and on any scroll. */ + /** Called on an outside click, on Escape, and on a scroll outside it. */ onClose: () => void; /** * A paginated endpoint answering `PaginatedData`, without a query @@ -120,8 +120,12 @@ export function UserListPopover( function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } - // Captured, so a scroll inside any container closes it too. - function onScroll() { + // Captured, so a scroll inside any container closes it too, but not one + // of its own list, which scrolls through a long list of users. + function onScroll(e: Event) { + if (e.target instanceof Node && popoverRef.current?.contains(e.target)) { + return; + } onClose(); } document.addEventListener("mousedown", onMouseDown); diff --git a/src/components/social/VoteButton.tsx b/src/components/social/VoteButton.tsx index 7e95ee51..6c761368 100644 --- a/src/components/social/VoteButton.tsx +++ b/src/components/social/VoteButton.tsx @@ -41,8 +41,14 @@ export function VoteButton(
    diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index c669a8cf..2001dea6 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -31,7 +31,7 @@ import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts"; import type { Dump } from "../../shared/dump.ts"; import type { PaginatedData, Wire } from "../../shared/api.ts"; -import { friendlyFetchError } from "../utils/shared/api.ts"; +import { expectOk, friendlyFetchError } from "../utils/shared/api.ts"; import { useFeedCache } from "../hooks/feed/useFeedCache.ts"; import { useScrollSave } from "../hooks/feed/useScrollSave.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; @@ -127,7 +127,7 @@ export function Index( ); const { cached, saveState } = useFeedCache( - `feed:index:${categorySlug ?? "all"}:${user?.id ?? "guest"}`, + `feed:index:${categorySlug ?? "all"}`, hydrateDump, ); const [dumpsState, setDumpsState] = useState(() => @@ -141,7 +141,6 @@ export function Index( } : { status: "loading" } ); - const mainFetchDone = useRef(false); // The Web Share Target: Android's share sheet opens `/?share_url=…`, which // the redirect from `/` keeps. The query is dropped from the URL at once, so @@ -156,8 +155,7 @@ export function Index( }, [shareUrl, location.pathname]); useEffect(() => { - if (mainFetchDone.current || cached) return; - mainFetchDone.current = true; + if (cached) return; const controller = new AbortController(); (async () => { try { @@ -165,9 +163,9 @@ export function Index( signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : {}, }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const body = await res.json(); - const { items, hasMore } = body.data as PaginatedData>; + const { items, hasMore } = await expectOk< + PaginatedData> + >(res); setDumpsState({ status: "loaded", dumps: items.map(deserializeDump), @@ -180,10 +178,7 @@ export function Index( setDumpsState({ status: "error", error: friendlyFetchError(err) }); } })(); - return () => { - mainFetchDone.current = false; - controller.abort(); - }; + return () => controller.abort(); }, [cached, token, feedUrl]); const setDumpsItems = useCallback( @@ -207,9 +202,8 @@ export function Index( signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : {}, }) - .then((r) => r.json()) - .then((body) => { - const { items } = body.data as PaginatedData>; + .then((res) => expectOk>>(res)) + .then(({ items }) => { const fresh = items.map(deserializeDump); setDumpsState((s) => { if (s.status !== "loaded") return s; @@ -235,9 +229,8 @@ export function Index( fetch(feedUrl(nextPage), { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) - .then((r) => r.json()) - .then((body) => { - const { items, hasMore } = body.data as PaginatedData>; + .then((res) => expectOk>>(res)) + .then(({ items, hasMore }) => { setDumpsState((s) => s.status === "loaded" ? { @@ -300,8 +293,8 @@ export function Index( ); const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]); - // Dumps deleted since loading are dropped. `justDeletedId` never matches, - // though: DumpEdit navigates to `/`, whose redirect drops the state. + // Dumps deleted since loading are dropped, and the one the edit page just + // deleted before its `dump_deleted` arrives. const combined = useMemo( () => [...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps] @@ -358,7 +351,6 @@ export function Index( user={user} castVote={castVote} removeVote={removeVote} - deletedDumpIds={deletedDumpIds} /> )}
    diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index ed263920..1780a332 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -6,7 +6,7 @@ * @module */ -import { useEffect, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Link } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; @@ -299,6 +299,15 @@ export function Notifications(): React.JSX.Element { arrived: [], }); + // The latest notification the read-all requests have covered, so that one + // arriving while the page is open is marked read once, and the one the bell + // already had isn't marked again after the first load. + const lastNotificationRef = useRef(lastNotification); + const markedReadRef = useRef(lastNotification); + useLayoutEffect(() => { + lastNotificationRef.current = lastNotification; + }); + useEffect(() => { let cancelled = false; (async () => { @@ -330,6 +339,7 @@ export function Notifications(): React.JSX.Element { } // If this fails, the list is still worth showing, and they stay unread // for next time. + markedReadRef.current = lastNotificationRef.current; try { const res = await authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST", @@ -369,6 +379,8 @@ export function Notifications(): React.JSX.Element { const loaded = state.status === "loaded"; useEffect(() => { if (!lastNotification || !loaded) return; + if (markedReadRef.current === lastNotification) return; + markedReadRef.current = lastNotification; authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" }) .then((res) => { if (res.ok) clearUnreadNotifications(); @@ -385,9 +397,8 @@ export function Notifications(): React.JSX.Element { authFetch( `${API_URL}/api/notifications?page=${nextPage}&limit=${NOTIFICATIONS_PAGE_SIZE}`, ) - .then((r) => r.json()) - .then((body) => { - const data = body.data as PaginatedData>; + .then((res) => expectOk>>(res)) + .then((data) => { setState((s) => s.status === "loaded" ? { diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 10614cc8..59c4dabe 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -125,9 +125,12 @@ export function PlaylistDetail(): React.JSX.Element { // For the socket effects, which must not re-run when it changes. const activeDumpIdsRef = useRef(activeDumpIds); - // Every dump seen in the playlist, so that one hidden when it turned private - // comes back when it turns public. + // Every dump of the playlist seen so far, so that one hidden when it turned + // private comes back when it turns public. A dump removed from the playlist + // leaves it. const knownDumpIdsRef = useRef>(new Set()); + // The removals still in flight, by dump, which an undo must wait for. + const removalsRef = useRef>>(new Map()); // The server's last order, where such a dump comes back. const dumpOrderRef = useRef([]); @@ -272,6 +275,9 @@ export function PlaylistDetail(): React.JSX.Element { for (const id of prev) { if (!newIds.has(id)) { + // Shown until now and gone from the list: removed from the + // playlist, not hidden (that comes as `dump_deleted`). + knownDumpIdsRef.current.delete(id); setActiveDumpIds((s) => { const n = new Set(s); n.delete(id); @@ -480,17 +486,31 @@ export function PlaylistDetail(): React.JSX.Element { }; const handleRemoveDump = (dumpId: string) => { - if (!playlistId) return; + if (!playlistId || state.status !== "loaded") return; + const index = state.playlist.dumps.findIndex((d) => d.id === dumpId); + const dump = state.playlist.dumps[index]; + knownDumpIdsRef.current.delete(dumpId); // Removed on the server at once; the undo adds it back. - authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, { - method: "DELETE", - }).then((res) => { + const removal = authFetch( + `${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, + { method: "DELETE" }, + ).then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); }).catch(() => { cancels.current.get(dumpId)?.(); + knownDumpIdsRef.current.add(dumpId); setActiveDumpIds((prev) => new Set([...prev, dumpId])); + // The fade may have taken it out of the list already. + setState((s) => { + if (s.status !== "loaded" || !dump) return s; + if (s.playlist.dumps.some((d) => d.id === dumpId)) return s; + const dumps = [...s.playlist.dumps]; + dumps.splice(Math.min(index, dumps.length), 0, dump); + return { ...s, playlist: { ...s.playlist, dumps } }; + }); setActionError(t`The dump couldn't be removed. Try again.`); - }); + }).finally(() => removalsRef.current.delete(dumpId)); + removalsRef.current.set(dumpId, removal); setActiveDumpIds((prev) => { const n = new Set(prev); n.delete(dumpId); @@ -510,23 +530,41 @@ export function PlaylistDetail(): React.JSX.Element { .filter((d) => restoredIds.has(d.id)) .map((d) => d.id); pendingUndoOrderRef.current = desiredOrder; + knownDumpIdsRef.current.add(dumpId); + // Added back only once the removal has landed, or the two could cross. // The server adds the dump first, so the order is restored after. - authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, { - method: "POST", - }) + (removalsRef.current.get(dumpId) ?? Promise.resolve()) .then(() => - authFetch(`${API_URL}/api/playlists/${playlistId}/order`, { + authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, { + method: "POST", + }) + ) + .then((res) => { + // 409: the removal failed, so it never left. + if (!res.ok && res.status !== 409) { + throw new Error(`HTTP ${res.status}`); + } + return authFetch(`${API_URL}/api/playlists/${playlistId}/order`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify( { dumpIds: desiredOrder } satisfies ReorderPlaylistRequest, ), - }) - ) + }); + }) + .catch(() => { + knownDumpIdsRef.current.delete(dumpId); + setActiveDumpIds((prev) => { + const n = new Set(prev); + n.delete(dumpId); + return n; + }); + startFade(dumpId); + setActionError(t`The dump couldn't be put back. Try again.`); + }) .finally(() => { pendingUndoOrderRef.current = null; - }) - .catch(() => {}); + }); }; const openEdit = () => setEditOpen(true); @@ -817,6 +855,12 @@ function PlaylistEditForm( ) { const { authFetch, mediaToken } = useAuth(); const [imagePreview, setImagePreview] = useState(null); + // Each preview holds the file in memory until its URL is revoked: when the + // next one replaces it, or when the form closes. + useEffect(() => { + if (!imagePreview) return; + return () => URL.revokeObjectURL(imagePreview); + }, [imagePreview]); const form = useApiForm({ defaultValues: { title: playlist.title, diff --git a/src/pages/UserDumps.tsx b/src/pages/UserDumps.tsx index fa52112b..2d8a46cc 100644 --- a/src/pages/UserDumps.tsx +++ b/src/pages/UserDumps.tsx @@ -51,13 +51,10 @@ export function UserDumps(): React.JSX.Element { dumpItems, setItems, lastDumpEvent, - (d) => d.isPrivate, (d) => !d.isPrivate && d.userId === profileUserId, ); useDumpListSync(setItems, { - ownerId: profileUserId ?? undefined, isOwner: isOwnProfile, - skipReinsert: true, }); if (state.status === "loading") { diff --git a/src/pages/UserPlaylists.tsx b/src/pages/UserPlaylists.tsx index fe90e08f..fd51af9c 100644 --- a/src/pages/UserPlaylists.tsx +++ b/src/pages/UserPlaylists.tsx @@ -120,13 +120,11 @@ export function UserPlaylists(): React.JSX.Element { createdItems, setCreated, lastPlaylistItem, - (p) => !p.isPublic, (p) => p.isPublic && p.userId === profileUserId, ); usePlaylistListSync(setCreated, { isOwner: isOwnProfile, ownerId: profileUserId ?? undefined, - skipReinsert: true, }); const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => { @@ -234,9 +232,8 @@ export function UserPlaylists(): React.JSX.Element { `${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }, ) - .then((r) => r.json()) - .then((body) => { - const { items, hasMore } = body.data as PaginatedData>; + .then((res) => expectOk>>(res)) + .then(({ items, hasMore }) => { setState((s) => s.status === "loaded" ? { @@ -274,9 +271,8 @@ export function UserPlaylists(): React.JSX.Element { fetch( `${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, ) - .then((r) => r.json()) - .then((body) => { - const { items, hasMore } = body.data as PaginatedData>; + .then((res) => expectOk>>(res)) + .then(({ items, hasMore }) => { setState((s) => s.status === "loaded" ? { diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index 44f6bd2a..8b25046b 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -337,13 +337,10 @@ export function UserPublicProfile(): React.JSX.Element { dumpItems, setDumps, lastDumpEvent, - (d) => d.isPrivate, (d) => !d.isPrivate && d.userId === profileUserId, ); useDumpListSync(setDumps, { - ownerId: profileUserId ?? undefined, isOwner: isOwnProfile, - skipReinsert: true, }); // A withdrawn vote leaves its dump in the list, for UpvotedDumpList to fade @@ -362,10 +359,9 @@ export function UserPublicProfile(): React.JSX.Element { voteItems, setVotes, lastDumpEvent, - (d) => d.isPrivate, (d) => !d.isPrivate && !withdrawnVoteIdsRef.current.has(d.id), ); - useDumpListSync(setVotes, { skipReinsert: true }); + useDumpListSync(setVotes); const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => { setState((s) => @@ -382,13 +378,11 @@ export function UserPublicProfile(): React.JSX.Element { playlistItems, setPlaylists, lastPlaylistItem, - (p) => !p.isPublic, (p) => p.isPublic && p.userId === profileUserId, ); usePlaylistListSync(setPlaylists, { isOwner: isOwnProfile, ownerId: profileUserId ?? undefined, - skipReinsert: true, }); useEffect(() => { @@ -591,7 +585,10 @@ export function UserPublicProfile(): React.JSX.Element { withdrawnVoteIdsRef.current.add(dumpId); } else { withdrawnVoteIdsRef.current.delete(dumpId); - fetch(`${API_URL}/api/dumps/${dumpId}`) + // Signed in, or a private dump the viewer owns can't be fetched. + fetch(`${API_URL}/api/dumps/${dumpId}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) .then((r) => r.json()) .then((body) => { if (!body.success) return; @@ -613,7 +610,7 @@ export function UserPublicProfile(): React.JSX.Element { }) .catch(() => {}); } - }, [lastVoteEvent, profileUserId]); + }, [lastVoteEvent, profileUserId, token]); // Saves the lists and the scroll position, so that Back restores both. useEffect(() => { @@ -654,6 +651,16 @@ export function UserPublicProfile(): React.JSX.Element { ); }, [playlistFeed, savePlaylists]); + // The two lazy tabs' fetches aren't aborted when the user switches tabs: + // they finish in the background. Only another profile or leaving the page + // aborts them, since their state is reset then anyway. + const followedFetchRef = useRef(null); + const inviteesFetchRef = useRef(null); + useEffect(() => () => { + followedFetchRef.current?.abort(); + inviteesFetchRef.current?.abort(); + }, [username]); + useEffect(() => { if ( tab !== "followed" || followedState !== null || state.status !== "loaded" @@ -663,6 +670,7 @@ export function UserPublicProfile(): React.JSX.Element { // Deferred, since the lint forbids a setState in an effect's body. queueMicrotask(() => setFollowedState({ status: "loading" })); const controller = new AbortController(); + followedFetchRef.current = controller; Promise.all([ fetch( `${API_URL}/api/users/${username}/followed-users?page=1&limit=${DEFAULT_PAGE_SIZE}`, @@ -697,9 +705,8 @@ export function UserPublicProfile(): React.JSX.Element { if (err.name === "AbortError") return; setFollowedState({ status: "error", error: friendlyFetchError(err) }); }); - return () => controller.abort(); - // followedState only guards the first fetch. As a dependency, its change - // to "loading" would abort that fetch. + // followedState only guards the first fetch; as a dependency, its change + // to "loading" would run this again. // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, state.status, username]); @@ -712,6 +719,7 @@ export function UserPublicProfile(): React.JSX.Element { } queueMicrotask(() => setInviteTreeState({ status: "loading" })); const controller = new AbortController(); + inviteesFetchRef.current = controller; fetch( `${API_URL}/api/users/${username}/invitees`, { signal: controller.signal }, @@ -727,7 +735,6 @@ export function UserPublicProfile(): React.JSX.Element { if (err.name === "AbortError") return; setInviteTreeState({ status: "error", error: friendlyFetchError(err) }); }); - return () => controller.abort(); // inviteTreeState only guards the first fetch, as followedState above. // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, state.status, username]); diff --git a/src/pages/UserRegister.tsx b/src/pages/UserRegister.tsx index 13e9654b..d7275b06 100644 --- a/src/pages/UserRegister.tsx +++ b/src/pages/UserRegister.tsx @@ -66,11 +66,18 @@ export function UserRegister(): React.JSX.Element { useEffect(() => { if (!token) return; - fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`) + // Aborted for a newer token, whose answer alone may land. + const controller = new AbortController(); + fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`, { + signal: controller.signal, + }) .then((r) => { setTokenState(r.ok ? { status: "valid" } : { status: "invalid" }); }) - .catch(() => setTokenState({ status: "invalid" })); + .catch((err) => { + if (err.name !== "AbortError") setTokenState({ status: "invalid" }); + }); + return () => controller.abort(); }, [token]); const onSubmit = form.submit(async ({ username, email, password }) => { diff --git a/src/pages/UserUpvoted.tsx b/src/pages/UserUpvoted.tsx index ea2ea381..10d49c1f 100644 --- a/src/pages/UserUpvoted.tsx +++ b/src/pages/UserUpvoted.tsx @@ -32,7 +32,7 @@ import { PageError } from "../components/layout/PageError.tsx"; */ export function UserUpvoted(): React.JSX.Element { const { username } = useParams(); - const { user: me } = useAuth(); + const { user: me, token } = useAuth(); const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS(); useDocumentTitle(username ?? null); @@ -124,7 +124,10 @@ export function UserUpvoted(): React.JSX.Element { } else { setVotedIds((prev) => new Set([...prev, dumpId])); cancelFading(dumpId); - fetch(`${API_URL}/api/dumps/${dumpId}`) + // Signed in, or a private dump the viewer owns can't be fetched. + fetch(`${API_URL}/api/dumps/${dumpId}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) .then((r) => r.json()) .then((body) => { if (!body.success) return; @@ -138,7 +141,14 @@ export function UserUpvoted(): React.JSX.Element { }) .catch(() => {}); } - }, [lastVoteEvent, profileUserId, startFading, cancelFading, setState]); + }, [ + lastVoteEvent, + profileUserId, + startFading, + cancelFading, + setState, + token, + ]); if (state.status === "loading") { return ( diff --git a/src/test/components/comment/CommentThread.test.tsx b/src/test/components/comment/CommentThread.test.tsx index 622ad80a..b40c30e3 100644 --- a/src/test/components/comment/CommentThread.test.tsx +++ b/src/test/components/comment/CommentThread.test.tsx @@ -21,7 +21,6 @@ function setup(initial: Comment[], session: Session | null = me, ws = {}) { dumpId="d1" comments={comments} currentUser={session?.user ?? null} - token={session?.token ?? null} onCommentCreated={(c) => setComments((prev) => [...prev, c])} onCommentDeleted={(id) => setComments((prev) => @@ -67,7 +66,10 @@ describe("CommentThread", () => { .toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Reply" })).not .toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Like" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Like" })).toHaveAttribute( + "aria-disabled", + "true", + ); }); test("posting a comment", async () => { diff --git a/src/test/components/dump/FilePreview.test.tsx b/src/test/components/dump/FilePreview.test.tsx index 46ecf887..c1332006 100644 --- a/src/test/components/dump/FilePreview.test.tsx +++ b/src/test/components/dump/FilePreview.test.tsx @@ -46,7 +46,7 @@ describe("FilePreview", () => { renderWithContexts(); const link = screen.getByRole("link", { name: /Download thing/ }); expect(link).toHaveAttribute("download", "thing"); - expect(link).toHaveTextContent("(2.0 KB)"); + expect(link).toHaveTextContent("(2 kB)"); }); test("compact audio plays in the global player", async () => { diff --git a/src/test/components/layout/ChatButton.test.tsx b/src/test/components/layout/ChatButton.test.tsx index d454d72e..c7270e40 100644 --- a/src/test/components/layout/ChatButton.test.tsx +++ b/src/test/components/layout/ChatButton.test.tsx @@ -11,7 +11,7 @@ describe("ChatButton", () => { const user = userEvent.setup(); renderWithContexts( {} }} + value={{ openChat }} > , diff --git a/src/test/components/layout/ChatFab.test.tsx b/src/test/components/layout/ChatFab.test.tsx index 684fd156..33739c6a 100644 --- a/src/test/components/layout/ChatFab.test.tsx +++ b/src/test/components/layout/ChatFab.test.tsx @@ -8,7 +8,7 @@ import { renderWithContexts } from "../../render.tsx"; function withChat(ui: React.ReactNode, openChat = vi.fn()) { return ( {} }} + value={{ openChat }} > {ui} diff --git a/src/test/components/layout/NotificationBell.test.tsx b/src/test/components/layout/NotificationBell.test.tsx index c410abfc..dcb4a584 100644 --- a/src/test/components/layout/NotificationBell.test.tsx +++ b/src/test/components/layout/NotificationBell.test.tsx @@ -40,6 +40,15 @@ describe("NotificationBell", () => { expect(bell()).toHaveClass("notification-bell--ringing"); act(() => vi.advanceTimersByTime(700)); expect(bell()).not.toHaveClass("notification-bell--ringing"); + // Another in the middle of a ring doesn't leave the bell rung. + rerender(withLast(makeNotification({ id: "n3" }))); + act(() => vi.advanceTimersByTime(100)); + rerender(withLast(makeNotification({ id: "n4" }))); + act(() => vi.advanceTimersByTime(700)); + expect(bell()).not.toHaveClass("notification-bell--ringing"); + rerender(withLast(makeNotification({ id: "n5" }))); + act(() => vi.advanceTimersByTime(10)); + expect(bell()).toHaveClass("notification-bell--ringing"); } finally { vi.useRealTimers(); } diff --git a/src/test/components/shared/ThumbnailPlaceholder.test.tsx b/src/test/components/shared/ThumbnailPlaceholder.test.tsx index 14a31568..6073e497 100644 --- a/src/test/components/shared/ThumbnailPlaceholder.test.tsx +++ b/src/test/components/shared/ThumbnailPlaceholder.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { fireEvent, screen } from "@testing-library/react"; import { ThumbnailPlaceholder } from "../../../components/shared/ThumbnailPlaceholder.tsx"; -import { hueFromString } from "../../../utils/shared/thumbnailTint.ts"; +import { tintFor } from "../../../utils/shared/thumbnailTint.ts"; import { renderWithContexts } from "../../render.tsx"; describe("ThumbnailPlaceholder", () => { @@ -22,7 +22,7 @@ describe("ThumbnailPlaceholder", () => { />, ); expect(box().style.getPropertyValue("--thumb-tint")).toBe( - hueFromString("site.test"), + tintFor({ seed: "site.test" }), ); }); diff --git a/src/test/components/social/UserListPopover.test.tsx b/src/test/components/social/UserListPopover.test.tsx index 51b239ed..c45eb2b3 100644 --- a/src/test/components/social/UserListPopover.test.tsx +++ b/src/test/components/social/UserListPopover.test.tsx @@ -77,7 +77,7 @@ describe("UserListPopover", () => { await screen.findByText("b"); }); - test("closes on Escape, an outside press and a scroll — not on the anchor", async () => { + test("closes on Escape, an outside press and a scroll — not on the anchor or its own scroll", async () => { route("GET /api/x/voters", () => page([])); const onClose = vi.fn(); const user = userEvent.setup(); @@ -85,6 +85,9 @@ describe("UserListPopover", () => { await screen.findByText("No one yet."); await user.click(screen.getByText("anchor")); await user.click(screen.getByText("No one yet.")); + act(() => { + screen.getByText("No one yet.").dispatchEvent(new Event("scroll")); + }); expect(onClose).not.toHaveBeenCalled(); await user.keyboard("{Escape}"); await user.click(screen.getByText("outside")); diff --git a/src/test/components/social/VoteButton.test.tsx b/src/test/components/social/VoteButton.test.tsx index 7aa4b75b..a0e7d583 100644 --- a/src/test/components/social/VoteButton.test.tsx +++ b/src/test/components/social/VoteButton.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import { screen } from "@testing-library/react"; +import { fireEvent, screen } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { VoteButton } from "../../../components/social/VoteButton.tsx"; import { route } from "../../fetch.ts"; @@ -35,20 +35,31 @@ describe("VoteButton", () => { expect(onRemove).toHaveBeenCalledWith("d", 3); }); - test("disabled for guests, with a hint", () => { + test("disabled for guests, with a hint, but its count still opens", async () => { + const onCast = vi.fn(); renderWithContexts( {}} + onCast={onCast} onRemove={() => {}} />, ); const btn = screen.getByRole("button", { name: "Upvote" }); - expect(btn).toBeDisabled(); + expect(btn).toHaveAttribute("aria-disabled", "true"); expect(btn).toHaveAttribute("title", "Log in to vote"); + fireEvent.click(btn); + expect(onCast).not.toHaveBeenCalled(); + // The count still opens who voted. + route( + "GET /api/dumps/:id/voters", + () => page([makeUser({ username: "fan" })]), + ); + fireEvent.click(screen.getByText("1")); + expect(await screen.findByRole("menuitem", { name: /fan/ })) + .toBeInTheDocument(); }); test("the count opens who voted, without voting", async () => { diff --git a/src/test/contexts/category/CategoriesProvider.test.tsx b/src/test/contexts/category/CategoriesProvider.test.tsx index f4aa49ca..0e7ce6b1 100644 --- a/src/test/contexts/category/CategoriesProvider.test.tsx +++ b/src/test/contexts/category/CategoriesProvider.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { act, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { CategoriesProvider } from "../../../contexts/category/CategoriesProvider.tsx"; @@ -30,10 +30,22 @@ describe("CategoriesProvider", () => { expect(result.current.categories).toHaveLength(2); }); - test("a failure still ends loading", async () => { - route("GET /api/categories", () => fail("x", 500)); - const { result } = renderHook(useCategories, { wrapper }); - await waitFor(() => expect(result.current.isLoaded).toBe(true)); - expect(result.current.categories).toEqual([]); + test("a failure isn't taken for an empty list, and is tried again", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + route("GET /api/categories", () => fail("x", 500)); + const { result } = renderHook(useCategories, { wrapper }); + await act(async () => {}); + expect(result.current.isLoaded).toBe(false); + route("GET /api/categories", () => wire([makeCategory({ id: "a" })])); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + await act(async () => {}); + expect(result.current.isLoaded).toBe(true); + expect(result.current.categories).toHaveLength(1); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/src/test/contexts/chat/ChatProvider.test.tsx b/src/test/contexts/chat/ChatProvider.test.tsx index 236f7dd9..5288f695 100644 --- a/src/test/contexts/chat/ChatProvider.test.tsx +++ b/src/test/contexts/chat/ChatProvider.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { act, renderHook, screen } from "@testing-library/react"; +import { act, fireEvent, renderHook, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx"; import { ChatProvider } from "../../../contexts/chat/ChatProvider.tsx"; @@ -20,26 +20,20 @@ describe("ChatProvider", () => { ), }); act(() => guest.result.current.openChat()); - expect(guest.result.current.isChatOpen).toBe(false); + expect(screen.queryByRole("dialog")).toBeNull(); }); test("?chat=open deep-links into it, and closing strips the param", async () => { const session = makeSession(); route("GET /api/chat", () => ({ items: [], hasMore: false })); - let chat!: ReturnType; - function Probe() { - chat = useChat(); - return null; - } - renderApp(, { session, route: "/somewhere?chat=open&x=1" }); - expect(chat.isChatOpen).toBe(true); + renderApp(<>, { session, route: "/somewhere?chat=open&x=1" }); await screen.findByRole("dialog", { name: "Chat" }); - act(() => chat.closeChat()); - expect(chat.isChatOpen).toBe(false); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).toBeNull(); expect(currentPath()).toBe("/somewhere?x=1"); }); - test("openChat / closeChat", () => { + test("openChat opens it, and its close button closes it", async () => { const session = makeSession(); route("GET /api/chat", () => ({ items: [], hasMore: false })); let chat!: ReturnType; @@ -49,8 +43,8 @@ describe("ChatProvider", () => { } renderApp(, { session }); act(() => chat.openChat()); - expect(chat.isChatOpen).toBe(true); - act(() => chat.closeChat()); - expect(chat.isChatOpen).toBe(false); + await screen.findByRole("dialog", { name: "Chat" }); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).toBeNull(); }); }); diff --git a/src/test/contexts/social/FollowProvider.test.tsx b/src/test/contexts/social/FollowProvider.test.tsx index e8da0127..a272899f 100644 --- a/src/test/contexts/social/FollowProvider.test.tsx +++ b/src/test/contexts/social/FollowProvider.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { act, renderHook, waitFor } from "@testing-library/react"; import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx"; import { FollowProvider } from "../../../contexts/social/FollowProvider.tsx"; @@ -48,11 +48,18 @@ describe("FollowProvider", () => { await waitFor(() => expect(result.current.isLoaded).toBe(true)); route("POST /api/follows/users/:id", () => ({})); - await act(() => result.current.followUser("u2")); + let ok: boolean | undefined; + await act(async () => { + ok = await result.current.followUser("u2"); + }); + expect(ok).toBe(true); expect(result.current.followedUserIds.has("u2")).toBe(true); route("DELETE /api/follows/users/:id", () => fail("no", 500)); - await act(() => result.current.unfollowUser("u2")); + await act(async () => { + ok = await result.current.unfollowUser("u2"); + }); + expect(ok).toBe(false); expect(result.current.followedUserIds.has("u2")).toBe(true); route("POST /api/follows/playlists/:id", () => new TypeError("offline")); @@ -68,4 +75,35 @@ describe("FollowProvider", () => { "/api/follows/playlists/p2", ); }); + + test("a failed load keeps the buttons disabled, and is tried again", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const session = makeSession(); + storeSession(session); + route("GET /api/users/me", () => wire(session.user)); + route("GET /api/follows/status", () => fail("down", 500)); + const { result } = renderHook(useFollows, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + await act(async () => {}); + expect(result.current.isLoaded).toBe(false); + route("GET /api/follows/status", () => ({ + followedUserIds: ["u1"], + followedPlaylistIds: [], + })); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + await act(async () => {}); + expect(result.current.isLoaded).toBe(true); + expect(result.current.followedUserIds.has("u1")).toBe(true); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/test/contexts/ws/WSProvider.test.tsx b/src/test/contexts/ws/WSProvider.test.tsx index 24466fa3..12cf3278 100644 --- a/src/test/contexts/ws/WSProvider.test.tsx +++ b/src/test/contexts/ws/WSProvider.test.tsx @@ -275,6 +275,43 @@ describe("votes", () => { act(() => vi.advanceTimersByTime(10_000)); expect(ws().myVotes.has("d1")).toBe(true); }); + + test("a welcome says a vote never landed, and its count goes back", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().castVote("d1", 4)); + expect(ws().voteCounts.d1).toBe(5); + socket.welcome({ myVotes: [] }); + expect(ws().myVotes.has("d1")).toBe(false); + expect(ws().voteCounts.d1).toBe(4); + }); + + test("clicks before an ack share one rollback, and the last ack decides", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().castVote("d1", 4)); + act(() => ws().removeVote("d1")); + act(() => ws().castVote("d1")); + expect(ws().voteCounts.d1).toBe(5); + // The first ack doesn't flip the button back while others are in flight. + socket.receive({ + type: "vote_ack", + dumpId: "d1", + action: "cast", + voteCount: 5, + }); + socket.receive({ + type: "vote_ack", + dumpId: "d1", + action: "remove", + voteCount: 4, + }); + expect(ws().myVotes.has("d1")).toBe(true); + // The last one is lost: the timer restores the state before the clicks. + act(() => vi.advanceTimersByTime(5_000)); + expect(ws().myVotes.has("d1")).toBe(false); + expect(ws().voteCounts.d1).toBe(4); + }); }); describe("comment likes", () => { @@ -325,7 +362,6 @@ describe("comment likes", () => { }); expect(ws().myLikes.has("c2")).toBe(true); expect(ws().likeCounts.c2).toBe(3); - expect(ws().lastLikeEvent?.commentId).toBe("c2"); }); }); diff --git a/src/test/hooks/dump/useDumpListSync.test.tsx b/src/test/hooks/dump/useDumpListSync.test.tsx index 9b33e8f0..bf3abe54 100644 --- a/src/test/hooks/dump/useDumpListSync.test.tsx +++ b/src/test/hooks/dump/useDumpListSync.test.tsx @@ -72,27 +72,10 @@ describe("useDumpListSync", () => { expect(owner.hook.result.current.items[0].isPrivate).toBe(true); }); - test("re-inserts only the owner's dumps, and only when asked", () => { - const mine = makeDump({ id: "m", userId: "me" }); - const theirs = makeDump({ id: "t", userId: "them" }); - - const none = setup([]); - none.emit({ lastDumpEvent: mine }); - expect(none.hook.result.current.items).toEqual([]); - - const owned = setup([], { ownerId: "me" }); - owned.emit({ lastDumpEvent: theirs }); - expect(owned.hook.result.current.items).toEqual([]); - owned.emit({ lastDumpEvent: mine }); - expect(ids(owned.hook.result.current.items)).toEqual(["m"]); - - const skip = setup([], { ownerId: "me", skipReinsert: true }); - skip.emit({ lastDumpEvent: mine }); - expect(skip.hook.result.current.items).toEqual([]); - - const privateOne = setup([], { ownerId: "me" }); - privateOne.emit({ lastDumpEvent: { ...mine, isPrivate: true } }); - expect(privateOne.hook.result.current.items).toEqual([]); + test("never adds a dump the list doesn't hold", () => { + const { hook, emit } = setup([]); + emit({ lastDumpEvent: makeDump({ id: "m", userId: "me" }) }); + expect(hook.result.current.items).toEqual([]); }); test("comment events move the count, never below zero; edits don't", () => { diff --git a/src/test/hooks/feed/usePositionAwareSync.test.tsx b/src/test/hooks/feed/usePositionAwareSync.test.tsx index efa329b2..6c207056 100644 --- a/src/test/hooks/feed/usePositionAwareSync.test.tsx +++ b/src/test/hooks/feed/usePositionAwareSync.test.tsx @@ -10,6 +10,7 @@ import type { Dump } from "../../../../shared/dump.ts"; import type { Playlist } from "../../../../shared/playlist.ts"; import { useDumpListSync } from "../../../hooks/dump/useDumpListSync.ts"; import { usePositionAwareSync } from "../../../hooks/feed/usePositionAwareSync.ts"; +import { useWS } from "../../../hooks/ws/useWS.ts"; import { makeDump, makePlaylist } from "../../fixtures.ts"; /** A WS context whose value a test can push new events into. */ @@ -42,14 +43,8 @@ describe("usePositionAwareSync", () => { const both = renderHook(() => { const { items, setItems } = useList(initial); const [event, setEvent] = useState(null); - usePositionAwareSync( - items, - setItems, - event, - (d) => d.isPrivate, - (d) => !d.isPrivate, - ); - useDumpListSync(setItems, { ownerId: "me", skipReinsert: true }); + usePositionAwareSync(items, setItems, event, (d) => !d.isPrivate); + useDumpListSync(setItems); return { items, setEvent }; }, { wrapper: ws.Wrapper }); @@ -63,30 +58,40 @@ describe("usePositionAwareSync", () => { expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]); }); + test("a dump that goes private for others, as a deletion, comes back where it was", () => { + const ws = liveWS(); + const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" })); + const both = renderHook(() => { + const { items, setItems } = useList(initial); + const { lastDumpEvent } = useWS(); + usePositionAwareSync(items, setItems, lastDumpEvent, (d) => !d.isPrivate); + useDumpListSync(setItems); + return { items }; + }, { wrapper: ws.Wrapper }); + ws.emit({ deletedDumpIds: new Set(["b"]) }); + expect(ids(both.result.current.items)).toEqual(["a", "c"]); + ws.emit({ deletedDumpIds: new Set(), lastDumpEvent: initial[1] }); + expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]); + }); + test("a position is only used once, and clamps to the list end", () => { const initial = ["a", "b", "c"].map((id) => makePlaylist({ id })); const hook = renderHook(() => { const { items, setItems } = useList(initial); const [event, setEvent] = useState(null); - usePositionAwareSync( - items, - setItems, - event, - (p) => !p.isPublic, - (p) => p.isPublic, - ); + usePositionAwareSync(items, setItems, event, (p) => p.isPublic); return { items, setItems, setEvent }; }); const c = initial[2]; - act(() => hook.result.current.setEvent({ ...c, isPublic: false })); + // It leaves, whatever takes it out, then the list shrinks further. + act(() => hook.result.current.setItems((prev) => prev.slice(0, 2))); act(() => hook.result.current.setItems((prev) => prev.slice(0, 1))); act(() => hook.result.current.setEvent({ ...c, isPublic: true })); expect(ids(hook.result.current.items)).toEqual(["a", "c"]); - // Nothing saved any more: a second public event is left alone. - act(() => hook.result.current.setItems((prev) => prev.slice(0, 1))); + // Used once: the same event again adds nothing. act(() => hook.result.current.setEvent({ ...c, isPublic: true, title: "x" }) ); - expect(ids(hook.result.current.items)).toEqual(["a"]); + expect(ids(hook.result.current.items)).toEqual(["a", "c"]); }); }); diff --git a/src/test/hooks/playlist/usePlaylistListSync.test.tsx b/src/test/hooks/playlist/usePlaylistListSync.test.tsx index 6d813256..575a6ffb 100644 --- a/src/test/hooks/playlist/usePlaylistListSync.test.tsx +++ b/src/test/hooks/playlist/usePlaylistListSync.test.tsx @@ -87,7 +87,7 @@ describe("usePlaylistListSync", () => { expect(followed.hook.result.current.items).toEqual([]); }); - test("updated: in place, removed when private, re-added when public", () => { + test("updated: in place, removed when private, never re-added by itself", () => { const p = makePlaylist({ id: "p", title: "one" }); const { hook, emit } = setup([p]); emit({ @@ -113,18 +113,8 @@ describe("usePlaylistListSync", () => { playlist: { ...p, title: "back" }, }, }); - expect(hook.result.current.items[0].title).toBe("back"); - }); - - test("updated and not in the list: skipReinsert and noNewEntries hold it back", () => { - const p = makePlaylist({ id: "p" }); - for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) { - const { hook, emit } = setup([], opts); - emit({ - lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p }, - }); - expect(hook.result.current.items).toEqual([]); - } + // usePositionAwareSync puts it back, where it was. + expect(hook.result.current.items).toEqual([]); }); test("deleted and deletedPlaylistIds both remove", () => { diff --git a/src/test/pages/Dump.test.tsx b/src/test/pages/Dump.test.tsx index 816497ee..65ac12db 100644 --- a/src/test/pages/Dump.test.tsx +++ b/src/test/pages/Dump.test.tsx @@ -328,6 +328,16 @@ describe("DumpEdit page", () => { }, }); + test("someone else's dump gets no form", async () => { + openEdit(urlDump(), other); + expect( + await screen.findByText( + "Only its poster or a moderator can edit this dump.", + ), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + }); + test("saves title, note, categories and visibility", async () => { route( "PUT /api/dumps/:id", @@ -466,6 +476,33 @@ describe("DumpEdit page", () => { expect(calls("PUT /api/dumps/:id")).toHaveLength(0); }); + test("a replacement over the limit is refused before any upload", async () => { + const fileDump = makeDump({ + id: "f1", + kind: "file", + title: "Song", + userId: "owner", + fileName: "song.mp3", + fileMime: "audio/mpeg", + fileSize: 10, + url: undefined, + }); + const user = openEdit(fileDump); + await screen.findByText("song.mp3"); + const big = new File([new Uint8Array(3)], "big.mp3", { + type: "audio/mpeg", + }); + Object.defineProperty(big, "size", { value: 60 * 1024 * 1024 }); + await user.upload( + document.querySelector('.fdz input[type="file"]')!, + big, + ); + expect(await screen.findByText("That file is over 50 MB.")) + .toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(calls("PUT /api/dumps/:id/file")).toHaveLength(0); + }); + test("deleting asks, then goes home", async () => { route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 })); const user = openEdit(urlDump()); diff --git a/src/test/pages/Index.test.tsx b/src/test/pages/Index.test.tsx index 173d2bf4..723d9b7f 100644 --- a/src/test/pages/Index.test.tsx +++ b/src/test/pages/Index.test.tsx @@ -157,7 +157,10 @@ describe("Index feeds", () => { route("GET /api/dumps/", () => page([older])); setup("/~/new"); await screen.findByText("Old but loved"); - expect(screen.getByRole("button", { name: "Upvote" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Upvote" })).toHaveAttribute( + "aria-disabled", + "true", + ); }); test("a reconnect backfills what was missed", async () => { diff --git a/src/test/pages/Notifications.test.tsx b/src/test/pages/Notifications.test.tsx index 09ffdd21..f4594c4b 100644 --- a/src/test/pages/Notifications.test.tsx +++ b/src/test/pages/Notifications.test.tsx @@ -250,6 +250,12 @@ describe("Notifications", () => { expect(items).toHaveLength(2); expect(items[0]).toHaveTextContent("bob upvoted Pic"); expect(items[0]).toHaveClass("notification-item--unread"); + // The read-all after the load covered them: none is sent again. + await waitFor(() => + expect(calls("POST /api/notifications/read-all")).toHaveLength(1) + ); + await new Promise((r) => setTimeout(r, 50)); + expect(calls("POST /api/notifications/read-all")).toHaveLength(1); }); test("load more", async () => { diff --git a/src/test/pages/UserPublicProfile.test.tsx b/src/test/pages/UserPublicProfile.test.tsx index 2783c725..4f1b2f43 100644 --- a/src/test/pages/UserPublicProfile.test.tsx +++ b/src/test/pages/UserPublicProfile.test.tsx @@ -3,7 +3,7 @@ import { screen, waitFor, within } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { UserPublicProfile } from "../../pages/UserPublicProfile.tsx"; import type { PublicUser } from "../../../shared/user.ts"; -import { calls, fail, route } from "../fetch.ts"; +import { calls, deferred, fail, route } from "../fetch.ts"; import { makeCategory, makeDump, @@ -127,6 +127,19 @@ describe("UserPublicProfile", () => { .toBeInTheDocument(); }); + test("followed: leaving the tab while it loads still loads it", async () => { + const users = deferred(); + route("GET /api/users/:u/followed-users", () => users.promise); + route("GET /api/users/:u/followed-playlists", () => page([])); + const { user } = open({ url: "/users/ann/~/followed" }); + await screen.findByRole("heading", { name: "ann" }); + await user.click(screen.getByRole("button", { name: "Playlists" })); + users.resolve(page([makeUser({ username: "bob" })])); + await user.click(screen.getByRole("button", { name: "Followed" })); + expect(await screen.findByRole("link", { name: /@bob/ })) + .toBeInTheDocument(); + }); + test("invitees as a tree", async () => { route("GET /api/users/:u/invitees", () => wire([ diff --git a/src/test/utils/shared/charCount.test.ts b/src/test/utils/shared/charCount.test.ts index 239cdca5..a4014367 100644 --- a/src/test/utils/shared/charCount.test.ts +++ b/src/test/utils/shared/charCount.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { charCountClass, overLimit } from "../../../utils/shared/charCount.ts"; +import { charCountClass } from "../../../utils/shared/charCount.ts"; describe("character limits", () => { test("charCountClass warns from 85% and flags the limit", () => { @@ -7,8 +7,4 @@ describe("character limits", () => { expect(charCountClass(85, 100)).toBe(" text-editor-count--warn"); expect(charCountClass(100, 100)).toBe(" text-editor-count--danger"); }); - test("overLimit is strictly past it", () => { - expect(overLimit("abc", 3)).toBe(false); - expect(overLimit("abcd", 3)).toBe(true); - }); }); diff --git a/src/test/utils/shared/format.test.ts b/src/test/utils/shared/format.test.ts index adb88396..ba57377b 100644 --- a/src/test/utils/shared/format.test.ts +++ b/src/test/utils/shared/format.test.ts @@ -5,10 +5,10 @@ import { } from "../../../utils/shared/format.ts"; describe("formatBytes", () => { - test("B, KB and MB", () => { - expect(formatBytes(512)).toBe("512 B"); - expect(formatBytes(1536)).toBe("1.5 KB"); - expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB"); + test("bytes, kilobytes and megabytes, in the app's language", () => { + expect(formatBytes(512)).toBe("512B"); + expect(formatBytes(1536)).toBe("1.5 kB"); + expect(formatBytes(5 * 1024 * 1024)).toBe("5 MB"); }); }); diff --git a/src/test/utils/shared/sound.test.ts b/src/test/utils/shared/sound.test.ts index e1be94bd..b0f8b18b 100644 --- a/src/test/utils/shared/sound.test.ts +++ b/src/test/utils/shared/sound.test.ts @@ -38,6 +38,11 @@ async function load() { return await import("../../../utils/shared/sound.ts"); } +// playChime claims the event asynchronously, under a lock shared by tabs. +const settle = async () => { + for (let i = 0; i < 3; i++) await Promise.resolve(); +}; + const notes = () => FakeAudioContext.instances.reduce((n, c) => n + c.oscillators, 0); @@ -71,8 +76,10 @@ describe("playChime", () => { test("a notification plays two notes, a chat message one", async () => { const s = await load(); s.playChime("notify", "n1"); + await settle(); expect(notes()).toBe(2); s.playChime("chat", "c1"); + await settle(); expect(notes()).toBe(3); }); @@ -80,13 +87,16 @@ describe("playChime", () => { const s = await load(); s.writeSoundsEnabled(false); s.playChime("notify", "n1"); + await settle(); expect(notes()).toBe(0); }); test("one chime per event across tabs", async () => { const s = await load(); s.playChime("notify", "same"); + await settle(); s.playChime("notify", "same"); + await settle(); expect(notes()).toBe(2); expect(localStorage.getItem("sounds-last-claim")).toBe("notify:same"); }); @@ -94,19 +104,24 @@ describe("playChime", () => { test("chat blips are throttled", async () => { const s = await load(); s.playChime("chat", "a"); + await settle(); s.playChime("chat", "b"); + await settle(); expect(notes()).toBe(1); vi.advanceTimersByTime(4_001); s.playChime("chat", "c"); + await settle(); expect(notes()).toBe(2); }); test("a suspended context stays quiet until a gesture unlocks it", async () => { const s = await load(); s.playChime("notify", "warmup"); // creates the context + await settle(); const ctx = FakeAudioContext.instances[0]; ctx.state = "suspended"; s.playChime("notify", "n2"); + await settle(); expect(ctx.oscillators).toBe(2); globalThis.dispatchEvent(new Event("keydown")); expect(ctx.resume).toHaveBeenCalled(); diff --git a/src/test/utils/shared/thumbnailTint.test.ts b/src/test/utils/shared/thumbnailTint.test.ts index fbd532bc..eba34db9 100644 --- a/src/test/utils/shared/thumbnailTint.test.ts +++ b/src/test/utils/shared/thumbnailTint.test.ts @@ -1,37 +1,33 @@ import { describe, expect, test } from "vitest"; -import { - hueFromString, - initialsFor, - isSafeHex, - tintFor, -} from "../../../utils/shared/thumbnailTint.ts"; +import { initialsFor, tintFor } from "../../../utils/shared/thumbnailTint.ts"; + +const hue = (tint: string) => Number(/hsl\((\d+)/.exec(tint)?.[1]); describe("thumbnail tint", () => { - test("isSafeHex accepts only canonical #rrggbb", () => { - expect(isSafeHex("#a1b2c3")).toBe(true); - expect(isSafeHex("#A1B2C3")).toBe(false); - expect(isSafeHex("#abc")).toBe(false); - expect(isSafeHex("red")).toBe(false); - expect(isSafeHex("#000000;background:url(x)")).toBe(false); - expect(isSafeHex(undefined)).toBe(false); + test("tintFor takes only a canonical #rrggbb page colour", () => { + const url = "https://a.b"; + expect(tintFor({ accentColor: "#a1b2c3", url })).toBe("#a1b2c3"); + for ( + const unsafe of [ + "#A1B2C3", + "#abc", + "red", + "#000000;background:url(x)", + undefined, + ] + ) { + expect(tintFor({ accentColor: unsafe, url })).toMatch(/^hsl\(/); + } }); - test("hueFromString is stable and in range", () => { - expect(hueFromString("example.com")).toBe(hueFromString("example.com")); - const hue = Number(/hsl\((\d+)/.exec(hueFromString("x"))![1]); - expect(hue).toBeGreaterThanOrEqual(0); - expect(hue).toBeLessThan(360); - }); - - test("tintFor prefers the page colour, then the hostname sans www", () => { - expect(tintFor({ accentColor: "#112233", url: "https://a.b" })).toBe( - "#112233", - ); - expect(tintFor({ accentColor: "evil", url: "https://www.a.b/x" })).toBe( - hueFromString("a.b"), - ); - expect(tintFor({ seed: "file.mp3" })).toBe(hueFromString("file.mp3")); - expect(tintFor({ url: "not a url" })).toBe(hueFromString("not a url")); + test("tintFor falls back to a stable hue of the seed or the hostname sans www", () => { + const fromHost = tintFor({ url: "https://www.a.b/x" }); + expect(tintFor({ url: "https://a.b/y" })).toBe(fromHost); + expect(tintFor({ seed: "a.b" })).toBe(fromHost); + expect(tintFor({ seed: "file.mp3" })).toBe(tintFor({ seed: "file.mp3" })); + expect(tintFor({ url: "not a url" })).toBe(tintFor({ seed: "not a url" })); + expect(hue(fromHost)).toBeGreaterThanOrEqual(0); + expect(hue(fromHost)).toBeLessThan(360); }); test("initialsFor takes the first letter or digit", () => { diff --git a/src/test/utils/shared/visited.test.ts b/src/test/utils/shared/visited.test.ts index d8957193..1ebb992f 100644 --- a/src/test/utils/shared/visited.test.ts +++ b/src/test/utils/shared/visited.test.ts @@ -24,6 +24,19 @@ describe("visited", () => { expect(isDumpVisited("a")).toBe(true); }); + test("the old list format still counts, and week-old visits are dropped", () => { + const day = 24 * 3600 * 1000; + localStorage.setItem("visited_dumps", JSON.stringify(["old"])); + expect(isDumpVisited("old")).toBe(true); + localStorage.setItem( + "visited_dumps", + JSON.stringify({ stale: Date.now() - 8 * day, fresh: Date.now() }), + ); + markDumpVisited("new"); + expect(Object.keys(JSON.parse(localStorage.getItem("visited_dumps")!))) + .toEqual(["fresh", "new"]); + }); + test("isRecent is a seven-day window", () => { const day = 24 * 3600 * 1000; expect(isRecent(new Date(Date.now() - 6 * day))).toBe(true); diff --git a/src/themes/brutalist.css b/src/themes/brutalist.css index fb9cc8bc..adcc135d 100644 --- a/src/themes/brutalist.css +++ b/src/themes/brutalist.css @@ -127,7 +127,7 @@ [data-style="brutalist"] .modal-close-btn:hover, [data-style="brutalist"] .playlist-remove-btn:hover, [data-style="brutalist"] .playlist-card-delete-btn:hover, -[data-style="brutalist"] .vote-btn:hover:not(:disabled), +[data-style="brutalist"] .vote-btn:hover:not([aria-disabled="true"]), [data-style="brutalist"] .emoji-picker-float [frimousse-emoji]:hover, [data-style="brutalist"] .emoji-picker-close-btn:hover, [data-style="brutalist"] .text-editor-emoji-btn:hover:not(:disabled), diff --git a/src/themes/kanagawa.css b/src/themes/kanagawa.css index 3e2a0f0b..a688a622 100644 --- a/src/themes/kanagawa.css +++ b/src/themes/kanagawa.css @@ -171,7 +171,7 @@ --journal-title-hover-shadow: inset 2px 0 0 var(--kngw-accent-on-photo); } -[data-style="kanagawa"] .journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not(:disabled) { +[data-style="kanagawa"] .journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not([aria-disabled="true"]) { color: var(--kngw-accent-on-photo); border-color: var(--kngw-accent-on-photo); } diff --git a/src/utils/shared/api.ts b/src/utils/shared/api.ts index 062cb7b5..33ea0308 100644 --- a/src/utils/shared/api.ts +++ b/src/utils/shared/api.ts @@ -5,6 +5,7 @@ * @module */ +import { t } from "@lingui/core/macro"; import type { APIResponse } from "../../../shared/api.ts"; /** @@ -17,19 +18,20 @@ export function parseAPIResponse(raw: unknown): APIResponse { if (raw !== null && typeof raw === "object" && "success" in raw) { return raw as APIResponse; } - throw new Error("Unexpected response format"); + throw new Error(t`Unexpected response format`); } /** * Returns the message to show for `err`, an error caught around a request. A * `TypeError` means the request never reached the server (no network, CORS), - * and gets a message saying so. The messages are not translated. + * and gets a message saying so. The server's own messages come through as + * they are. */ export function friendlyFetchError(err: unknown): string { if (err instanceof TypeError) { - return "Could not connect to the server. Check your connection and try again."; + return t`Could not connect to the server. Check your connection and try again.`; } - return err instanceof Error ? err.message : "An unexpected error occurred."; + return err instanceof Error ? err.message : t`An unexpected error occurred.`; } /** diff --git a/src/utils/shared/charCount.ts b/src/utils/shared/charCount.ts index 3731b406..5ea0e664 100644 --- a/src/utils/shared/charCount.ts +++ b/src/utils/shared/charCount.ts @@ -15,11 +15,3 @@ export function charCountClass(len: number, max: number): string { if (len >= max * 0.85) return " text-editor-count--warn"; return ""; } - -/** - * Checks whether `value` is longer than `max`, which the server would refuse. - * Only the tests call it. - */ -export function overLimit(value: string, max: number): boolean { - return value.length > max; -} diff --git a/src/utils/shared/format.ts b/src/utils/shared/format.ts index d9bdd818..de372f7b 100644 --- a/src/utils/shared/format.ts +++ b/src/utils/shared/format.ts @@ -5,14 +5,26 @@ * @module */ +import { i18n } from "@lingui/core"; + /** - * Returns `bytes` as a size in B, KB or MB, where a KB is 1024 bytes. The units - * are not translated. + * Returns `bytes` as a size in bytes, kilobytes or megabytes, where a + * kilobyte is 1024 bytes, in the app's language ("1.5 kB", "1,5 ko"). + * `Intl` names the units, so they follow the language without a catalog. */ export function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + const [value, unit] = bytes < 1024 + ? [bytes, "byte"] + : bytes < 1024 * 1024 + ? [bytes / 1024, "kilobyte"] + : [bytes / (1024 * 1024), "megabyte"]; + return new Intl.NumberFormat(i18n.locale, { + style: "unit", + unit, + // English's short form of a byte is "byte", even for 512. + unitDisplay: unit === "byte" ? "narrow" : "short", + maximumFractionDigits: 1, + }).format(value); } /** diff --git a/src/utils/shared/relativeTime.ts b/src/utils/shared/relativeTime.ts index 88c9ef0d..35ad0d46 100644 --- a/src/utils/shared/relativeTime.ts +++ b/src/utils/shared/relativeTime.ts @@ -10,12 +10,13 @@ import { i18n } from "@lingui/core"; /** * Returns how long ago `date` was, as "3 minutes ago", in the current locale. * It is computed at render, so the text only moves on when the caller - * re-renders. A month counts 30 days and a year 365. + * re-renders. A month counts 30 days and a year 365. Every date shown is of + * something that happened, so one in the future, which only a clock ahead of + * this one can give, reads as "now". */ export function relativeTime(date: Date): string { const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" }); - const diff = date.getTime() - Date.now(); - const abs = Math.abs(diff) / 1000; + const abs = Math.max(0, Date.now() - date.getTime()) / 1000; if (abs < 60) return rtf.format(-Math.round(abs), "second"); if (abs < 3600) return rtf.format(-Math.round(abs / 60), "minute"); diff --git a/src/utils/shared/sound.ts b/src/utils/shared/sound.ts index dd5f115f..eddbd46b 100644 --- a/src/utils/shared/sound.ts +++ b/src/utils/shared/sound.ts @@ -75,11 +75,8 @@ function unlock() { globalThis.addEventListener("pointerdown", unlock); globalThis.addEventListener("keydown", unlock); -/** - * Claims `eventId` for this tab, and returns whether no other tab had. Two tabs - * checking at the same instant can both win. - */ -function claim(eventId: string): boolean { +/** Checks `CLAIM_KEY` and sets it to `eventId`, in one step or not at all. */ +function checkAndSet(eventId: string): boolean { try { if (localStorage.getItem(CLAIM_KEY) === eventId) return false; localStorage.setItem(CLAIM_KEY, eventId); @@ -89,6 +86,17 @@ function claim(eventId: string): boolean { return true; } +/** + * Claims `eventId` for this tab, and resolves to whether no other tab had. The + * check and the write run under a lock shared by the site's tabs, so two tabs + * can't both win; a browser without Web Locks goes without it. + */ +function claim(eventId: string): Promise { + const locks = globalThis.navigator?.locks; + if (!locks) return Promise.resolve(checkAndSet(eventId)); + return locks.request(CLAIM_KEY, () => checkAndSet(eventId)); +} + /** * Plays one note of `freq` Hz on `ac`, from `start` for `duration` seconds, * peaking at gain `peak`. @@ -141,8 +149,9 @@ export function playChime(kind: ChimeKind, eventId: string): void { if (now - lastChatAt < CHAT_THROTTLE) return; lastChatAt = now; } - if (!claim(`${kind}:${eventId}`)) return; - render(kind); + void claim(`${kind}:${eventId}`).then((won) => { + if (won) render(kind); + }); } /** diff --git a/src/utils/shared/thumbnailTint.ts b/src/utils/shared/thumbnailTint.ts index 05f3e579..242198b8 100644 --- a/src/utils/shared/thumbnailTint.ts +++ b/src/utils/shared/thumbnailTint.ts @@ -12,7 +12,7 @@ * property is substituted verbatim wherever it is used, unparsed. So the form * is checked here before it reaches a style. */ -export function isSafeHex(value: string | undefined): value is string { +function isSafeHex(value: string | undefined): value is string { return !!value && /^#[0-9a-f]{6}$/.test(value); } @@ -33,7 +33,7 @@ function hashString(seed: string): number { * Returns a colour of a hue drawn from `seed`, so that the same site always * gets the same tint. */ -export function hueFromString(seed: string): string { +function hueFromString(seed: string): string { return `hsl(${hashString(seed) % 360} 55% 55%)`; } diff --git a/src/utils/shared/visited.ts b/src/utils/shared/visited.ts index e7695b97..6a87f292 100644 --- a/src/utils/shared/visited.ts +++ b/src/utils/shared/visited.ts @@ -10,32 +10,65 @@ const PLAYLIST_KEY = "visited_playlists"; /** The age past which an item shows no unread dot, visited or not. */ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; -function loadSet(key: string): Set { - try { - const raw = localStorage.getItem(key); - return raw ? new Set(JSON.parse(raw) as string[]) : new Set(); - } catch { - return new Set(); - } -} +// Each key's visits, id to epoch ms, with the stored string they were parsed +// from: reading the string is cheap, and it is parsed again only when it +// changed (another tab, or cleared storage). +const cache = new Map< + string, + { raw: string | null; visits: Map } +>(); -function saveSet(key: string, set: Set): void { +function load(key: string): Map { + let raw: string | null; try { - localStorage.setItem(key, JSON.stringify([...set])); - } catch { /* With storage full or blocked, the visit is forgotten. */ } + raw = localStorage.getItem(key); + } catch { + // Blocked storage: whatever this page recorded. + return cache.get(key)?.visits ?? new Map(); + } + const cached = cache.get(key); + if (cached && cached.raw === raw) return cached.visits; + let visits = new Map(); + try { + const parsed = JSON.parse(raw ?? "{}") as Record | string[]; + // A list from before visits had times counts as visited now. + visits = Array.isArray(parsed) + ? new Map(parsed.map((id) => [id, Date.now()])) + : new Map(Object.entries(parsed)); + } catch { + // Unreadable: nothing visited. + } + cache.set(key, { raw, visits }); + return visits; } /** - * Checks whether dump `id` was opened in this browser. It parses the whole - * stored list on every call. + * Records `id` as visited under `key`, and drops the visits older than 7 days: + * an item was at most that old when visited, so past then it shows no dot + * anyway, and the list stays small. */ +function record(key: string, id: string): void { + const visits = load(key); + const now = Date.now(); + visits.set(id, now); + for (const [visitedId, at] of visits) { + if (now - at > MAX_AGE_MS) visits.delete(visitedId); + } + const raw = JSON.stringify(Object.fromEntries(visits)); + cache.set(key, { raw, visits }); + try { + localStorage.setItem(key, raw); + } catch { /* With storage full or blocked, the visit lasts until reload. */ } +} + +/** Checks whether dump `id` was opened in this browser. */ export function isDumpVisited(id: string): boolean { - return loadSet(DUMP_KEY).has(id); + return load(DUMP_KEY).has(id); } /** Checks whether playlist `id` was opened in this browser. */ export function isPlaylistVisited(id: string): boolean { - return loadSet(PLAYLIST_KEY).has(id); + return load(PLAYLIST_KEY).has(id); } /** @@ -43,16 +76,12 @@ export function isPlaylistVisited(id: string): boolean { * keeps its dot until it renders again. */ export function markDumpVisited(id: string): void { - const set = loadSet(DUMP_KEY); - set.add(id); - saveSet(DUMP_KEY, set); + record(DUMP_KEY, id); } /** Records playlist `id` as opened, as {@link markDumpVisited} does a dump. */ export function markPlaylistVisited(id: string): void { - const set = loadSet(PLAYLIST_KEY); - set.add(id); - saveSet(PLAYLIST_KEY, set); + record(PLAYLIST_KEY, id); } /**