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
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:
@@ -42,6 +42,8 @@ export function Avatar(
|
||||
title={username}
|
||||
style={sizeStyle}
|
||||
className="avatar-img"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export default function Thumbnail(
|
||||
alt={alt}
|
||||
className={className}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() =>
|
||||
setAttempt(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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é"
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user