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); } /**