v3: a performance pass over both halves — indexed queries, streamed files, cached previews, and feeds that stop re-rendering on every tick — a review turned up whole-file reads, unindexed scans and render storms; each fix comes with a test. Database: migration 0016 adds the indexes the hot queries lacked — dumps by created_at, (user_id, created_at) and slug, votes by (user_id, created_at), playlists by slug, users by lower(username) for @mentions, and the child side of every cascading foreign key (comment replies, a user's comments, follows both ways, chat messages, invites, reset tokens); the feeds, dump and playlist pages, upvotes and follower fan-out all plan as index searches now, with no temp-table sort. SQLite runs in WAL with synchronous=NORMAL and a busy timeout, and a transaction() helper wraps the multi-row writes (a dump and its categories, category sets, backlinks, attachment links, playlist reorders, follower notifications) so they commit once instead of once a row. Lists no longer run a COUNT(*) per page: they fetch limit + 1 rows and hasMore comes from the extra one; total stays only on search, the one place it's shown. Files: /api/files read the whole upload (up to 50 MB) for every range request a player makes while seeking; api/lib/serve-file.ts streams the requested bytes from disk, with an ETag and 304s, and serves avatars, attachments, covers and thumbnails too. File and thumbnail URLs carry the file's version (size and last change), so the current one is cached for a year and anything else revalidates — a same-size replacement used to stay stale, and a video's still was cached immutable forever under a URL that never changed. Feeds showed image dumps at full size: /api/thumbnails now makes a 960px preview with ffmpeg (GIFs and unreadable images go out as they are), one run per output however many cards ask at once, and ffmpeg is probed once per process instead of spawned per request. Uploads stream to disk; replacing a file writes beside it and renames over, a hard link keeping the old one for rollback instead of holding both in memory; generated stills and previews go when the file is replaced or the dump deleted, and a custom thumbnail with its dump. Outbound: fetchWithTimeout's deadline now covers the body — a server that answered at once and then dripped could hold a create open indefinitely — and pages are read to 4 MB at most; metadata is cached five minutes so the create form's preview and the post that follows share one fetch (refresh-metadata still goes to the page); proxied images sit in a 32 MB LRU. Broadcasts serialize once rather than per socket, and index.html is kept in memory until the file changes. Frontend: currentTime lived in PlayerContext, so every card re-rendered — and re-parsed its Markdown — four times a second while anything played; the playhead has its own PlayerTimeContext now. The socket context is split three ways (feed, chat via useWSChat, presence via useWSPresence), so typing, presence and chat messages leave the feeds alone. DumpCard, JournalCard and Markdown are memoized; card images use the server preview and load lazily; the feed cache is written when the page is left rather than after every pause in scrolling; the theme context value is memoized.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 16:35:51 +00:00
parent 364c498a24
commit de5abeceb2
74 changed files with 1795 additions and 792 deletions

View File

@@ -42,6 +42,8 @@ export function Avatar(
title={username}
style={sizeStyle}
className="avatar-img"
loading="lazy"
decoding="async"
onError={() => setImgFailed(true)}
/>
);

View File

@@ -1,10 +1,10 @@
import { t } from "@lingui/core/macro";
import { useWS } from "../hooks/useWS.ts";
import { useWSChat } from "../hooks/useWS.ts";
import { useChat } from "../hooks/useChat.ts";
/** Header chat button with an unread-message badge. Opens the app-wide chat. */
export function ChatButton() {
const { unreadChatCount } = useWS();
const { unreadChatCount } = useWSChat();
const { openChat } = useChat();
return (

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { useLocation } from "react-router";
import { t } from "@lingui/core/macro";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useWSChat } from "../hooks/useWS.ts";
import { useChat } from "../hooks/useChat.ts";
/**
@@ -12,7 +12,7 @@ import { useChat } from "../hooks/useChat.ts";
*/
export function ChatFab() {
const { user } = useAuth();
const { unreadChatCount } = useWS();
const { unreadChatCount } = useWSChat();
const { openChat } = useChat();
const location = useLocation();
const [headerHidden, setHeaderHidden] = useState(false);

View File

@@ -17,7 +17,7 @@ import { Avatar } from "./Avatar.tsx";
import { ConfirmModal } from "./ConfirmModal.tsx";
import { Tooltip } from "./Tooltip.tsx";
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { useWS } from "../hooks/useWS.ts";
import { useWS, useWSChat } from "../hooks/useWS.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { can } from "../utils/permissions.ts";
import { relativeTime } from "../utils/relativeTime.ts";
@@ -553,8 +553,8 @@ type Row =
};
export function ChatModal({ onClose }: { onClose: () => void }) {
const { wsStatus, connectionEpoch } = useWS();
const {
wsStatus,
chatMessages,
deletedChatIds,
pendingChatMessages,
@@ -571,8 +571,7 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
reconcileChat,
setChatTyping,
setChatOpen,
connectionEpoch,
} = useWS();
} = useWSChat();
const { authFetch, user } = useAuth();
const canModerate = can(user, "chat:moderate");
const userId = user?.id;

View File

@@ -1,3 +1,4 @@
import { memo } from "react";
import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../model.ts";
@@ -25,7 +26,9 @@ interface DumpCardProps {
isOwner?: boolean;
}
export function DumpCard(
// Memoized: a feed re-renders on every live event (a vote anywhere, a new
// dump), and a card whose props haven't changed has nothing to redraw.
export const DumpCard = memo(function DumpCard(
{ dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }:
DumpCardProps,
) {
@@ -139,4 +142,4 @@ export function DumpCard(
</div>
</li>
);
}
});

View File

@@ -4,7 +4,11 @@ import { formatBytes } from "../utils/format.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx";
import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts";
import {
PlayerContext,
type PlayerItem,
PlayerTimeContext,
} from "../contexts/PlayerContext.ts";
import {
BAR_GAP,
BAR_W,
@@ -50,13 +54,15 @@ function filePlayerItem(
}
// Waveform preview for the dump detail page — routes to global player,
// reflects live play state and position from PlayerContext.
// reflects live play state and position from PlayerContext/PlayerTimeContext.
function AudioFilePreview(
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
) {
const { mediaToken: token } = useAuth();
const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
useContext(PlayerContext);
const { current, playing, play, togglePlay, seekTo } = useContext(
PlayerContext,
);
const { currentTime, duration } = useContext(PlayerTimeContext);
const [peaks, setPeaks] = useState<Float32Array | null>(null);
const isActive = current?.kind === "file" && current.fileUrl === fileUrl;
const progress = isActive && duration > 0 ? currentTime / duration : 0;
@@ -159,6 +165,8 @@ function VideoThumb({ src, fallback }: { src: string; fallback: string }) {
src={src}
alt=""
className="rich-content-compact-thumbnail"
loading="lazy"
decoding="async"
onError={() => setFailed(true)}
/>
);
@@ -186,11 +194,15 @@ export default function FilePreview(
if (compact) {
if (mime.startsWith("image/")) {
// The server's preview (or the custom thumbnail): a card doesn't need
// the full upload.
return (
<img
src={thumbOverride ?? fileUrl}
src={dumpThumbnailUrl(dump, token)}
alt={dump.fileName}
className="rich-content-compact-thumbnail"
loading="lazy"
decoding="async"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}

View File

@@ -1,8 +1,9 @@
import { memo } from "react";
import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDumpCardLink } from "../hooks/useDumpCardLink.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
@@ -33,7 +34,8 @@ interface JournalCardProps {
isOwner?: boolean;
}
export function JournalCard(
// Memoized, like DumpCard: live events re-render the feed, not every card.
export const JournalCard = memo(function JournalCard(
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
JournalCardProps,
) {
@@ -51,13 +53,13 @@ export function JournalCard(
}
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
// still here too, rather than degrading to a text card with a 🎬.
// still here too, rather than degrading to a text card with a 🎬. An image
// shows the server's scaled-down preview, not the full upload.
const thumbnailUrl = dump.thumbnailMime
? dumpThumbnailUrl(dump, mediaToken)
: dump.kind === "file"
? (dump.fileMime?.startsWith("image/")
? dumpFileUrl(dump, mediaToken)
: dump.fileMime?.startsWith("video/")
? (dump.fileMime?.startsWith("image/") ||
dump.fileMime?.startsWith("video/")
? dumpThumbnailUrl(dump, mediaToken)
: null)
: (dump.richContent?.thumbnailUrl ?? null);
@@ -224,4 +226,4 @@ export function JournalCard(
{footer}
</li>
);
}
});

View File

@@ -1,3 +1,4 @@
import { memo } from "react";
import { Link } from "react-router";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -135,7 +136,9 @@ const INLINE_MARKDOWN_COMPONENTS: Components = {
img: () => null,
};
export function Markdown(
// Memoized: parsing runs the whole remark pipeline, and the text rarely
// changes when the component around it re-renders.
export const Markdown = memo(function Markdown(
{ children, className, inline = false }: MarkdownProps,
) {
return (
@@ -152,4 +155,4 @@ export function Markdown(
</ReactMarkdown>
</div>
);
}
});

View File

@@ -42,6 +42,8 @@ export function PlaylistCard(
src={playlistImageUrl(playlist)}
alt=""
className="playlist-card-img"
loading="lazy"
decoding="async"
/>
)
: <span className="playlist-card-icon">📋</span>}

View File

@@ -2,7 +2,7 @@ import { useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Avatar } from "./Avatar.tsx";
import { useWS } from "../hooks/useWS.ts";
import { useWSPresence } from "../hooks/useWS.ts";
import { useRoomBeside } from "../hooks/useRoomBeside.ts";
// Past this many, the row ends in a "+N" counter rather than growing, so a
@@ -24,7 +24,7 @@ const SEAT_STEP = 22;
export function PresenceRow(
{ fitWithin, fitBeside }: { fitWithin?: string; fitBeside?: string },
) {
const { onlineUsers } = useWS();
const { onlineUsers } = useWSPresence();
const [el, setEl] = useState<HTMLDivElement | null>(null);
const room = useRoomBeside(el, { within: fitWithin, beside: fitBeside });

View File

@@ -64,6 +64,7 @@ export default function Thumbnail(
alt={alt}
className={className}
loading={loading}
decoding="async"
referrerPolicy="no-referrer"
onError={() =>
setAttempt(

View File

@@ -68,8 +68,6 @@ export interface PlayerContextValue {
// Playback state — readable by any consumer
current: PlayerItem | null;
playing: boolean;
currentTime: number;
duration: number;
// Queue. Every playback is a queue of at least one item, so single tracks and
// albums share one code path. `current` is always `queue[queueIndex]`.
@@ -115,8 +113,6 @@ export interface PlayerContextValue {
export const defaultPlayerValue: PlayerContextValue = {
current: null,
playing: false,
currentTime: 0,
duration: 0,
queue: [],
queueIndex: 0,
hasNext: false,
@@ -143,3 +139,22 @@ export const defaultPlayerValue: PlayerContextValue = {
export const PlayerContext = createContext<PlayerContextValue>(
defaultPlayerValue,
);
/**
* The playhead, apart from the rest: it moves several times a second while
* something plays, and only what draws a position should re-render with it —
* not every card that reads `current` or `playing`.
*/
export interface PlayerTimeValue {
currentTime: number;
duration: number;
}
export const defaultPlayerTimeValue: PlayerTimeValue = {
currentTime: 0,
duration: 0,
};
export const PlayerTimeContext = createContext<PlayerTimeValue>(
defaultPlayerTimeValue,
);

View File

@@ -3,6 +3,7 @@ import {
PlayerContext,
type PlayerItem,
playerItemKey,
PlayerTimeContext,
} from "./PlayerContext.ts";
import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts";
import { useAuth } from "../hooks/useAuth.ts";
@@ -392,8 +393,6 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
const value = useMemo(() => ({
current,
playing,
currentTime,
duration,
queue,
queueIndex,
hasNext,
@@ -418,8 +417,6 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
}), [
current,
playing,
currentTime,
duration,
queue,
queueIndex,
hasNext,
@@ -441,9 +438,16 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
onError,
]);
const timeValue = useMemo(
() => ({ currentTime, duration }),
[currentTime, duration],
);
return (
<PlayerContext.Provider value={value}>
{children}
<PlayerTimeContext.Provider value={timeValue}>
{children}
</PlayerTimeContext.Provider>
</PlayerContext.Provider>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
type ColorScheme,
type StyleName,
@@ -37,15 +37,15 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem("color-scheme", colorScheme);
}, [colorScheme]);
const value = useMemo(() => ({
style,
colorScheme,
setStyle: setStyleState,
setColorScheme: setColorSchemeState,
}), [style, colorScheme]);
return (
<ThemeContext.Provider
value={{
style,
colorScheme,
setStyle: setStyleState,
setColorScheme: setColorSchemeState,
}}
>
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);

View File

@@ -65,10 +65,15 @@ export interface ChatError {
message: string;
}
/**
* The socket's state, split three ways by how often each part changes, so a
* consumer re-renders only with what it reads: typing and presence update
* every few seconds while people are around, chat with every message, and a
* feed has no use for either.
*/
export interface WSContextValue {
wsStatus: "connecting" | "connected" | "disconnected";
wsErrorMessage: string | null;
onlineUsers: OnlineUser[];
voteCounts: Record<string, number>;
myVotes: Set<string>;
likeCounts: Record<string, number>;
@@ -84,6 +89,19 @@ export interface WSContextValue {
lastUserEvent: UserEvent | null;
unreadNotificationCount: number;
lastNotification: Notification | null;
/** Increments on each WS reconnect so pages can backfill missed events. */
connectionEpoch: number;
/** `shownCount`: the count on screen, used until a live one arrives. */
castVote: (dumpId: string, shownCount?: number) => void;
removeVote: (dumpId: string, shownCount?: number) => void;
castCommentLike: (commentId: string, shownCount?: number) => void;
removeCommentLike: (commentId: string, shownCount?: number) => void;
injectDump: (dump: Dump) => void;
clearUnreadNotifications: () => void;
}
/** The chat room: its live stream, unread state and what to send into it. */
export interface WSChatContextValue {
/** Live buffer of chat messages received this session (capped). */
chatMessages: ChatMessage[];
/** Ids of chat messages deleted live this session, so fetched history can
@@ -100,15 +118,6 @@ export interface WSContextValue {
/** Users currently composing a chat message (excludes nobody — the chatbox
* filters out the current user itself). Shaped like presence for avatars. */
typingUsers: OnlineUser[];
/** Increments on each WS reconnect so pages can backfill missed events. */
connectionEpoch: number;
/** `shownCount`: the count on screen, used until a live one arrives. */
castVote: (dumpId: string, shownCount?: number) => void;
removeVote: (dumpId: string, shownCount?: number) => void;
castCommentLike: (commentId: string, shownCount?: number) => void;
removeCommentLike: (commentId: string, shownCount?: number) => void;
injectDump: (dump: Dump) => void;
clearUnreadNotifications: () => void;
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
retryChatMessage: (nonce: string) => void;
discardChatMessage: (nonce: string) => void;
@@ -129,11 +138,15 @@ export interface WSContextValue {
setChatOpen: (open: boolean) => void;
}
/** Who's connected. */
export interface WSPresenceContextValue {
onlineUsers: OnlineUser[];
}
/** What a consumer outside any provider gets. */
export const defaultWSValue: WSContextValue = {
wsStatus: "connecting",
wsErrorMessage: null,
onlineUsers: [],
voteCounts: {},
myVotes: new Set(),
likeCounts: {},
@@ -149,13 +162,6 @@ export const defaultWSValue: WSContextValue = {
lastUserEvent: null,
unreadNotificationCount: 0,
lastNotification: null,
chatMessages: [],
deletedChatIds: new Set(),
unreadChatCount: 0,
chatReadAt: null,
pendingChatMessages: [],
chatError: null,
typingUsers: [],
connectionEpoch: 0,
castVote: () => {},
removeVote: () => {},
@@ -163,6 +169,16 @@ export const defaultWSValue: WSContextValue = {
removeCommentLike: () => {},
injectDump: () => {},
clearUnreadNotifications: () => {},
};
export const defaultWSChatValue: WSChatContextValue = {
chatMessages: [],
deletedChatIds: new Set(),
unreadChatCount: 0,
chatReadAt: null,
pendingChatMessages: [],
chatError: null,
typingUsers: [],
sendChatMessage: () => {},
retryChatMessage: () => {},
discardChatMessage: () => {},
@@ -175,4 +191,14 @@ export const defaultWSValue: WSContextValue = {
setChatOpen: () => {},
};
export const defaultWSPresenceValue: WSPresenceContextValue = {
onlineUsers: [],
};
export const WSContext = createContext<WSContextValue>(defaultWSValue);
export const WSChatContext = createContext<WSChatContextValue>(
defaultWSChatValue,
);
export const WSPresenceContext = createContext<WSPresenceContextValue>(
defaultWSPresenceValue,
);

View File

@@ -16,8 +16,12 @@ import {
type PlaylistEvent,
type UserEvent,
type VoteEvent,
WSChatContext,
type WSChatContextValue,
WSContext,
type WSContextValue,
WSPresenceContext,
type WSPresenceContextValue,
} from "./WSContext.ts";
import {
type AvatarOverride,
@@ -1130,7 +1134,6 @@ export function WSProvider({ children }: WSProviderProps) {
const value: WSContextValue = useMemo(() => ({
wsStatus,
wsErrorMessage,
onlineUsers,
voteCounts,
myVotes,
likeCounts,
@@ -1146,13 +1149,6 @@ export function WSProvider({ children }: WSProviderProps) {
lastUserEvent,
unreadNotificationCount,
lastNotification,
chatMessages,
deletedChatIds,
unreadChatCount,
chatReadAt,
pendingChatMessages,
chatError,
typingUsers,
connectionEpoch,
castVote,
removeVote,
@@ -1160,6 +1156,41 @@ export function WSProvider({ children }: WSProviderProps) {
removeCommentLike,
injectDump,
clearUnreadNotifications,
}), [
wsStatus,
wsErrorMessage,
voteCounts,
myVotes,
likeCounts,
myLikes,
recentDumps,
deletedDumpIds,
lastVoteEvent,
lastLikeEvent,
lastDumpEvent,
lastPlaylistEvent,
deletedPlaylistIds,
lastCommentEvent,
lastUserEvent,
unreadNotificationCount,
lastNotification,
connectionEpoch,
castVote,
removeVote,
castCommentLike,
removeCommentLike,
injectDump,
clearUnreadNotifications,
]);
const chatValue: WSChatContextValue = useMemo(() => ({
chatMessages,
deletedChatIds,
unreadChatCount,
chatReadAt,
pendingChatMessages,
chatError,
typingUsers,
sendChatMessage,
retryChatMessage,
discardChatMessage,
@@ -1171,24 +1202,6 @@ export function WSProvider({ children }: WSProviderProps) {
setChatTyping,
setChatOpen,
}), [
wsStatus,
wsErrorMessage,
onlineUsers,
voteCounts,
myVotes,
likeCounts,
myLikes,
recentDumps,
deletedDumpIds,
lastVoteEvent,
lastLikeEvent,
lastDumpEvent,
lastPlaylistEvent,
deletedPlaylistIds,
lastCommentEvent,
lastUserEvent,
unreadNotificationCount,
lastNotification,
chatMessages,
deletedChatIds,
unreadChatCount,
@@ -1196,13 +1209,6 @@ export function WSProvider({ children }: WSProviderProps) {
pendingChatMessages,
chatError,
typingUsers,
connectionEpoch,
castVote,
removeVote,
castCommentLike,
removeCommentLike,
injectDump,
clearUnreadNotifications,
sendChatMessage,
retryChatMessage,
discardChatMessage,
@@ -1215,11 +1221,20 @@ export function WSProvider({ children }: WSProviderProps) {
setChatOpen,
]);
const presenceValue: WSPresenceContextValue = useMemo(
() => ({ onlineUsers }),
[onlineUsers],
);
return (
<WSContext.Provider value={value}>
<AvatarOverrideContext.Provider value={avatarOverrides}>
{children}
</AvatarOverrideContext.Provider>
<WSChatContext.Provider value={chatValue}>
<WSPresenceContext.Provider value={presenceValue}>
<AvatarOverrideContext.Provider value={avatarOverrides}>
{children}
</AvatarOverrideContext.Provider>
</WSPresenceContext.Provider>
</WSChatContext.Provider>
</WSContext.Provider>
);
}

View File

@@ -1,24 +1,37 @@
import { useEffect } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
/**
* Sets up a debounced scroll listener that calls `onSave(scrollY)` when the
* user scrolls, but only while `enabled` is true (e.g. the page is loaded).
* Calls `onSave(scrollY)` when the page is left — the component unmounts (a
* navigation within the app) or the tab goes away (`pagehide`) — as long as
* `enabled` is true (e.g. the page is loaded).
*
* Scrolling itself only notes the position: saving serializes the whole feed
* into sessionStorage, which on every pause in scrolling cost a long task.
*/
export function useScrollSave(
enabled: boolean,
onSave: (scrollY: number) => void,
) {
const onSaveRef = useRef(onSave);
useLayoutEffect(() => {
onSaveRef.current = onSave;
});
useEffect(() => {
if (!enabled) return;
let timer: ReturnType<typeof setTimeout>;
// Tracked as it happens: by the time the unmount runs, the next page may
// already have scrolled the window back to the top.
let y = globalThis.scrollY;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => onSave(globalThis.scrollY), 100);
y = globalThis.scrollY;
};
const save = () => onSaveRef.current(y);
globalThis.addEventListener("scroll", onScroll, { passive: true });
globalThis.addEventListener("pagehide", save);
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
globalThis.removeEventListener("pagehide", save);
save();
};
}, [enabled, onSave]);
}, [enabled]);
}

View File

@@ -1,6 +1,21 @@
import { useContext } from "react";
import { WSContext } from "../contexts/WSContext.ts";
import {
WSChatContext,
WSContext,
WSPresenceContext,
} from "../contexts/WSContext.ts";
/** Votes, likes, live dumps, playlists, comments and notifications. */
export function useWS() {
return useContext(WSContext);
}
/** The chat room. Re-renders with every message and typing change. */
export function useWSChat() {
return useContext(WSChatContext);
}
/** Who's online. */
export function useWSPresence() {
return useContext(WSPresenceContext);
}

View File

@@ -18,13 +18,13 @@ msgid "[deleted]"
msgstr "[deleted]"
#. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:109
#: src/components/JournalCard.tsx:114
#: src/components/DumpCard.tsx:112
#: src/components/JournalCard.tsx:116
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}"
#. placeholder {0}: playlist.dumpCount
#: src/components/PlaylistCard.tsx:85
#: src/components/PlaylistCard.tsx:87
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# dump} other {# dumps}}"
@@ -35,12 +35,12 @@ msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
#. placeholder {0}: typingNames[0]
#. placeholder {1}: typingNames[1]
#: src/components/ChatModal.tsx:1079
#: src/components/ChatModal.tsx:1078
msgid "{0} and {1} are typing…"
msgstr "{0} and {1} are typing…"
#. placeholder {0}: typingNames[0]
#: src/components/ChatModal.tsx:1077
#: src/components/ChatModal.tsx:1076
msgid "{0} is typing…"
msgstr "{0} is typing…"
@@ -59,7 +59,7 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
msgid "{label} ({count})"
msgstr "{label} ({count})"
#: src/components/ChatModal.tsx:1208
#: src/components/ChatModal.tsx:1207
msgid "{unseenCount} new messages"
msgstr "{unseenCount} new messages"
@@ -143,7 +143,7 @@ msgstr "<0>{0}</0> upvoted <1>{1}</1>"
msgid "<0>{0}</0> was added to <1>{1}</1>"
msgstr "<0>{0}</0> was added to <1>{1}</1>"
#: src/components/ChatModal.tsx:1207
#: src/components/ChatModal.tsx:1206
msgid "1 new message"
msgstr "1 new message"
@@ -220,8 +220,8 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Back to login"
#: src/contexts/WSProvider.tsx:328
#: src/contexts/WSProvider.tsx:711
#: src/contexts/WSProvider.tsx:332
#: src/contexts/WSProvider.tsx:715
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
@@ -244,8 +244,8 @@ msgstr "Cancel"
msgid "Cancel removal"
msgstr "Cancel removal"
#: src/components/ChatModal.tsx:1267
#: src/components/ChatModal.tsx:1268
#: src/components/ChatModal.tsx:1269
msgid "Cancel reply"
msgstr "Cancel reply"
@@ -282,7 +282,7 @@ msgstr "Change skin tone"
#: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:1168
#: src/components/ChatModal.tsx:1167
msgid "Chat"
msgstr "Chat"
@@ -329,7 +329,7 @@ msgstr "Color scheme"
msgid "Confirm new password"
msgstr "Confirm new password"
#: src/contexts/WSProvider.tsx:704
#: src/contexts/WSProvider.tsx:708
msgid "Connection lost — this message may not have been sent"
msgstr "Connection lost — this message may not have been sent"
@@ -362,7 +362,7 @@ msgstr "Could not save"
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
#: src/components/ChatModal.tsx:1094
#: src/components/ChatModal.tsx:1093
msgid "Couldn't load the chat."
msgstr "Couldn't load the chat."
@@ -427,7 +427,7 @@ msgstr "Delete dump"
msgid "Delete failed ({0})"
msgstr "Delete failed ({0})"
#: src/components/PlaylistCard.tsx:108
#: src/components/PlaylistCard.tsx:110
#: src/pages/PlaylistDetail.tsx:799
#: src/pages/UserPlaylists.tsx:483
msgid "Delete playlist"
@@ -463,12 +463,12 @@ msgstr "Description (optional)"
msgid "Discard"
msgstr "Discard"
#: src/components/ChatModal.tsx:1172
#: src/components/ChatModal.tsx:1171
msgid "Disconnected — reconnecting…"
msgstr "Disconnected — reconnecting…"
#: src/components/ChatModal.tsx:1243
#: src/components/ChatModal.tsx:1244
#: src/components/ChatModal.tsx:1245
msgid "Dismiss"
msgstr "Dismiss"
@@ -477,7 +477,7 @@ msgid "Done"
msgstr "Done"
#. placeholder {0}: dump.fileName
#: src/components/FilePreview.tsx:317
#: src/components/FilePreview.tsx:329
msgid "Download {0}"
msgstr "Download {0}"
@@ -804,7 +804,7 @@ msgstr "Link"
msgid "Link or file"
msgstr "Link or file"
#: src/contexts/WSProvider.tsx:710
#: src/contexts/WSProvider.tsx:714
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
@@ -818,7 +818,7 @@ msgstr "Live updates unavailable."
msgid "Load more"
msgstr "Load more"
#: src/components/ChatModal.tsx:1193
#: src/components/ChatModal.tsx:1192
msgid "Load older messages"
msgstr "Load older messages"
@@ -827,7 +827,7 @@ msgstr "Load older messages"
msgid "Loading dump…"
msgstr "Loading dump…"
#: src/components/ChatModal.tsx:1086
#: src/components/ChatModal.tsx:1085
msgid "Loading messages…"
msgstr "Loading messages…"
@@ -852,7 +852,7 @@ msgid "Loading profile…"
msgstr "Loading profile…"
#: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:1192
#: src/components/ChatModal.tsx:1191
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:669
#: src/components/UserListPopover.tsx:198
@@ -926,7 +926,7 @@ msgstr "Name"
msgid "new"
msgstr "new"
#: src/components/ChatModal.tsx:1121
#: src/components/ChatModal.tsx:1120
#: src/components/FeedTabBar.tsx:37
#: src/pages/UserPublicProfile.tsx:1350
msgid "New"
@@ -985,7 +985,7 @@ msgstr "No followed playlists yet."
msgid "No invitees yet."
msgstr "No invitees yet."
#: src/components/ChatModal.tsx:1105
#: src/components/ChatModal.tsx:1104
msgid "No messages yet. Say hello!"
msgstr "No messages yet. Say hello!"
@@ -1003,7 +1003,7 @@ msgstr "No playlists match \"{q}\"."
msgid "No playlists yet."
msgstr "No playlists yet."
#: src/contexts/WSProvider.tsx:944
#: src/contexts/WSProvider.tsx:948
msgid "No reply from the server — this message may not have been sent"
msgstr "No reply from the server — this message may not have been sent"
@@ -1090,8 +1090,8 @@ msgstr "Passwords do not match"
msgid "Paste a link…"
msgstr "Paste a link…"
#: src/components/FilePreview.tsx:108
#: src/components/FilePreview.tsx:269
#: src/components/FilePreview.tsx:114
#: src/components/FilePreview.tsx:281
#: src/components/MediaPlayer.tsx:484
#: src/components/RichContentCard.tsx:83
msgid "Pause"
@@ -1101,10 +1101,10 @@ msgstr "Pause"
msgid "Picked up where you left off."
msgstr "Picked up where you left off."
#: src/components/FilePreview.tsx:108
#: src/components/FilePreview.tsx:213
#: src/components/FilePreview.tsx:234
#: src/components/FilePreview.tsx:269
#: src/components/FilePreview.tsx:114
#: src/components/FilePreview.tsx:225
#: src/components/FilePreview.tsx:246
#: src/components/FilePreview.tsx:281
#: src/components/MediaPlayer.tsx:484
#: src/components/RichContentCard.tsx:83
#: src/components/RichContentCard.tsx:129
@@ -1148,9 +1148,9 @@ msgstr "Posting…"
msgid "Previous track"
msgstr "Previous track"
#: src/components/DumpCard.tsx:118
#: src/components/JournalCard.tsx:123
#: src/components/PlaylistCard.tsx:72
#: src/components/DumpCard.tsx:121
#: src/components/JournalCard.tsx:125
#: src/components/PlaylistCard.tsx:74
#: src/components/PlaylistMembershipPanel.tsx:64
#: src/pages/Dump.tsx:474
#: src/pages/PlaylistDetail.tsx:681
@@ -1162,7 +1162,7 @@ msgstr "private"
msgid "Private"
msgstr "Private"
#: src/components/PlaylistCard.tsx:71
#: src/components/PlaylistCard.tsx:73
#: src/pages/PlaylistDetail.tsx:680
msgid "public"
msgstr "public"
@@ -1228,7 +1228,7 @@ msgstr "Replace file"
msgid "Reply"
msgstr "Reply"
#: src/components/ChatModal.tsx:1256
#: src/components/ChatModal.tsx:1255
msgid "Replying to"
msgstr "Replying to"
@@ -1315,7 +1315,7 @@ msgstr "Searching…"
msgid "Seek"
msgstr "Seek"
#: src/components/ChatModal.tsx:1296
#: src/components/ChatModal.tsx:1295
msgid "Send"
msgstr "Send"
@@ -1341,7 +1341,7 @@ msgstr "Set new password"
msgid "Settings"
msgstr "Settings"
#: src/components/ChatModal.tsx:1080
#: src/components/ChatModal.tsx:1079
msgid "Several people are typing…"
msgstr "Several people are typing…"
@@ -1359,7 +1359,7 @@ msgstr "Some images couldn't be uploaded."
#: src/components/form/FormError.tsx:16
#: src/components/PageError.tsx:13
#: src/contexts/WSProvider.tsx:664
#: src/contexts/WSProvider.tsx:668
#: src/pages/PlaylistDetail.tsx:715
msgid "Something went wrong"
msgstr "Something went wrong"
@@ -1436,11 +1436,11 @@ msgstr "Today"
msgid "Track {0}"
msgstr "Track {0}"
#: src/components/ChatModal.tsx:1097
#: src/components/ChatModal.tsx:1096
msgid "Try again"
msgstr "Try again"
#: src/components/ChatModal.tsx:1281
#: src/components/ChatModal.tsx:1280
msgid "Type a message…"
msgstr "Type a message…"
@@ -1579,7 +1579,7 @@ msgstr "You can post it anyway."
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
#: src/contexts/WSProvider.tsx:925
#: src/contexts/WSProvider.tsx:929
msgid "You're offline — not sent"
msgstr "You're offline — not sent"

View File

@@ -18,13 +18,13 @@ msgid "[deleted]"
msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:109
#: src/components/JournalCard.tsx:114
#: src/components/DumpCard.tsx:112
#: src/components/JournalCard.tsx:116
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
#. placeholder {0}: playlist.dumpCount
#: src/components/PlaylistCard.tsx:85
#: src/components/PlaylistCard.tsx:87
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# reco} other {# recos}}"
@@ -35,12 +35,12 @@ msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus ancienn
#. placeholder {0}: typingNames[0]
#. placeholder {1}: typingNames[1]
#: src/components/ChatModal.tsx:1079
#: src/components/ChatModal.tsx:1078
msgid "{0} and {1} are typing…"
msgstr "{0} et {1} sont en train d'écrire…"
#. placeholder {0}: typingNames[0]
#: src/components/ChatModal.tsx:1077
#: src/components/ChatModal.tsx:1076
msgid "{0} is typing…"
msgstr "{0} est en train d'écrire…"
@@ -59,7 +59,7 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
msgid "{label} ({count})"
msgstr "{label} ({count})"
#: src/components/ChatModal.tsx:1208
#: src/components/ChatModal.tsx:1207
msgid "{unseenCount} new messages"
msgstr "{unseenCount} nouveaux messages"
@@ -143,7 +143,7 @@ msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
msgid "<0>{0}</0> was added to <1>{1}</1>"
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
#: src/components/ChatModal.tsx:1207
#: src/components/ChatModal.tsx:1206
msgid "1 new message"
msgstr "1 nouveau message"
@@ -220,8 +220,8 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Retour à la connexion"
#: src/contexts/WSProvider.tsx:328
#: src/contexts/WSProvider.tsx:711
#: src/contexts/WSProvider.tsx:332
#: src/contexts/WSProvider.tsx:715
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
@@ -244,8 +244,8 @@ msgstr "Annuler"
msgid "Cancel removal"
msgstr "Annuler la suppression"
#: src/components/ChatModal.tsx:1267
#: src/components/ChatModal.tsx:1268
#: src/components/ChatModal.tsx:1269
msgid "Cancel reply"
msgstr "Annuler la réponse"
@@ -282,7 +282,7 @@ msgstr "Changer la couleur de peau"
#: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:1168
#: src/components/ChatModal.tsx:1167
msgid "Chat"
msgstr "Tribune"
@@ -329,7 +329,7 @@ msgstr "Thème de couleur"
msgid "Confirm new password"
msgstr "Confirmer le nouveau mot de passe"
#: src/contexts/WSProvider.tsx:704
#: src/contexts/WSProvider.tsx:708
msgid "Connection lost — this message may not have been sent"
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
@@ -362,7 +362,7 @@ msgstr "Sauvegarde impossible"
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard."
#: src/components/ChatModal.tsx:1094
#: src/components/ChatModal.tsx:1093
msgid "Couldn't load the chat."
msgstr "Impossible de charger le chat."
@@ -427,7 +427,7 @@ msgstr "Supprimer la reco"
msgid "Delete failed ({0})"
msgstr "Échec de la suppression ({0})"
#: src/components/PlaylistCard.tsx:108
#: src/components/PlaylistCard.tsx:110
#: src/pages/PlaylistDetail.tsx:799
#: src/pages/UserPlaylists.tsx:483
msgid "Delete playlist"
@@ -463,12 +463,12 @@ msgstr "Description (facultatif)"
msgid "Discard"
msgstr "Abandonner"
#: src/components/ChatModal.tsx:1172
#: src/components/ChatModal.tsx:1171
msgid "Disconnected — reconnecting…"
msgstr "Déconnecté — reconnexion…"
#: src/components/ChatModal.tsx:1243
#: src/components/ChatModal.tsx:1244
#: src/components/ChatModal.tsx:1245
msgid "Dismiss"
msgstr "Fermer"
@@ -477,7 +477,7 @@ msgid "Done"
msgstr "Terminé"
#. placeholder {0}: dump.fileName
#: src/components/FilePreview.tsx:317
#: src/components/FilePreview.tsx:329
msgid "Download {0}"
msgstr "Télécharger {0}"
@@ -804,7 +804,7 @@ msgstr "Lien"
msgid "Link or file"
msgstr "Lien ou fichier"
#: src/contexts/WSProvider.tsx:710
#: src/contexts/WSProvider.tsx:714
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
@@ -818,7 +818,7 @@ msgstr "Mises à jour en direct indisponibles."
msgid "Load more"
msgstr "Charger plus"
#: src/components/ChatModal.tsx:1193
#: src/components/ChatModal.tsx:1192
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
@@ -827,7 +827,7 @@ msgstr "Charger les messages plus anciens"
msgid "Loading dump…"
msgstr "Chargement de la reco…"
#: src/components/ChatModal.tsx:1086
#: src/components/ChatModal.tsx:1085
msgid "Loading messages…"
msgstr "Chargement des messages…"
@@ -852,7 +852,7 @@ msgid "Loading profile…"
msgstr "Chargement du profil…"
#: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:1192
#: src/components/ChatModal.tsx:1191
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:669
#: src/components/UserListPopover.tsx:198
@@ -926,7 +926,7 @@ msgstr "Nom"
msgid "new"
msgstr "nouveau"
#: src/components/ChatModal.tsx:1121
#: src/components/ChatModal.tsx:1120
#: src/components/FeedTabBar.tsx:37
#: src/pages/UserPublicProfile.tsx:1350
msgid "New"
@@ -985,7 +985,7 @@ msgstr "Pas encore de collections suivies."
msgid "No invitees yet."
msgstr "Aucun invité pour le moment."
#: src/components/ChatModal.tsx:1105
#: src/components/ChatModal.tsx:1104
msgid "No messages yet. Say hello!"
msgstr "Aucun message pour l'instant. Dites bonjour !"
@@ -1003,7 +1003,7 @@ msgstr "Aucune collection ne correspond à « {q} »."
msgid "No playlists yet."
msgstr "Pas encore de collections."
#: src/contexts/WSProvider.tsx:944
#: src/contexts/WSProvider.tsx:948
msgid "No reply from the server — this message may not have been sent"
msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé"
@@ -1090,8 +1090,8 @@ msgstr "Les mots de passe ne correspondent pas"
msgid "Paste a link…"
msgstr "Collez un lien…"
#: src/components/FilePreview.tsx:108
#: src/components/FilePreview.tsx:269
#: src/components/FilePreview.tsx:114
#: src/components/FilePreview.tsx:281
#: src/components/MediaPlayer.tsx:484
#: src/components/RichContentCard.tsx:83
msgid "Pause"
@@ -1101,10 +1101,10 @@ msgstr "Pause"
msgid "Picked up where you left off."
msgstr "Reprise de votre brouillon."
#: src/components/FilePreview.tsx:108
#: src/components/FilePreview.tsx:213
#: src/components/FilePreview.tsx:234
#: src/components/FilePreview.tsx:269
#: src/components/FilePreview.tsx:114
#: src/components/FilePreview.tsx:225
#: src/components/FilePreview.tsx:246
#: src/components/FilePreview.tsx:281
#: src/components/MediaPlayer.tsx:484
#: src/components/RichContentCard.tsx:83
#: src/components/RichContentCard.tsx:129
@@ -1148,9 +1148,9 @@ msgstr "Publication…"
msgid "Previous track"
msgstr "Piste précédente"
#: src/components/DumpCard.tsx:118
#: src/components/JournalCard.tsx:123
#: src/components/PlaylistCard.tsx:72
#: src/components/DumpCard.tsx:121
#: src/components/JournalCard.tsx:125
#: src/components/PlaylistCard.tsx:74
#: src/components/PlaylistMembershipPanel.tsx:64
#: src/pages/Dump.tsx:474
#: src/pages/PlaylistDetail.tsx:681
@@ -1162,7 +1162,7 @@ msgstr "privé"
msgid "Private"
msgstr "Privé"
#: src/components/PlaylistCard.tsx:71
#: src/components/PlaylistCard.tsx:73
#: src/pages/PlaylistDetail.tsx:680
msgid "public"
msgstr "public"
@@ -1228,7 +1228,7 @@ msgstr "Remplacer le fichier"
msgid "Reply"
msgstr "Répondre"
#: src/components/ChatModal.tsx:1256
#: src/components/ChatModal.tsx:1255
msgid "Replying to"
msgstr "En réponse à"
@@ -1315,7 +1315,7 @@ msgstr "Recherche…"
msgid "Seek"
msgstr "Position"
#: src/components/ChatModal.tsx:1296
#: src/components/ChatModal.tsx:1295
msgid "Send"
msgstr "Envoyer"
@@ -1341,7 +1341,7 @@ msgstr "Définir un nouveau mot de passe"
msgid "Settings"
msgstr "Paramètres"
#: src/components/ChatModal.tsx:1080
#: src/components/ChatModal.tsx:1079
msgid "Several people are typing…"
msgstr "Plusieurs personnes écrivent…"
@@ -1359,7 +1359,7 @@ msgstr "Certaines images n'ont pas pu être envoyées."
#: src/components/form/FormError.tsx:16
#: src/components/PageError.tsx:13
#: src/contexts/WSProvider.tsx:664
#: src/contexts/WSProvider.tsx:668
#: src/pages/PlaylistDetail.tsx:715
msgid "Something went wrong"
msgstr "Une erreur est survenue"
@@ -1436,11 +1436,11 @@ msgstr "Aujourd'hui"
msgid "Track {0}"
msgstr "Piste {0}"
#: src/components/ChatModal.tsx:1097
#: src/components/ChatModal.tsx:1096
msgid "Try again"
msgstr "Réessayer"
#: src/components/ChatModal.tsx:1281
#: src/components/ChatModal.tsx:1280
msgid "Type a message…"
msgstr "Écrivez un message…"
@@ -1579,7 +1579,7 @@ msgstr "Vous pouvez la publier quand même."
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
#: src/contexts/WSProvider.tsx:925
#: src/contexts/WSProvider.tsx:929
msgid "You're offline — not sent"
msgstr "Hors ligne — message non envoyé"

View File

@@ -1,7 +1,8 @@
export interface PaginatedData<T> {
items: T[];
total: number;
hasMore: boolean;
/** The whole list's size — only where it's shown (search results). */
total?: number;
}
/**

View File

@@ -489,13 +489,13 @@ export function UserPublicProfile() {
const votesData: PaginatedData<RawDump> = votesBody.success
? votesBody.data
: { items: [], total: 0, hasMore: false };
: { items: [], hasMore: false };
const playlistsData: PaginatedData<RawPlaylist> = playlistsBody.success
? playlistsBody.data
: { items: [], total: 0, hasMore: false };
: { items: [], hasMore: false };
const dumpsData: PaginatedData<RawDump> = dumpsBody.success
? dumpsBody.data
: { items: [], total: 0, hasMore: false };
: { items: [], hasMore: false };
const profileUser = deserializePublicUser(userBody.data);
const voteItems = votesData.items.map(deserializeDump);

View File

@@ -5,6 +5,11 @@ import { MediaPlayer } from "../../components/MediaPlayer.tsx";
import FilePreview from "../../components/FilePreview.tsx";
import { makeDump, makeSession } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
import {
dumpFileUrl,
dumpFileVersion,
dumpThumbnailUrl,
} from "../../utils/urls.ts";
function media(container: HTMLElement) {
return container.querySelector<HTMLMediaElement>("audio, video")!;
@@ -221,13 +226,24 @@ describe("FilePreview", () => {
});
test("images show inline, with the media token", () => {
renderWithContexts(<FilePreview dump={file("image/png")} />, { session });
const d = file("image/png");
renderWithContexts(<FilePreview dump={d} />, { session });
expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute(
"src",
`http://localhost:8000/api/files/f1?v=2048&token=${session.mediaToken}`,
`http://localhost:8000/api/files/f1?v=${
dumpFileVersion(d)
}&token=${session.mediaToken}`,
);
});
test("a compact image shows the server's preview, not the upload", () => {
const d = file("image/png");
renderWithContexts(<FilePreview dump={d} compact />, { session });
const img = screen.getByRole("img", { name: "thing" });
expect(img).toHaveAttribute("src", dumpThumbnailUrl(d, session.mediaToken));
expect(img).toHaveAttribute("loading", "lazy");
});
test("other files are a download link", () => {
renderWithContexts(<FilePreview dump={file("application/zip")} />);
const link = screen.getByRole("link", { name: /Download thing/ });
@@ -253,12 +269,14 @@ describe("FilePreview", () => {
test("compact video claims its generated still as artwork", async () => {
const play = vi.fn();
renderWithContexts(<FilePreview dump={file("video/mp4")} compact />, {
const d = file("video/mp4");
renderWithContexts(<FilePreview dump={d} compact />, {
player: { play },
});
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play.mock.calls[0][0].artworkUrl).toBe(
"http://localhost:8000/api/thumbnails/f1",
expect(play.mock.calls[0][0].artworkUrl).toBe(dumpThumbnailUrl(d));
expect(play.mock.calls[0][0].artworkUrl).toMatch(
/^http:\/\/localhost:8000\/api\/thumbnails\/f1\?v=2048-\d+$/,
);
});
@@ -271,7 +289,7 @@ describe("FilePreview", () => {
playing: true,
current: {
kind: "file",
fileUrl: "http://localhost:8000/api/files/f1?v=2048",
fileUrl: dumpFileUrl(d),
mimeType: "video/mp4",
},
},

View File

@@ -13,6 +13,7 @@ import {
import { makeDump, makePlaylist } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
import { markPlaylistVisited } from "../../utils/visited.ts";
import { dumpThumbnailUrl } from "../../utils/urls.ts";
let player!: React.ContextType<typeof PlayerContext>;
function Probe() {
@@ -272,22 +273,19 @@ describe("JournalCard", () => {
});
test("a video file shows its generated still", () => {
const dump = makeDump({
id: "v",
kind: "file",
fileMime: "video/mp4",
url: undefined,
});
const { container } = renderWithContexts(
<ul>
<JournalCard
dump={makeDump({
id: "v",
kind: "file",
fileMime: "video/mp4",
url: undefined,
})}
shape="tall"
{...base}
/>
<JournalCard dump={dump} shape="tall" {...base} />
</ul>,
);
expect(container.querySelector("img")!.getAttribute("src")).toBe(
"http://localhost:8000/api/thumbnails/v",
dumpThumbnailUrl(dump),
);
});
});

View File

@@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, useContext, useState } from "react";
import { act, render, renderHook, waitFor } from "@testing-library/react";
import { memo, type ReactNode, useContext, useState } from "react";
import { AuthContext } from "../../contexts/AuthContext.ts";
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
import {
PlayerContext,
type PlayerItem,
PlayerTimeContext,
} from "../../contexts/PlayerContext.ts";
import type { AuthResponse } from "../../model.ts";
import { STREAM_STALE_MS } from "../../utils/streamSources.ts";
@@ -64,7 +65,10 @@ function wrapperFor(initial: AuthResponse | null) {
}
function setup(session: AuthResponse | null = null) {
const hook = renderHook(() => useContext(PlayerContext), {
const hook = renderHook(() => ({
...useContext(PlayerContext),
...useContext(PlayerTimeContext),
}), {
wrapper: wrapperFor(session),
});
return { hook, p: () => hook.result.current };
@@ -395,3 +399,41 @@ describe("expiring streams", () => {
beforeEach(() => {
setSession = () => {};
});
describe("the playhead", () => {
test("a time update re-renders what reads the time, not the rest", () => {
let controls = 0;
let clock = 0;
// Two consumers under one provider: one reads the controls, one the time.
const Probe = () => {
useContext(PlayerContext);
controls++;
return null;
};
const Clock = () => {
useContext(PlayerTimeContext);
clock++;
return null;
};
const Wrapper = wrapperFor(null);
let onTimeUpdate!: (t: number, d: number) => void;
const Grab = () => {
onTimeUpdate = useContext(PlayerContext).onTimeUpdate;
return null;
};
const MemoProbe = memo(Probe);
const MemoClock = memo(Clock);
render(
<Wrapper>
<Grab />
<MemoProbe />
<MemoClock />
</Wrapper>,
);
const before = { controls, clock };
act(() => onTimeUpdate(5, 100));
act(() => onTimeUpdate(6, 100));
expect(controls).toBe(before.controls);
expect(clock).toBe(before.clock + 2);
});
});

View File

@@ -5,7 +5,7 @@ import { useContext } from "react";
import { AuthProvider } from "../../contexts/AuthProvider.tsx";
import { WSProvider } from "../../contexts/WSProvider.tsx";
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
import { useWS } from "../../hooks/useWS.ts";
import { useWS, useWSChat, useWSPresence } from "../../hooks/useWS.ts";
import { useAuth } from "../../hooks/useAuth.ts";
import { FakeWebSocket } from "../ws.ts";
import { defaultRoutes } from "../render.tsx";
@@ -36,7 +36,7 @@ function setup({ signedIn = true } = {}) {
defaultRoutes({ session: signedIn ? me : null });
const hook = renderHook(
() => ({
ws: useWS(),
ws: { ...useWS(), ...useWSChat(), ...useWSPresence() },
auth: useAuth(),
avatars: useContext(AvatarOverrideContext),
}),
@@ -805,3 +805,33 @@ describe("chat", () => {
});
});
});
describe("split contexts", () => {
test("chat, typing and presence leave the feed's context alone", () => {
storeSession(me);
defaultRoutes({ session: me });
const feeds: unknown[] = [];
const chats: unknown[] = [];
renderHook(() => {
feeds.push(useWS());
chats.push(useWSChat());
}, { wrapper });
const socket = FakeWebSocket.last;
socket.welcome();
const feed = feeds.at(-1);
const chat = chats.at(-1);
socket.receive({
type: "chat_typing_update",
users: [{ userId: "x", username: "x", hasAvatar: false }],
});
socket.receive({
type: "presence_update",
users: [{ userId: "x", username: "x", hasAvatar: false }],
});
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
// The feed's value is the same object: its consumers had no reason to
// re-render. The chat's moved on.
expect(feeds.at(-1)).toBe(feed);
expect(chats.at(-1)).not.toBe(chat);
});
});

View File

@@ -379,22 +379,51 @@ describe("useScrollLock", () => {
});
describe("useScrollSave", () => {
test("debounces scroll positions while enabled", () => {
vi.useFakeTimers();
const scrollTo = (y: number) => {
Object.defineProperty(globalThis, "scrollY", {
value: y,
configurable: true,
});
globalThis.dispatchEvent(new Event("scroll"));
};
test("saves the last position when the page is left, not while scrolling", () => {
const onSave = vi.fn();
const { rerender, unmount } = renderHook(
({ on }) => useScrollSave(on, onSave),
{ initialProps: { on: true } },
);
globalThis.dispatchEvent(new Event("scroll"));
globalThis.dispatchEvent(new Event("scroll"));
vi.advanceTimersByTime(100);
expect(onSave).toHaveBeenCalledTimes(1);
rerender({ on: false });
globalThis.dispatchEvent(new Event("scroll"));
vi.advanceTimersByTime(100);
expect(onSave).toHaveBeenCalledTimes(1);
const { unmount } = renderHook(() => useScrollSave(true, onSave));
scrollTo(100);
scrollTo(250);
expect(onSave).not.toHaveBeenCalled();
// The next page has already scrolled back up by the time this unmounts.
Object.defineProperty(globalThis, "scrollY", {
value: 0,
configurable: true,
});
unmount();
expect(onSave).toHaveBeenCalledExactlyOnceWith(250);
});
test("saves on pagehide, with the latest callback", () => {
const first = vi.fn();
const second = vi.fn();
const { rerender, unmount } = renderHook(
({ cb }) => useScrollSave(true, cb),
{ initialProps: { cb: first } },
);
scrollTo(40);
rerender({ cb: second });
globalThis.dispatchEvent(new Event("pagehide"));
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledExactlyOnceWith(40);
unmount();
});
test("nothing is tracked or saved while disabled", () => {
const onSave = vi.fn();
const { unmount } = renderHook(() => useScrollSave(false, onSave));
scrollTo(10);
globalThis.dispatchEvent(new Event("pagehide"));
unmount();
expect(onSave).not.toHaveBeenCalled();
});
});

View File

@@ -8,9 +8,15 @@ import type { AuthResponse, Category } from "../model.ts";
import { AuthContext } from "../contexts/AuthContext.ts";
import { AuthProvider } from "../contexts/AuthProvider.tsx";
import {
defaultWSChatValue,
defaultWSPresenceValue,
defaultWSValue,
WSChatContext,
type WSChatContextValue,
WSContext,
type WSContextValue,
WSPresenceContext,
type WSPresenceContextValue,
} from "../contexts/WSContext.ts";
import { WSProvider } from "../contexts/WSProvider.tsx";
import {
@@ -25,9 +31,12 @@ import {
} from "../contexts/CategoriesContext.ts";
import { CategoriesProvider } from "../contexts/CategoriesProvider.tsx";
import {
defaultPlayerTimeValue,
defaultPlayerValue,
PlayerContext,
type PlayerContextValue,
PlayerTimeContext,
type PlayerTimeValue,
} from "../contexts/PlayerContext.ts";
import { PlayerProvider } from "../contexts/PlayerProvider.tsx";
import { ThemeProvider } from "../contexts/ThemeProvider.tsx";
@@ -84,10 +93,11 @@ function Router(
export interface StubOptions extends RouterOptions {
session?: AuthResponse | null;
ws?: Partial<WSContextValue>;
/** Any of the socket's three contexts' fields; each goes to its own. */
ws?: Partial<WSContextValue & WSChatContextValue & WSPresenceContextValue>;
follows?: Partial<FollowContextValue>;
categories?: Category[] | Partial<CategoriesContextValue>;
player?: Partial<PlayerContextValue>;
player?: Partial<PlayerContextValue & PlayerTimeValue>;
}
function StubAuth(
@@ -101,6 +111,17 @@ function StubAuth(
);
}
/** `defaults`, with whichever of its keys `overrides` sets. */
function pick<T extends object>(defaults: T, overrides: object = {}): T {
const out = { ...defaults };
for (const key of Object.keys(defaults) as (keyof T)[]) {
if (key in overrides) {
out[key] = (overrides as T)[key];
}
}
return out;
}
function categoriesValue(
c: StubOptions["categories"],
): CategoriesContextValue {
@@ -130,17 +151,36 @@ export function renderWithContexts(
<PlayerContext.Provider
value={{ ...defaultPlayerValue, ...opts.player }}
>
<WSContext.Provider value={{ ...defaultWSValue, ...opts.ws }}>
<FollowContext.Provider
value={{ ...defaultFollowValue, isLoaded: true, ...opts.follows }}
>
<CategoriesContext.Provider
value={categoriesValue(opts.categories)}
>
<Router {...opts}>{children}</Router>
</CategoriesContext.Provider>
</FollowContext.Provider>
</WSContext.Provider>
<PlayerTimeContext.Provider
value={{
currentTime: opts.player?.currentTime ??
defaultPlayerTimeValue.currentTime,
duration: opts.player?.duration ??
defaultPlayerTimeValue.duration,
}}
>
<WSContext.Provider value={pick(defaultWSValue, opts.ws)}>
<WSChatContext.Provider value={pick(defaultWSChatValue, opts.ws)}>
<WSPresenceContext.Provider
value={pick(defaultWSPresenceValue, opts.ws)}
>
<FollowContext.Provider
value={{
...defaultFollowValue,
isLoaded: true,
...opts.follows,
}}
>
<CategoriesContext.Provider
value={categoriesValue(opts.categories)}
>
<Router {...opts}>{children}</Router>
</CategoriesContext.Provider>
</FollowContext.Provider>
</WSPresenceContext.Provider>
</WSChatContext.Provider>
</WSContext.Provider>
</PlayerTimeContext.Provider>
</PlayerContext.Provider>
</StubAuth>
</I18nProvider>

View File

@@ -141,12 +141,17 @@ describe("thumbnail tint", () => {
});
describe("urls", () => {
test("dumpFileUrl versions by size and carries the token", () => {
expect(dumpFileUrl({ id: "d1", fileSize: 42 })).toBe(
`${API_URL}/api/files/d1?v=42`,
test("dumpFileUrl versions by size and last change, and carries the token", () => {
const createdAt = new Date(1000);
const updatedAt = new Date(2000);
expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt })).toBe(
`${API_URL}/api/files/d1?v=42-1000`,
);
// An edit (a replaced file among them) moves the version on.
expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt, updatedAt }))
.toBe(`${API_URL}/api/files/d1?v=42-2000`);
expect(dumpFileUrl({ id: "d1" }, "t o+k")).toBe(
`${API_URL}/api/files/d1?v=0&token=t%20o%2Bk`,
`${API_URL}/api/files/d1?v=0-0&token=t%20o%2Bk`,
);
});
test("dumpThumbnailUrl", () => {
@@ -154,6 +159,9 @@ describe("urls", () => {
expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe(
`${API_URL}/api/thumbnails/d1?token=tk`,
);
expect(
dumpThumbnailUrl({ id: "d1", fileSize: 7, createdAt: new Date(5) }, "tk"),
).toBe(`${API_URL}/api/thumbnails/d1?v=7-5&token=tk`);
});
test("normalizeUrl adds a scheme when there's none", () => {
expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x");

View File

@@ -1,5 +1,23 @@
import { API_URL } from "../config/api.ts";
type VersionedDump = {
id: string;
fileSize?: number;
createdAt?: Date;
updatedAt?: Date;
};
/**
* A file dump's version, as the server computes it (dumpFileVersion in
* api/services/dump-service.ts): it changes whenever the file can have. A URL
* carrying the current one is cached by the browser for good; any other is
* revalidated.
*/
export function dumpFileVersion(dump: Omit<VersionedDump, "id">): string {
const at = dump.updatedAt ?? dump.createdAt;
return `${dump.fileSize ?? 0}-${at ? at.getTime() : 0}`;
}
/**
* Direct URL to a dump's uploaded file. The viewer's token is passed as a query
* param — media elements (`<audio>`, `<img>`, …) can't send an `Authorization`
@@ -7,20 +25,30 @@ import { API_URL } from "../config/api.ts";
* the WebSocket connection (`?token=`).
*/
export function dumpFileUrl(
dump: { id: string; fileSize?: number },
dump: VersionedDump,
token?: string | null,
): string {
const auth = token ? `&token=${encodeURIComponent(token)}` : "";
return `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}${auth}`;
return `${API_URL}/api/files/${dump.id}?v=${dumpFileVersion(dump)}${auth}`;
}
/** Thumbnail URL for a dump, with the viewer's token (see {@link dumpFileUrl}). */
/**
* Thumbnail URL for a dump, with the viewer's token (see {@link dumpFileUrl}):
* the custom thumbnail if it has one, else what the server makes from the file
* — a video still, or a scaled-down copy of an image for cards to show instead
* of the full upload.
*/
export function dumpThumbnailUrl(
dump: { id: string },
dump: VersionedDump,
token?: string | null,
): string {
const auth = token ? `?token=${encodeURIComponent(token)}` : "";
return `${API_URL}/api/thumbnails/${dump.id}${auth}`;
const params = [
...(dump.createdAt ? [`v=${dumpFileVersion(dump)}`] : []),
...(token ? [`token=${encodeURIComponent(token)}`] : []),
];
return `${API_URL}/api/thumbnails/${dump.id}${
params.length ? `?${params.join("&")}` : ""
}`;
}
export function normalizeUrl(input: string): string {