diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts
index b3a5af4e..272b2988 100644
--- a/api/services/follow-service.ts
+++ b/api/services/follow-service.ts
@@ -25,10 +25,11 @@ import { isFollowRow } from "../model/follow.ts";
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { attachCategoryIds } from "./category-service.ts";
+import { sendToUser } from "./ws-service.ts";
/**
* Makes `followerId` follow `followedUserId`, and notifies the followed user.
- * Following again changes nothing.
+ * Following again changes nothing. The follower's other tabs are told.
*
* @throws {APIException} 400 when a user tries to follow themselves, 404 when
* there is no such user.
@@ -61,6 +62,7 @@ export function followUser(followerId: string, followedUserId: string): void {
}
}
if (isNew) notifyUserNewFollower(followerId, followedUserId);
+ sendToUser(followerId, { type: "follows_changed" });
}
/** Makes `followerId` stop following `followedUserId`, if they did. */
@@ -68,6 +70,7 @@ export function unfollowUser(followerId: string, followedUserId: string): void {
db.prepare(
`DELETE FROM follows WHERE follower_id = ? AND followed_user_id = ?;`,
).run(followerId, followedUserId);
+ sendToUser(followerId, { type: "follows_changed" });
}
/**
@@ -111,6 +114,7 @@ export function followPlaylist(followerId: string, playlistId: string): void {
}
}
if (isNew) notifyPlaylistOwnerNewFollower(followerId, playlistId);
+ sendToUser(followerId, { type: "follows_changed" });
}
/** Makes `followerId` stop following a playlist, if they did. */
@@ -118,6 +122,7 @@ export function unfollowPlaylist(followerId: string, playlistId: string): void {
db.prepare(
`DELETE FROM follows WHERE follower_id = ? AND followed_playlist_id = ?;`,
).run(followerId, playlistId);
+ sendToUser(followerId, { type: "follows_changed" });
}
/**
diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts
index 5a6a055e..b33cdd81 100644
--- a/api/services/ws-service.ts
+++ b/api/services/ws-service.ts
@@ -129,7 +129,8 @@ function broadcast(
/**
* Sends `data` to every socket of `userId`. The notification service sends
- * `notification_created` with it, and the route `chat_read_update`.
+ * `notification_created` with it, the follow service `follows_changed`, and
+ * the route `chat_read_update`.
*/
export function sendToUser(userId: string, data: ServerToClientMessage): void {
broadcast(data, (client) => client.userId === userId);
diff --git a/shared/ws.ts b/shared/ws.ts
index 02d60ef1..9ae38985 100644
--- a/shared/ws.ts
+++ b/shared/ws.ts
@@ -249,9 +249,10 @@ export interface CommentLikeAckMessage {
}
/**
- * Sent by the server to every socket when a public dump is posted, or when a
- * private one goes public. `WSProvider` puts it at the top of its recent
- * dumps.
+ * Sent by the server when a dump is posted: to every socket if it is public,
+ * to its poster's alone if private. Also sent to everyone but the poster when
+ * a private dump goes public, since to them it is new. `WSProvider` puts it at
+ * the top of its recent dumps.
*/
export interface DumpCreatedMessage {
type: "dump_created";
@@ -259,8 +260,9 @@ export interface DumpCreatedMessage {
}
/**
- * Sent by the server to every socket when a public dump changes. `WSProvider`
- * refreshes it in place, without moving it up the feeds.
+ * Sent by the server when a dump changes: to every socket if it is public, to
+ * its poster's alone if private, as when it has just gone private.
+ * `WSProvider` refreshes it in place, without moving it up the feeds.
*/
export interface DumpUpdatedMessage {
type: "dump_updated";
@@ -268,8 +270,10 @@ export interface DumpUpdatedMessage {
}
/**
- * Sent by the server to every socket when a dump is deleted, or when a public
- * one goes private. `WSProvider` drops it from every list.
+ * Sent by the server when a dump is deleted: to every socket if it was
+ * public, to its poster's alone if private. Also sent to everyone but the
+ * poster when a public dump goes private. `WSProvider` drops it from every
+ * list.
*/
export interface DumpDeletedMessage {
type: "dump_deleted";
@@ -439,6 +443,15 @@ export interface ChatReadUpdateMessage {
unread: number;
}
+/**
+ * Sent by the server to every socket of a user who followed or unfollowed a
+ * user or a playlist, so that `FollowProvider` in their other tabs refetches
+ * the follow status.
+ */
+export interface FollowsChangedMessage {
+ type: "follows_changed";
+}
+
/**
* Sent by `services/ws-service.ts` and the WebSocket route, and handled by
* `WSProvider`'s `onmessage`.
@@ -469,4 +482,5 @@ export type ServerToClientMessage =
| ChatMessageUpdatedMessage
| ChatMessageDeletedMessage
| ChatTypingUpdateMessage
- | ChatReadUpdateMessage;
+ | ChatReadUpdateMessage
+ | FollowsChangedMessage;
diff --git a/src/App.css b/src/App.css
index 8e9da460..1954beeb 100644
--- a/src/App.css
+++ b/src/App.css
@@ -1916,7 +1916,7 @@ body.has-player .chat-fab {
-webkit-font-smoothing: antialiased;
}
-.vote-btn:hover:not(:disabled) {
+.vote-btn:hover:not([aria-disabled="true"]) {
border-color: var(--color-accent);
color: var(--color-accent);
}
@@ -1927,7 +1927,7 @@ body.has-player .chat-fab {
color: var(--color-on-accent);
}
-.vote-btn--active:hover:not(:disabled) {
+.vote-btn--active:hover:not([aria-disabled="true"]) {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
color: var(--color-on-accent);
@@ -1942,7 +1942,7 @@ body.has-player .chat-fab {
color-mix(in srgb, var(--color-accent) 35%, transparent);
}
-.vote-btn:disabled {
+.vote-btn[aria-disabled="true"] {
opacity: 0.5;
cursor: default;
}
@@ -2281,6 +2281,12 @@ body.has-player .chat-fab {
margin-top: 0.5rem;
}
+.follow-error {
+ margin-left: 0.5rem;
+ font-size: 0.85rem;
+ color: var(--color-danger);
+}
+
/* ── Profile description ── */
.profile-description {
width: 100%;
@@ -5523,7 +5529,11 @@ body.has-player .chat-fab {
var(--dur-fast);
}
-.comment-action-btn:hover {
+.comment-action-btn[aria-disabled="true"] {
+ cursor: default;
+}
+
+.comment-action-btn:not([aria-disabled="true"]):hover {
color: var(--color-text);
border-color: var(--color-border-subtle);
background: color-mix(in srgb, var(--color-text) 6%, transparent);
@@ -6100,7 +6110,7 @@ body.has-player .chat-fab {
border-color: rgba(255, 255, 255, 0.4);
background: rgba(0, 0, 0, 0.35);
}
-.journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not(:disabled) {
+.journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not([aria-disabled="true"]) {
color: var(--color-accent);
border-color: var(--color-accent);
}
diff --git a/src/App.tsx b/src/App.tsx
index df27aa02..5f9d6615 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -121,22 +121,33 @@ function useResolvedDefaultTab() {
}
/**
- * Redirects `/` to the default feed tab. The query string must survive: the
- * web app manifest's share target opens `/?share_url=…` for an Android share,
- * and only the feed acts on it.
+ * Redirects `/` to the default feed tab. The query string and the navigation
+ * state must survive: the web app manifest's share target opens
+ * `/?share_url=…` for an Android share, and the edit page hands the feed the
+ * id of a dump it just deleted.
*/
function IndexRedirect() {
- const { search } = useLocation();
- return ;
+ const { search, state } = useLocation();
+ return (
+
+ );
}
-/** Redirects a bare `/` to that category's default feed tab. */
+/**
+ * Redirects a bare `/` to that category's default feed tab, keeping the
+ * query string and the navigation state as {@link IndexRedirect} does.
+ */
function CategoryRedirect() {
const { categorySlug } = useParams();
- const { search } = useLocation();
+ const { search, state } = useLocation();
return (
);
diff --git a/src/components/comment/CommentNode.tsx b/src/components/comment/CommentNode.tsx
index 46765bde..16eb5bd5 100644
--- a/src/components/comment/CommentNode.tsx
+++ b/src/components/comment/CommentNode.tsx
@@ -44,8 +44,6 @@ export interface CommentNodeProps {
depth: number;
dumpId: string;
currentUser: SelfUser | null;
- /** Only checked for being set before a delete. */
- token: string | null;
onCommentCreated: (comment: Comment) => void;
onCommentDeleted: (commentId: string) => void;
onCommentUpdated: (comment: Comment) => void;
@@ -63,7 +61,6 @@ export function CommentNode({
depth,
dumpId,
currentUser,
- token,
onCommentCreated,
onCommentDeleted,
onCommentUpdated,
@@ -96,7 +93,6 @@ export function CommentNode({
}
async function handleDelete() {
- if (!token) return;
setDeleteFailed(false);
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "DELETE",
@@ -157,7 +153,6 @@ export function CommentNode({
depth={depth + 1}
dumpId={dumpId}
currentUser={currentUser}
- token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
@@ -312,7 +307,6 @@ export function CommentNode({
depth={depth + 1}
dumpId={dumpId}
currentUser={currentUser}
- token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
diff --git a/src/components/comment/CommentThread.tsx b/src/components/comment/CommentThread.tsx
index 6d0fea05..45c7509b 100644
--- a/src/components/comment/CommentThread.tsx
+++ b/src/components/comment/CommentThread.tsx
@@ -32,7 +32,6 @@ export interface CommentThreadProps {
*/
comments: Comment[];
currentUser: SelfUser | null;
- token: string | null;
onCommentCreated: (comment: Comment) => void;
onCommentDeleted: (commentId: string) => void;
onCommentUpdated: (comment: Comment) => void;
@@ -59,7 +58,6 @@ export function CommentThread({
dumpId,
comments,
currentUser,
- token,
onCommentCreated,
onCommentDeleted,
onCommentUpdated,
@@ -117,7 +115,6 @@ export function CommentThread({
depth={0}
dumpId={dumpId}
currentUser={currentUser}
- token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
diff --git a/src/components/dump/create/DumpCreateModal.tsx b/src/components/dump/create/DumpCreateModal.tsx
index ac177000..c122ce5c 100644
--- a/src/components/dump/create/DumpCreateModal.tsx
+++ b/src/components/dump/create/DumpCreateModal.tsx
@@ -32,7 +32,10 @@ import {
import { useAuth } from "../../../hooks/auth/useAuth.ts";
import { useWS } from "../../../hooks/ws/useWS.ts";
import { dumpUrl, normalizeUrl } from "../../../utils/shared/urls.ts";
-import { titleFromFileName } from "../../../utils/shared/format.ts";
+import {
+ formatBytes,
+ titleFromFileName,
+} from "../../../utils/shared/format.ts";
import {
clearDumpDraft,
type DumpDraft,
@@ -413,7 +416,8 @@ export function DumpCreateModal(
} else {
if (!title.trim()) throw new Error(t`Title is required.`);
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
- throw new Error(t`File too large (max 50 MB).`);
+ const max = formatBytes(DUMP_MAX_FILE_SIZE_BYTES);
+ throw new Error(t`File too large (max ${max}).`);
}
const formData = new FormData();
formData.append("file", file);
@@ -683,6 +687,7 @@ export function DumpCreateModal(
onValueChange={selectFile}
disabled={submitting}
hint={t`Drop a file here, or paste one`}
+ maxBytes={DUMP_MAX_FILE_SIZE_BYTES}
/>
)}
diff --git a/src/components/feed/FollowedFeed.tsx b/src/components/feed/FollowedFeed.tsx
index e7213965..0c10aa94 100644
--- a/src/components/feed/FollowedFeed.tsx
+++ b/src/components/feed/FollowedFeed.tsx
@@ -54,8 +54,8 @@ async function readPage(res: Response): Promise>> {
type FollowedSection = "users" | "playlists";
/**
- * Passed to {@link FollowedFeed} by `Index`, with the vote state and deleted
- * dumps of `WSContext`. Unlike the other tabs, it loads its own dumps.
+ * Passed to {@link FollowedFeed} by `Index`, with the vote state of
+ * `WSContext`. Unlike the other tabs, it loads its own dumps.
*/
export interface FollowedFeedProps {
/** Scopes both sections to a category. */
@@ -66,7 +66,6 @@ export interface FollowedFeedProps {
user: SelfUser;
castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string, shownCount?: number) => void;
- deletedDumpIds: Set;
}
interface FollowedSubFeedProps {
@@ -76,7 +75,6 @@ interface FollowedSubFeedProps {
user: SelfUser;
castVote: (id: string, shownCount?: number) => void;
removeVote: (id: string, shownCount?: number) => void;
- deletedDumpIds: Set;
emptyMessage: string;
onLoadMore: () => void;
}
@@ -89,7 +87,6 @@ function FollowedSubFeed({
user,
castVote,
removeVote,
- deletedDumpIds,
emptyMessage,
onLoadMore,
}: FollowedSubFeedProps) {
@@ -108,16 +105,14 @@ function FollowedSubFeed({
return ;
}
- const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
-
- if (visible.length === 0) {
+ if (state.dumps.length === 0) {
return {emptyMessage}
;
}
return (
<>
- {visible.map((dump) => (
+ {state.dumps.map((dump) => (
(
- `feed:followed-users:${scope}:${user.id}`,
+ `feed:followed-users:${scope}`,
hydrateDump,
);
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
Dump
>(
- `feed:followed-playlists:${scope}:${user.id}`,
+ `feed:followed-playlists:${scope}`,
hydrateDump,
);
@@ -268,22 +262,24 @@ export function FollowedFeed({
}
}, [token, usersState.status, playlistsState.status, catQuery]);
+ // Only the lists are kept here: `Index` saves and restores the page's scroll
+ // for every tab, this one included.
useScrollSave(
usersState.status === "loaded",
- useCallback((y) => {
+ useCallback(() => {
if (usersState.status !== "loaded") return;
- saveUsers(usersState.dumps, usersState.page, usersState.hasMore, y);
+ saveUsers(usersState.dumps, usersState.page, usersState.hasMore, 0);
}, [usersState, saveUsers]),
);
useScrollSave(
playlistsState.status === "loaded",
- useCallback((y) => {
+ useCallback(() => {
if (playlistsState.status !== "loaded") return;
savePlaylists(
playlistsState.dumps,
playlistsState.page,
playlistsState.hasMore,
- y,
+ 0,
);
}, [playlistsState, savePlaylists]),
);
@@ -376,7 +372,6 @@ export function FollowedFeed({
user={user}
castVote={castVote}
removeVote={removeVote}
- deletedDumpIds={deletedDumpIds}
emptyMessage={t`Follow some users to see their dumps here.`}
onLoadMore={loadMoreUsers}
/>
@@ -390,7 +385,6 @@ export function FollowedFeed({
user={user}
castVote={castVote}
removeVote={removeVote}
- deletedDumpIds={deletedDumpIds}
emptyMessage={t`Follow some public playlists to see their dumps here.`}
onLoadMore={loadMorePlaylists}
/>
diff --git a/src/components/form/FileDropZone.tsx b/src/components/form/FileDropZone.tsx
index d984024d..f262d572 100644
--- a/src/components/form/FileDropZone.tsx
+++ b/src/components/form/FileDropZone.tsx
@@ -32,9 +32,10 @@ export interface FileDropZoneProps {
/** Replaces the "Drop a file here" prompt. */
hint?: string;
/**
- * Shows "Max 50 MB", which is written out rather than read from
- * `DUMP_MAX_FILE_SIZE_BYTES`. The zone doesn't enforce it.
+ * Stated under the prompt when `showLimit` is on. `FileField` refuses a
+ * larger file.
*/
+ maxBytes?: number;
showLimit?: boolean;
}
@@ -48,9 +49,11 @@ export function FileDropZone({
onChange,
disabled,
hint,
+ maxBytes,
showLimit = true,
}: FileDropZoneProps): React.JSX.Element {
const resolvedHint = hint ?? t`Drop a file here`;
+ const limit = maxBytes === undefined ? null : formatBytes(maxBytes);
const inputRef = useRef(null);
const [dragging, setDragging] = useState(false);
@@ -161,9 +164,9 @@ export function FileDropZone({
or browse files
- {showLimit && (
+ {showLimit && limit && (
- Max 50 MB
+ Max {limit}
)}
diff --git a/src/components/form/FileField.tsx b/src/components/form/FileField.tsx
index 0a24fa10..6feb7416 100644
--- a/src/components/form/FileField.tsx
+++ b/src/components/form/FileField.tsx
@@ -13,7 +13,9 @@ import {
useFormContext,
} from "react-hook-form";
+import { t } from "@lingui/core/macro";
import { FileDropZone } from "./FileDropZone.tsx";
+import { formatBytes } from "../../utils/shared/format.ts";
/**
* Passed to {@link FileField} by a dump form. The field it names holds the
@@ -23,6 +25,9 @@ export interface FileFieldProps {
name: Path;
label?: string;
hint?: string;
+ /** Refuses a larger file, as a field error, as soon as it is picked. */
+ maxBytes?: number;
+ /** States `maxBytes` in the drop zone. */
showLimit?: boolean;
disabled?: boolean;
rules?: RegisterOptions>;
@@ -41,13 +46,22 @@ export function FileField({
name,
label,
hint,
+ maxBytes,
showLimit,
disabled,
rules,
onValueChange,
}: FileFieldProps): React.JSX.Element {
- const { control, formState: { errors } } = useFormContext();
+ const { control, formState: { errors }, trigger } = useFormContext();
const error = errors[name];
+ const fitsLimit = (file: File | null) => {
+ if (maxBytes === undefined || !file || file.size <= maxBytes) return true;
+ const limit = formatBytes(maxBytes);
+ return t`That file is over ${limit}.`;
+ };
+ const validate = typeof rules?.validate === "function"
+ ? { custom: rules.validate, fitsLimit }
+ : { ...rules?.validate, fitsLimit };
return (
@@ -55,15 +69,18 @@ export function FileField
({
>}
render={({ field }) => (
{
field.onChange(file);
onValueChange?.(file);
+ // Said at once, not on submit.
+ trigger(name);
}}
hint={hint}
+ maxBytes={maxBytes}
showLimit={showLimit}
disabled={disabled}
/>
diff --git a/src/components/layout/NotificationBell.tsx b/src/components/layout/NotificationBell.tsx
index cb35e3ae..ac30444c 100644
--- a/src/components/layout/NotificationBell.tsx
+++ b/src/components/layout/NotificationBell.tsx
@@ -17,7 +17,11 @@ export function NotificationBell(): React.JSX.Element {
const { unreadNotificationCount, lastNotification } = useWS();
const navigate = useNavigate();
const [ringing, setRinging] = useState(false);
+ // The ring's timers outlive the effect that starts them: a notification
+ // arriving mid-ring must not cancel its end, or the bell stays rung.
+ const timersRef = useRef[]>([]);
const animatingRef = useRef(false);
+ useEffect(() => () => timersRef.current.forEach(clearTimeout), []);
// Each page mounts its own header, so a notification already in context
// must not ring the bell again after a navigation.
const lastSeenIdRef = useRef(lastNotification?.id ?? null);
@@ -29,15 +33,13 @@ export function NotificationBell(): React.JSX.Element {
if (animatingRef.current) return;
animatingRef.current = true;
- const tStart = setTimeout(() => setRinging(true), 0);
- const tEnd = setTimeout(() => {
- setRinging(false);
- animatingRef.current = false;
- }, 700);
- return () => {
- clearTimeout(tStart);
- clearTimeout(tEnd);
- };
+ timersRef.current = [
+ setTimeout(() => setRinging(true), 0),
+ setTimeout(() => {
+ setRinging(false);
+ animatingRef.current = false;
+ }, 700),
+ ];
}, [lastNotification]);
return (
diff --git a/src/components/layout/PageShell.tsx b/src/components/layout/PageShell.tsx
index 0eb3ebb6..b9217073 100644
--- a/src/components/layout/PageShell.tsx
+++ b/src/components/layout/PageShell.tsx
@@ -13,17 +13,15 @@ export interface PageShellProps {
children: ReactNode;
/** Centers the content, for a lone card such as a form or an error. */
centered?: boolean;
- /** Passed on to `AppHeader`. */
- centerSlot?: ReactNode;
}
/** Renders {@link AppHeader}, then the page's content as the ``. */
export function PageShell(
- { children, centered = false, centerSlot }: PageShellProps,
+ { children, centered = false }: PageShellProps,
): React.JSX.Element {
return (
-
+
diff --git a/src/components/player/PlayerIcons.tsx b/src/components/player/PlayerIcons.tsx
index 503f5a0f..fafef5ce 100644
--- a/src/components/player/PlayerIcons.tsx
+++ b/src/components/player/PlayerIcons.tsx
@@ -33,7 +33,17 @@ export const IconVolume = (
{muted
? (
-
+ <>
+
+ {/* The cross is stroked: the icon's other shapes are fills. */}
+
+ >
)
: (
diff --git a/src/components/playlist/PlaylistCreateForm.tsx b/src/components/playlist/PlaylistCreateForm.tsx
index 064047be..5c24d3e8 100644
--- a/src/components/playlist/PlaylistCreateForm.tsx
+++ b/src/components/playlist/PlaylistCreateForm.tsx
@@ -33,7 +33,8 @@ import { VisibilityToggle } from "../form/SegmentedField.tsx";
export interface PlaylistCreateFormProps {
/** Added to the new playlist, which the submit button says. */
dumpId?: string;
- onCreated: (playlist: Playlist) => void;
+ /** `hasDump` tells whether `dumpId` made it into the new playlist. */
+ onCreated: (playlist: Playlist, hasDump: boolean) => void;
onCancel: () => void;
className?: string;
}
@@ -46,7 +47,8 @@ interface Values {
/**
* Renders the form that creates a playlist with `POST /api/playlists`, then
- * adds `dumpId` to it when given. A failure to add the dump goes unreported.
+ * adds `dumpId` to it when given. The playlist exists even if adding the dump
+ * fails, so `onCreated` runs either way and says which.
*/
export function PlaylistCreateForm(
{ dumpId, onCreated, onCancel, className }: PlaylistCreateFormProps,
@@ -72,13 +74,15 @@ export function PlaylistCreateForm(
}),
),
);
+ let hasDump = false;
if (dumpId) {
- await authFetch(
+ const res = await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{ method: "POST" },
- );
+ ).catch(() => null);
+ hasDump = !!res?.ok;
}
- onCreated(playlist);
+ onCreated(playlist, hasDump);
});
return (
diff --git a/src/components/playlist/PlaylistMembershipPanel.tsx b/src/components/playlist/PlaylistMembershipPanel.tsx
index ffd2a3ec..d732e15b 100644
--- a/src/components/playlist/PlaylistMembershipPanel.tsx
+++ b/src/components/playlist/PlaylistMembershipPanel.tsx
@@ -91,8 +91,8 @@ export function PlaylistMembershipPanel({
{
- onPlaylistCreated({ playlist, hasDump: true });
+ onCreated={(playlist, hasDump) => {
+ onPlaylistCreated({ playlist, hasDump });
setShowNewForm(false);
}}
onCancel={() => setShowNewForm(false)}
diff --git a/src/components/shared/ImagePicker.tsx b/src/components/shared/ImagePicker.tsx
index 56ef6c3a..33c5fe31 100644
--- a/src/components/shared/ImagePicker.tsx
+++ b/src/components/shared/ImagePicker.tsx
@@ -6,6 +6,7 @@
*/
import { useRef } from "react";
+import { t } from "@lingui/core/macro";
/**
* Passed to {@link ImagePicker} by an edit page, which uploads the file and
@@ -43,6 +44,9 @@ export function ImagePicker({
}: ImagePickerProps): React.JSX.Element {
const inputRef = useRef(null);
const sizeStyle = { width: size, height: size, borderRadius };
+ const pick = () => {
+ if (!uploading) inputRef.current?.click();
+ };
const handleChange = (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
@@ -55,12 +59,16 @@ export function ImagePicker({
!uploading && inputRef.current?.click()}
- title={src ? "Change image" : "Add image"}
+ onClick={pick}
+ title={src ? t`Change image` : t`Add image`}
role="button"
tabIndex={0}
+ aria-disabled={uploading}
onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
+ if (e.key !== "Enter" && e.key !== " ") return;
+ // Or Space would also scroll the page.
+ e.preventDefault();
+ pick();
}}
>
{src
diff --git a/src/components/social/FollowButton.tsx b/src/components/social/FollowButton.tsx
index c92ccb23..9c431035 100644
--- a/src/components/social/FollowButton.tsx
+++ b/src/components/social/FollowButton.tsx
@@ -1,11 +1,12 @@
/**
* Renders the Follow buttons of a user's profile and of a playlist's page.
* Both go through `FollowContext`, which follows at once and rolls back when
- * the server refuses.
+ * the request fails; the button then says so beside it.
*
* @module
*/
+import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useAuth } from "../../hooks/auth/useAuth.ts";
@@ -35,24 +36,33 @@ export function FollowUserButton(
): React.JSX.Element | null {
const { user } = useAuth();
const { followedUserIds, followUser, unfollowUser, isLoaded } = useFollows();
+ const [failed, setFailed] = useState(false);
if (!user || user.id === targetUserId) return null;
const isFollowing = followedUserIds.has(targetUserId);
return (
-
- isFollowing ? unfollowUser(targetUserId) : followUser(targetUserId)}
- aria-label={isFollowing
- ? t`Unfollow ${targetUsername}`
- : t`Follow ${targetUsername}`}
- >
- {isFollowing ? Following : Follow }
-
+ <>
+
{
+ setFailed(false);
+ const ok = await (isFollowing
+ ? unfollowUser(targetUserId)
+ : followUser(targetUserId));
+ setFailed(!ok);
+ }}
+ aria-label={isFollowing
+ ? t`Unfollow ${targetUsername}`
+ : t`Follow ${targetUsername}`}
+ >
+ {isFollowing ? Following : Follow }
+
+ {failed &&
}
+ >
);
}
@@ -68,23 +78,38 @@ export function FollowPlaylistButton(
const { user } = useAuth();
const { followedPlaylistIds, followPlaylist, unfollowPlaylist, isLoaded } =
useFollows();
+ const [failed, setFailed] = useState(false);
if (!user || !isPublic) return null;
const isFollowing = followedPlaylistIds.has(targetPlaylistId);
return (
-
- isFollowing
- ? unfollowPlaylist(targetPlaylistId)
- : followPlaylist(targetPlaylistId)}
- aria-label={isFollowing ? t`Unfollow playlist` : t`Follow playlist`}
- >
- {isFollowing ? Following : Follow }
-
+ <>
+
{
+ setFailed(false);
+ const ok = await (isFollowing
+ ? unfollowPlaylist(targetPlaylistId)
+ : followPlaylist(targetPlaylistId));
+ setFailed(!ok);
+ }}
+ aria-label={isFollowing ? t`Unfollow playlist` : t`Follow playlist`}
+ >
+ {isFollowing ? Following : Follow }
+
+ {failed &&
}
+ >
+ );
+}
+
+function FollowError(): React.JSX.Element {
+ return (
+
+ Couldn't update. Try again.
+
);
}
diff --git a/src/components/social/LikeButton.tsx b/src/components/social/LikeButton.tsx
index 734e045d..8dcfe4f4 100644
--- a/src/components/social/LikeButton.tsx
+++ b/src/components/social/LikeButton.tsx
@@ -44,9 +44,13 @@ export function LikeButton(
className={`comment-action-btn${
liked ? " comment-action-btn--liked" : ""
}`}
- onClick={() =>
- liked ? onUnlike(commentId, count) : onLike(commentId, count)}
- disabled={disabled}
+ // Not `disabled`, as for VoteButton: its count opens the likers.
+ onClick={() => {
+ if (disabled) return;
+ if (liked) onUnlike(commentId, count);
+ else onLike(commentId, count);
+ }}
+ aria-disabled={disabled}
aria-label={liked ? t`Remove like` : t`Like`}
title={disabled ? t`Log in to like` : undefined}
>
diff --git a/src/components/social/UserListPopover.tsx b/src/components/social/UserListPopover.tsx
index 24be0935..9452d8dd 100644
--- a/src/components/social/UserListPopover.tsx
+++ b/src/components/social/UserListPopover.tsx
@@ -24,7 +24,7 @@ import type { PublicUser } from "../../../shared/user.ts";
*/
export interface UserListPopoverProps {
anchorRef: React.RefObject
;
- /** Called on an outside click, on Escape, and on any scroll. */
+ /** Called on an outside click, on Escape, and on a scroll outside it. */
onClose: () => void;
/**
* A paginated endpoint answering `PaginatedData`, without a query
@@ -120,8 +120,12 @@ export function UserListPopover(
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
- // Captured, so a scroll inside any container closes it too.
- function onScroll() {
+ // Captured, so a scroll inside any container closes it too, but not one
+ // of its own list, which scrolls through a long list of users.
+ function onScroll(e: Event) {
+ if (e.target instanceof Node && popoverRef.current?.contains(e.target)) {
+ return;
+ }
onClose();
}
document.addEventListener("mousedown", onMouseDown);
diff --git a/src/components/social/VoteButton.tsx b/src/components/social/VoteButton.tsx
index 7e95ee51..6c761368 100644
--- a/src/components/social/VoteButton.tsx
+++ b/src/components/social/VoteButton.tsx
@@ -41,8 +41,14 @@ export function VoteButton(
voted ? onRemove(dumpId, count) : onCast(dumpId, count)}
- disabled={disabled}
+ // Not `disabled`: a disabled button swallows its count's clicks, and
+ // visitors may open the list of voters too.
+ onClick={() => {
+ if (disabled) return;
+ if (voted) onRemove(dumpId, count);
+ else onCast(dumpId, count);
+ }}
+ aria-disabled={disabled}
aria-label={voted ? t`Remove vote` : t`Upvote`}
title={disabled ? t`Log in to vote` : undefined}
>
diff --git a/src/contexts/auth/AuthProvider.tsx b/src/contexts/auth/AuthProvider.tsx
index 440e0e9e..250b1ffb 100644
--- a/src/contexts/auth/AuthProvider.tsx
+++ b/src/contexts/auth/AuthProvider.tsx
@@ -27,8 +27,8 @@ export interface AuthProviderProps {
/**
* Provides {@link AuthContext}. The session starts as the stored one and
* follows it when another tab changes it. The server checks it on mount and
- * whenever the tab regains focus: a 401 ends it, and a changed role replaces
- * the stored user. A session from before media tokens gets one.
+ * whenever the tab regains focus: a 401 ends it, and a user changed on the
+ * server replaces the stored one. A session from before media tokens gets one.
*/
export function AuthProvider(
{ children }: AuthProviderProps,
@@ -70,15 +70,19 @@ export function AuthProvider(
setAuthResponse((prev) => prev?.token === token ? null : prev);
return;
}
- // The stored user dates from sign-in, and an admin may have changed
- // the role since. Only a role change replaces it, so an unchanged
- // session keeps its identity and doesn't re-render the app.
+ // The stored user dates from sign-in, and the role, avatar or email
+ // may have changed since, by an admin or in another browser. Only a
+ // change replaces it, so an unchanged session keeps its identity and
+ // doesn't re-render the app.
if (res.ok) {
const json = await res.json();
if (json?.success && json.data) {
const freshUser = deserializeSelfUser(json.data);
setAuthResponse((prev) => {
- if (!prev || prev.user.role === freshUser.role) return prev;
+ if (
+ !prev ||
+ JSON.stringify(prev.user) === JSON.stringify(freshUser)
+ ) return prev;
const updated = { ...prev, user: freshUser };
storeSession(updated);
return updated;
diff --git a/src/contexts/category/CategoriesProvider.tsx b/src/contexts/category/CategoriesProvider.tsx
index e42e9994..b56a1647 100644
--- a/src/contexts/category/CategoriesProvider.tsx
+++ b/src/contexts/category/CategoriesProvider.tsx
@@ -20,20 +20,20 @@ import { API_URL } from "../../config/api.ts";
import type { Category } from "../../../shared/category.ts";
import { deserializeCategory } from "../../utils/shared/deserialize.ts";
import type { Wire } from "../../../shared/api.ts";
+import { expectOk } from "../../utils/shared/api.ts";
+
+/** How long a failed first fetch waits before it is tried again. */
+const RETRY_MS = 10_000;
/**
- * Returns every category, or null when the API answers a failure.
+ * Returns every category.
*
- * @throws {Error} When the request fails or answers an error status.
+ * @throws {Error} When the request fails or the API answers a failure.
*/
-async function fetchCategories(
- signal?: AbortSignal,
-): Promise {
+async function fetchCategories(signal?: AbortSignal): Promise {
const res = await fetch(`${API_URL}/api/categories`, { signal });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const body = await res.json();
- if (!body.success) return null;
- return (body.data as Wire[]).map(deserializeCategory);
+ const data = await expectOk[]>(res);
+ return data.map(deserializeCategory);
}
/** Passed to {@link CategoriesProvider} by `App`. */
@@ -42,35 +42,44 @@ export interface CategoriesProviderProps {
}
/**
- * Provides {@link CategoriesContext}, fetching the categories on mount. A
- * failed fetch keeps the previous list.
+ * Provides {@link CategoriesContext}, fetching the categories on mount. Until
+ * a fetch succeeds, `isLoaded` stays false, so that no page takes an empty
+ * list for the truth and answers a category's URL with a 404; a failed first
+ * fetch is tried again 10 s later. A failed refresh keeps the previous list.
*/
export function CategoriesProvider(
{ children }: CategoriesProviderProps,
): React.JSX.Element {
const [categories, setCategories] = useState([]);
const [isLoaded, setIsLoaded] = useState(false);
+ const [attempt, setAttempt] = useState(0);
- const load = useCallback((signal?: AbortSignal) =>
- fetchCategories(signal)
- .then((data) => {
- if (!data) return;
+ const load = useCallback(
+ (signal?: AbortSignal) =>
+ fetchCategories(signal).then((data) => {
setCategories(data);
setIsLoaded(true);
- })
- // A failed fetch still ends the loading, so that pages waiting on it
- // don't wait forever.
- .catch((err) => {
- if ((err as Error).name !== "AbortError") setIsLoaded(true);
- }), []);
+ }),
+ [],
+ );
useEffect(() => {
+ if (isLoaded) return;
const controller = new AbortController();
- load(controller.signal);
- return () => controller.abort();
- }, [load]);
+ let retry: ReturnType | undefined;
+ load(controller.signal).catch((err) => {
+ if ((err as Error).name === "AbortError") return;
+ retry = setTimeout(() => setAttempt((n) => n + 1), RETRY_MS);
+ });
+ return () => {
+ controller.abort();
+ clearTimeout(retry);
+ };
+ // isLoaded only stops the retries once a fetch has worked.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [load, attempt]);
- const refresh = useCallback(() => load(), [load]);
+ const refresh = useCallback(() => load().catch(() => {}), [load]);
const byId = useMemo(
() => new Map(categories.map((c) => [c.id, c])),
diff --git a/src/contexts/chat/ChatContext.ts b/src/contexts/chat/ChatContext.ts
index a467681e..039ea488 100644
--- a/src/contexts/chat/ChatContext.ts
+++ b/src/contexts/chat/ChatContext.ts
@@ -8,22 +8,16 @@
import { createContext } from "react";
-/** Provided by `ChatProvider` through {@link ChatContext}. */
+/**
+ * Provided by `ChatProvider` through {@link ChatContext}. The modal closes
+ * itself, so opening is all its callers need.
+ */
export interface ChatContextValue {
- /** Never true for a visitor, whatever `openChat` or the URL asked. */
- isChatOpen: boolean;
+ /** Does nothing for a visitor. */
openChat: () => void;
- closeChat: () => void;
}
-/**
- * Holds the chat modal's open state. Outside `ChatProvider` it is closed and
- * its functions do nothing.
- */
+/** Opens the chat modal. Outside `ChatProvider` it does nothing. */
export const ChatContext: React.Context = createContext<
ChatContextValue
->({
- isChatOpen: false,
- openChat: () => {},
- closeChat: () => {},
-});
+>({ openChat: () => {} });
diff --git a/src/contexts/chat/ChatProvider.tsx b/src/contexts/chat/ChatProvider.tsx
index 36c37619..fabc69d5 100644
--- a/src/contexts/chat/ChatProvider.tsx
+++ b/src/contexts/chat/ChatProvider.tsx
@@ -56,10 +56,7 @@ export function ChatProvider(
}
}, [searchParams, setSearchParams]);
- const value: ChatContextValue = useMemo(
- () => ({ isChatOpen, openChat, closeChat }),
- [isChatOpen, openChat, closeChat],
- );
+ const value: ChatContextValue = useMemo(() => ({ openChat }), [openChat]);
return (
diff --git a/src/contexts/social/FollowContext.ts b/src/contexts/social/FollowContext.ts
index 66f16e1e..d7909907 100644
--- a/src/contexts/social/FollowContext.ts
+++ b/src/contexts/social/FollowContext.ts
@@ -11,18 +11,19 @@ import { createContext } from "react";
/**
* Provided by `FollowProvider` through {@link FollowContext}. The four
* functions change the sets at once and undo the change when the request
- * fails. Their promises never reject, so a caller can't tell a failure.
+ * fails. They resolve to whether it worked, and never reject.
*/
export interface FollowContextValue {
followedUserIds: Set;
followedPlaylistIds: Set;
- followUser: (userId: string) => Promise;
- unfollowUser: (userId: string) => Promise;
- followPlaylist: (playlistId: string) => Promise;
- unfollowPlaylist: (playlistId: string) => Promise;
+ followUser: (userId: string) => Promise;
+ unfollowUser: (userId: string) => Promise;
+ followPlaylist: (playlistId: string) => Promise;
+ unfollowPlaylist: (playlistId: string) => Promise;
/**
* False until the follows are fetched, and always for a visitor. The sets
* are empty until then, so a button would show "Follow" for a followed user.
+ * A failed fetch keeps it false, and is tried again 10 s later.
*/
isLoaded: boolean;
}
@@ -31,10 +32,10 @@ export interface FollowContextValue {
export const defaultFollowValue: FollowContextValue = {
followedUserIds: new Set(),
followedPlaylistIds: new Set(),
- followUser: async () => {},
- unfollowUser: async () => {},
- followPlaylist: async () => {},
- unfollowPlaylist: async () => {},
+ followUser: () => Promise.resolve(false),
+ unfollowUser: () => Promise.resolve(false),
+ followPlaylist: () => Promise.resolve(false),
+ unfollowPlaylist: () => Promise.resolve(false),
isLoaded: false,
};
diff --git a/src/contexts/social/FollowProvider.tsx b/src/contexts/social/FollowProvider.tsx
index 0dca3c61..d55a4416 100644
--- a/src/contexts/social/FollowProvider.tsx
+++ b/src/contexts/social/FollowProvider.tsx
@@ -15,6 +15,7 @@ import {
import { FollowContext, type FollowContextValue } from "./FollowContext.ts";
import { API_URL } from "../../config/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { useWS } from "../../hooks/ws/useWS.ts";
import type { FollowStatus } from "../../../shared/follow.ts";
/** Passed to {@link FollowProvider} by `App`. */
@@ -22,15 +23,19 @@ export interface FollowProviderProps {
children: ReactNode;
}
+/** How long a failed fetch of the follows waits before it is tried again. */
+const RETRY_MS = 10_000;
+
/**
* Provides {@link FollowContext}. It fetches the follows whenever the token
- * changes, and empties them on sign-out. Follows made in another tab don't
- * show until the next sign-in or reload: no socket message carries them.
+ * changes, and again when `follows_changed` says another tab changed them. It
+ * empties them on sign-out.
*/
export function FollowProvider(
{ children }: FollowProviderProps,
): React.JSX.Element {
const { token, authFetch } = useAuth();
+ const { followsVersion } = useWS();
const [followedUserIds, setFollowedUserIds] = useState>(
new Set(),
);
@@ -38,6 +43,7 @@ export function FollowProvider(
new Set(),
);
const [isLoaded, setIsLoaded] = useState(false);
+ const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!token) {
@@ -51,92 +57,80 @@ export function FollowProvider(
return;
}
const controller = new AbortController();
+ let retry: ReturnType | undefined;
+ // Whatever went wrong, the buttons stay disabled rather than show empty
+ // sets as the truth, until a later try succeeds.
+ const fail = () => {
+ if (controller.signal.aborted) return;
+ retry = setTimeout(() => setAttempt((n) => n + 1), RETRY_MS);
+ };
fetch(`${API_URL}/api/follows/status`, {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
})
.then((r) => r.json())
.then((body) => {
- if (!body.success) return;
+ if (!body.success) return fail();
const status = body.data as FollowStatus;
setFollowedUserIds(new Set(status.followedUserIds));
setFollowedPlaylistIds(new Set(status.followedPlaylistIds));
setIsLoaded(true);
})
- .catch((err) => {
- // Enables the buttons anyway, though the sets may be empty or stale.
- if (err.name !== "AbortError") setIsLoaded(true);
- });
+ .catch(fail);
return () => {
controller.abort();
+ clearTimeout(retry);
};
- }, [token]);
+ }, [token, followsVersion, attempt]);
- const followUser = useCallback(async (userId: string) => {
- setFollowedUserIds((prev) => new Set([...prev, userId]));
+ /**
+ * Adds or removes `id` in a set at once, sends the request, and undoes the
+ * change if it fails.
+ */
+ const toggle = useCallback(async (
+ setIds: React.Dispatch>>,
+ path: string,
+ id: string,
+ follow: boolean,
+ ): Promise => {
+ const apply = (add: boolean) =>
+ setIds((prev) => {
+ const next = new Set(prev);
+ if (add) next.add(id);
+ else next.delete(id);
+ return next;
+ });
+ apply(follow);
try {
- const res = await authFetch(`${API_URL}/api/follows/users/${userId}`, {
- method: "POST",
+ const res = await authFetch(`${API_URL}/api/follows/${path}/${id}`, {
+ method: follow ? "POST" : "DELETE",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return true;
} catch {
- setFollowedUserIds((prev) => {
- const n = new Set(prev);
- n.delete(userId);
- return n;
- });
+ apply(!follow);
+ return false;
}
}, [authFetch]);
- const unfollowUser = useCallback(async (userId: string) => {
- setFollowedUserIds((prev) => {
- const n = new Set(prev);
- n.delete(userId);
- return n;
- });
- try {
- const res = await authFetch(`${API_URL}/api/follows/users/${userId}`, {
- method: "DELETE",
- });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- } catch {
- setFollowedUserIds((prev) => new Set([...prev, userId]));
- }
- }, [authFetch]);
-
- const followPlaylist = useCallback(async (playlistId: string) => {
- setFollowedPlaylistIds((prev) => new Set([...prev, playlistId]));
- try {
- const res = await authFetch(
- `${API_URL}/api/follows/playlists/${playlistId}`,
- { method: "POST" },
- );
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- } catch {
- setFollowedPlaylistIds((prev) => {
- const n = new Set(prev);
- n.delete(playlistId);
- return n;
- });
- }
- }, [authFetch]);
-
- const unfollowPlaylist = useCallback(async (playlistId: string) => {
- setFollowedPlaylistIds((prev) => {
- const n = new Set(prev);
- n.delete(playlistId);
- return n;
- });
- try {
- const res = await authFetch(
- `${API_URL}/api/follows/playlists/${playlistId}`,
- { method: "DELETE" },
- );
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- } catch {
- setFollowedPlaylistIds((prev) => new Set([...prev, playlistId]));
- }
- }, [authFetch]);
+ const followUser = useCallback(
+ (userId: string) => toggle(setFollowedUserIds, "users", userId, true),
+ [toggle],
+ );
+ const unfollowUser = useCallback(
+ (userId: string) => toggle(setFollowedUserIds, "users", userId, false),
+ [toggle],
+ );
+ const followPlaylist = useCallback(
+ (playlistId: string) =>
+ toggle(setFollowedPlaylistIds, "playlists", playlistId, true),
+ [toggle],
+ );
+ const unfollowPlaylist = useCallback(
+ (playlistId: string) =>
+ toggle(setFollowedPlaylistIds, "playlists", playlistId, false),
+ [toggle],
+ );
const value: FollowContextValue = useMemo(() => ({
followedUserIds,
diff --git a/src/contexts/theme/ThemeContext.ts b/src/contexts/theme/ThemeContext.ts
index d382a585..1ad86e27 100644
--- a/src/contexts/theme/ThemeContext.ts
+++ b/src/contexts/theme/ThemeContext.ts
@@ -8,18 +8,25 @@
import { createContext } from "react";
/**
- * A site style. Each has a stylesheet under `src/themes/`, keyed on the root
- * element's `data-style`, which is absent for "smooth".
+ * Every site style, which `ThemeProvider` checks a stored choice against. Each
+ * has a stylesheet under `src/themes/`, keyed on the root element's
+ * `data-style`, which is absent for "smooth".
*/
-export type StyleName =
- | "smooth"
- | "brutalist"
- | "geocities"
- | "nyt"
- | "kanagawa"
- | "melange";
-/** A color scheme. "auto" follows the system's. */
-export type ColorScheme = "auto" | "light" | "dark";
+export const STYLE_NAMES = [
+ "smooth",
+ "brutalist",
+ "geocities",
+ "nyt",
+ "kanagawa",
+ "melange",
+] as const;
+/** One of {@link STYLE_NAMES}. */
+export type StyleName = typeof STYLE_NAMES[number];
+
+/** Every color scheme; "auto" follows the system's. */
+export const COLOR_SCHEMES = ["auto", "light", "dark"] as const;
+/** One of {@link COLOR_SCHEMES}. */
+export type ColorScheme = typeof COLOR_SCHEMES[number];
/**
* Provided by `ThemeProvider` to `useTheme`. A setter applies and stores the
diff --git a/src/contexts/theme/ThemeProvider.tsx b/src/contexts/theme/ThemeProvider.tsx
index 771e9bd0..9d933a36 100644
--- a/src/contexts/theme/ThemeProvider.tsx
+++ b/src/contexts/theme/ThemeProvider.tsx
@@ -7,7 +7,9 @@
import { useEffect, useMemo, useState } from "react";
import {
+ COLOR_SCHEMES,
type ColorScheme,
+ STYLE_NAMES,
type StyleName,
ThemeContext,
} from "./ThemeContext.ts";
@@ -17,6 +19,22 @@ export interface ThemeProviderProps {
children: React.ReactNode;
}
+/** Returns the stored `key` when it is one of `allowed`, else `fallback`. */
+function readChoice(
+ key: string,
+ allowed: readonly T[],
+ fallback: T,
+): T {
+ try {
+ const stored = localStorage.getItem(key);
+ return (allowed as readonly string[]).includes(stored ?? "")
+ ? stored as T
+ : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
/**
* Provides {@link ThemeContext}. It sets the choices on the root element as
* `data-style` and `data-color-scheme`, which the stylesheets key on, and
@@ -29,15 +47,13 @@ export interface ThemeProviderProps {
export function ThemeProvider(
{ children }: ThemeProviderProps,
): React.JSX.Element {
- const [style, setStyleState] = useState(() => {
- const stored = localStorage.getItem("style");
- // "smooth" was once stored as "default".
- if (stored === "default" || stored === null) return "smooth";
- return stored as StyleName;
- });
- const [colorScheme, setColorSchemeState] = useState(
- () =>
- (localStorage.getItem("color-scheme") as ColorScheme | null) ?? "auto",
+ // A value from an older version, or edited by hand, falls back to the
+ // default ("smooth" was once stored as "default").
+ const [style, setStyleState] = useState(() =>
+ readChoice("style", STYLE_NAMES, "smooth")
+ );
+ const [colorScheme, setColorSchemeState] = useState(() =>
+ readChoice("color-scheme", COLOR_SCHEMES, "auto")
);
useEffect(() => {
diff --git a/src/contexts/ws/WSContext.ts b/src/contexts/ws/WSContext.ts
index bf244ec8..17fb0200 100644
--- a/src/contexts/ws/WSContext.ts
+++ b/src/contexts/ws/WSContext.ts
@@ -25,13 +25,6 @@ export interface VoteEvent {
action: "cast" | "remove";
}
-/** Set as `lastLikeEvent` by `WSProvider` on a `comment_likes_update`. */
-export interface LikeEvent {
- commentId: string;
- likerId: string;
- action: "cast" | "remove";
-}
-
/**
* Set as `lastPlaylistEvent` by `WSProvider` on each `playlist_*` message.
* The pages that show playlists apply it, some through `usePlaylistListSync`.
@@ -147,14 +140,15 @@ export interface WSContextValue {
*/
recentDumps: Dump[];
/**
- * Dumps deleted or made private this session, for the lists to filter out.
- * A dump made public again leaves it.
+ * Dumps deleted this session, or made private by someone else, for the
+ * lists to filter out. A dump made public again leaves it.
*/
deletedDumpIds: Set;
lastVoteEvent: VoteEvent | null;
- /** Read by no consumer at the moment. */
- lastLikeEvent: LikeEvent | null;
- /** The dump as it is after its latest edit. */
+ /**
+ * The dump as its latest `dump_created` or `dump_updated` has it, for the
+ * lists to update it in place, or put it back where it was.
+ */
lastDumpEvent: Dump | null;
lastPlaylistEvent: PlaylistEvent | null;
deletedPlaylistIds: Set;
@@ -169,6 +163,11 @@ export interface WSContextValue {
* fetch again what they missed while the socket was down.
*/
connectionEpoch: number;
+ /**
+ * Incremented when the user follows or unfollows something in another tab
+ * (or this one), so `FollowProvider` fetches the follow status again.
+ */
+ followsVersion: number;
/**
* Upvotes a dump at once and sends `vote_cast`. The server's answer confirms
* or reverts it, and so does a timeout of 5 s, which covers a closed socket.
@@ -285,7 +284,6 @@ export const defaultWSValue: WSContextValue = {
recentDumps: [],
deletedDumpIds: new Set(),
lastVoteEvent: null,
- lastLikeEvent: null,
lastDumpEvent: null,
lastPlaylistEvent: null,
deletedPlaylistIds: new Set(),
@@ -294,6 +292,7 @@ export const defaultWSValue: WSContextValue = {
unreadNotificationCount: 0,
lastNotification: null,
connectionEpoch: 0,
+ followsVersion: 0,
castVote: () => {},
removeVote: () => {},
castCommentLike: () => {},
diff --git a/src/contexts/ws/WSProvider.tsx b/src/contexts/ws/WSProvider.tsx
index 6fa70785..2bd589ce 100644
--- a/src/contexts/ws/WSProvider.tsx
+++ b/src/contexts/ws/WSProvider.tsx
@@ -20,7 +20,6 @@ import {
type ChatError,
type ChatReplyTarget,
type CommentEvent,
- type LikeEvent,
type PendingChatMessage,
type PlaylistEvent,
type UserEvent,
@@ -94,16 +93,106 @@ const CHAT_READ_THROTTLE = 1_000;
*/
const CHAT_ECHO_WINDOW = 60_000;
-/** An optimistic vote waiting for the server. */
-interface PendingVote {
+/** Toggles of one item that the server hasn't acknowledged yet. */
+interface PendingToggle {
timeout: ReturnType;
- rollback: () => void;
+ /** The count and state before the first of them, to restore on failure. */
+ count: number;
+ on: boolean;
+ /** How many acks are still to come. */
+ outstanding: number;
}
-/** An optimistic like waiting for the server. */
-interface PendingLike {
- timeout: ReturnType;
- rollback: () => void;
+/**
+ * Returned by {@link useOptimisticToggles}, for the vote and like actions and
+ * the socket's handlers.
+ */
+interface OptimisticToggles {
+ /** Records a toggle already shown, from the count and state before it. */
+ begin: (id: string, count: number, on: boolean) => void;
+ /** Settles one toggle; true once none of the item's is left in flight. */
+ ack: (id: string) => boolean;
+ /** Checks whether the item has toggles in flight. */
+ pending: (id: string) => boolean;
+ /** Restores the item as it was before its toggles, now. */
+ fail: (id: string) => void;
+ /**
+ * Settles every toggle against the server's set after a (re)connect: each
+ * count moves by one from its baseline when the server's state differs.
+ */
+ reconcile: (serverOn: Set) => void;
+ /** Forgets every toggle, for a new session. */
+ clear: () => void;
+}
+
+/**
+ * Tracks optimistic toggles of votes or likes, which show at once and go back
+ * when the server refuses them or doesn't answer within {@link ACK_TIMEOUT}.
+ * Several clicks on one item before any ack share one baseline, so a failure
+ * restores what was there before the first, and only the last ack's count is
+ * applied.
+ */
+function useOptimisticToggles(
+ setOn: React.Dispatch>>,
+ setCounts: React.Dispatch>>,
+): OptimisticToggles {
+ const ref = useRef(new Map());
+ return useMemo(() => {
+ const pendingMap = ref.current;
+ const restore = (id: string, count: number, on: boolean) => {
+ setOn((prev) => {
+ const next = new Set(prev);
+ if (on) next.add(id);
+ else next.delete(id);
+ return next;
+ });
+ setCounts((prev) => ({ ...prev, [id]: count }));
+ };
+ const fail = (id: string) => {
+ const entry = pendingMap.get(id);
+ if (!entry) return;
+ clearTimeout(entry.timeout);
+ pendingMap.delete(id);
+ restore(id, entry.count, entry.on);
+ };
+ return {
+ begin(id, count, on) {
+ const entry = pendingMap.get(id);
+ if (entry) clearTimeout(entry.timeout);
+ // Nothing is sent while the socket is down, so the timer fails it.
+ pendingMap.set(id, {
+ count: entry?.count ?? count,
+ on: entry?.on ?? on,
+ outstanding: (entry?.outstanding ?? 0) + 1,
+ timeout: setTimeout(() => fail(id), ACK_TIMEOUT),
+ });
+ },
+ ack(id) {
+ const entry = pendingMap.get(id);
+ if (!entry) return true;
+ entry.outstanding -= 1;
+ if (entry.outstanding > 0) return false;
+ clearTimeout(entry.timeout);
+ pendingMap.delete(id);
+ return true;
+ },
+ pending: (id) => pendingMap.has(id),
+ fail,
+ reconcile(serverOn) {
+ for (const [id, entry] of pendingMap) {
+ clearTimeout(entry.timeout);
+ const on = serverOn.has(id);
+ const count = entry.count + (on === entry.on ? 0 : on ? 1 : -1);
+ setCounts((prev) => ({ ...prev, [id]: Math.max(0, count) }));
+ }
+ pendingMap.clear();
+ },
+ clear() {
+ for (const entry of pendingMap.values()) clearTimeout(entry.timeout);
+ pendingMap.clear();
+ },
+ };
+ }, [setOn, setCounts]);
}
/**
@@ -149,7 +238,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const [recentDumps, setRecentDumps] = useState([]);
const [deletedDumpIds, setDeletedDumpIds] = useState>(new Set());
const [lastVoteEvent, setLastVoteEvent] = useState(null);
- const [lastLikeEvent, setLastLikeEvent] = useState(null);
const [lastDumpEvent, setLastDumpEvent] = useState(null);
const [lastPlaylistEvent, setLastPlaylistEvent] = useState<
PlaylistEvent | null
@@ -179,6 +267,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
>({});
// Never reset: pages compare it with the value they last saw.
const [connectionEpoch, setConnectionEpoch] = useState(0);
+ const [followsVersion, setFollowsVersion] = useState(0);
// A new token means another session: the `welcome` resyncs votes, likes and
// unread counts, but what the last session gathered (another account's
@@ -196,7 +285,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
setRecentDumps([]);
setDeletedDumpIds(new Set());
setLastVoteEvent(null);
- setLastLikeEvent(null);
setLastDumpEvent(null);
setLastPlaylistEvent(null);
setDeletedPlaylistIds(new Set());
@@ -274,64 +362,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
// By dump id, and by comment id for likes. One per item: a second click
// before the answer replaces the first's timer, so only the latest change
// can be rolled back.
- const pendingRef = useRef>(
- new Map(),
- );
- const pendingLikesRef = useRef>(
- new Map(),
- );
-
- const clearPendingVote = useCallback((dumpId: string) => {
- const pending = pendingRef.current.get(dumpId);
- if (!pending) return;
- clearTimeout(pending.timeout);
- pendingRef.current.delete(dumpId);
- }, []);
-
- const clearAllPendingVotes = useCallback(() => {
- for (const pending of pendingRef.current.values()) {
- clearTimeout(pending.timeout);
- }
- pendingRef.current.clear();
- }, []);
-
- const schedulePendingVote = useCallback((
- dumpId: string,
- rollback: () => void,
- ) => {
- clearPendingVote(dumpId);
- const timeout = setTimeout(() => {
- pendingRef.current.delete(dumpId);
- rollback();
- }, ACK_TIMEOUT);
- pendingRef.current.set(dumpId, { timeout, rollback });
- }, [clearPendingVote]);
-
- const clearPendingLike = useCallback((commentId: string) => {
- const pending = pendingLikesRef.current.get(commentId);
- if (!pending) return;
- clearTimeout(pending.timeout);
- pendingLikesRef.current.delete(commentId);
- }, []);
-
- const clearAllPendingLikes = useCallback(() => {
- for (const pending of pendingLikesRef.current.values()) {
- clearTimeout(pending.timeout);
- }
- pendingLikesRef.current.clear();
- }, []);
-
- const schedulePendingLike = useCallback((
- commentId: string,
- rollback: () => void,
- ) => {
- clearPendingLike(commentId);
- const timeout = setTimeout(() => {
- pendingLikesRef.current.delete(commentId);
- rollback();
- }, ACK_TIMEOUT);
- pendingLikesRef.current.set(commentId, { timeout, rollback });
- }, [clearPendingLike]);
+ const votes = useOptimisticToggles(setMyVotes, setVoteCounts);
+ const likes = useOptimisticToggles(setMyLikes, setLikeCounts);
useEffect(() => {
let closed = false;
@@ -387,6 +419,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
case "welcome":
backoff = 500;
setOnlineUsers(msg.users);
+ // Toggles sent before a reconnect may or may not have landed: the
+ // server's sets say which, and fix the counts shown for them.
+ votes.reconcile(new Set(msg.myVotes));
+ likes.reconcile(new Set(msg.myCommentLikes));
setMyVotes(new Set(msg.myVotes));
setMyLikes(new Set(msg.myCommentLikes));
setUnreadNotificationCount(msg.unreadNotificationCount);
@@ -399,10 +435,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
readAt.getTime(),
);
}
- // The server's sets replace whatever was pending. The optimistic
- // counts stay, though, until a live count replaces them.
- clearAllPendingVotes();
- clearAllPendingLikes();
welcomeCount += 1;
if (welcomeCount > 1) {
setConnectionEpoch((n) => n + 1);
@@ -445,12 +477,13 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
case "votes_update": {
const { dumpId, voteCount, voterId, action } = msg;
- setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
setLastVoteEvent({ dumpId, voterId, action });
- // The user's own vote, from this tab or another: it settles the
- // pending one and keeps every tab's button in step.
+ // While this tab has votes on it in flight, their acks settle it.
+ if (votes.pending(dumpId)) break;
+ setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
+ // The user's own vote from another tab keeps this tab's button in
+ // step.
if (voterId === userIdRef.current) {
- clearPendingVote(dumpId);
setMyVotes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(dumpId);
@@ -462,12 +495,12 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
}
case "comment_likes_update": {
- const { commentId, likeCount, likerId, action } = msg;
- setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
- setLastLikeEvent({ commentId, likerId, action });
+ const { commentId, likeCount, likerId } = msg;
// As for votes.
+ if (likes.pending(commentId)) break;
+ setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
if (likerId === userIdRef.current) {
- clearPendingLike(commentId);
+ const { action } = msg;
setMyLikes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(commentId);
@@ -494,6 +527,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
MAX_RECENT_DUMPS,
)
);
+ // The profiles put a dump back where it was when it turns public.
+ setLastDumpEvent(dump);
break;
}
@@ -511,7 +546,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
}
case "dump_deleted": {
- // Also sent when a dump goes private.
+ // Also sent to others when someone's dump goes private.
const { dumpId } = msg;
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
@@ -520,7 +555,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
case "vote_ack": {
const { dumpId, action, voteCount } = msg;
- clearPendingVote(dumpId);
+ // With more clicks in flight, the last ack tells how it ended.
+ if (!votes.ack(dumpId)) break;
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
setMyVotes((prev) => {
const next = new Set(prev);
@@ -533,7 +569,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
case "comment_like_ack": {
const { commentId, action, likeCount } = msg;
- clearPendingLike(commentId);
+ if (!likes.ack(commentId)) break;
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
setMyLikes((prev) => {
const next = new Set(prev);
@@ -669,6 +705,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
setTypingUsers(msg.users);
break;
+ case "follows_changed":
+ setFollowsVersion((n) => n + 1);
+ break;
+
case "chat_read_update": {
// Sent to every socket of the user, whichever tab moved it.
const readAt = new Date(msg.readAt);
@@ -690,21 +730,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
}
// A failed vote or like names its dump or comment, so it is
// rolled back at once. Other errors leave it to the timer.
- if (msg.dumpId) {
- const pending = pendingRef.current.get(msg.dumpId);
- if (pending) {
- clearTimeout(pending.timeout);
- pendingRef.current.delete(msg.dumpId);
- pending.rollback();
- }
- } else if (msg.commentId) {
- const pending = pendingLikesRef.current.get(msg.commentId);
- if (pending) {
- clearTimeout(pending.timeout);
- pendingLikesRef.current.delete(msg.commentId);
- pending.rollback();
- }
- }
+ if (msg.dumpId) votes.fail(msg.dumpId);
+ else if (msg.commentId) likes.fail(msg.commentId);
break;
}
}
@@ -743,172 +770,122 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
connect();
- const pending = pendingRef.current;
- const pendingLikes = pendingLikesRef.current;
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (connectTimeout) clearTimeout(connectTimeout);
socketRef.current?.close();
socketRef.current = null;
- for (const pendingVote of pending.values()) {
- clearTimeout(pendingVote.timeout);
- }
- pending.clear();
- for (const pendingLike of pendingLikes.values()) {
- clearTimeout(pendingLike.timeout);
- }
- pendingLikes.clear();
+ votes.clear();
+ likes.clear();
};
}, [
- clearAllPendingVotes,
- clearPendingVote,
- clearAllPendingLikes,
- clearPendingLike,
+ votes,
+ likes,
failPendingChat,
isViewingChat,
retirePendingChat,
token,
]);
- const castVote = useCallback((dumpId: string, shownCount?: number) => {
+ /**
+ * Shows a vote or like toggled at once, records it with `toggles`, and sends
+ * `type` for it. `on` is the state wanted; a click that asks for the state
+ * already shown does nothing.
+ */
+ const toggle = useCallback((
+ toggles: OptimisticToggles,
+ onRef: React.RefObject>,
+ countsRef: React.RefObject>,
+ setOn: React.Dispatch>>,
+ setCounts: React.Dispatch>>,
+ id: string,
+ on: boolean,
+ shownCount: number | undefined,
+ message: ClientToServerMessage,
+ ) => {
// Until the socket brings a count, the one on screen is the only one.
- const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
- const prevVoted = myVotesRef.current.has(dumpId);
- if (prevVoted) return;
-
- setMyVotes((prev) => {
- const n = new Set(prev);
- n.add(dumpId);
- return n;
+ const prevCount = countsRef.current[id] ?? shownCount ?? 0;
+ const prevOn = onRef.current.has(id);
+ if (prevOn === on) return;
+ setOn((prev) => {
+ const next = new Set(prev);
+ if (on) next.add(id);
+ else next.delete(id);
+ return next;
});
- setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
-
- schedulePendingVote(dumpId, () => {
- setMyVotes((prev) => {
- const n = new Set(prev);
- n.delete(dumpId);
- return n;
- });
- setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
- });
-
- if (socketRef.current?.readyState === WebSocket.OPEN) {
- socketRef.current.send(
- JSON.stringify(
- { type: "vote_cast", dumpId } satisfies ClientToServerMessage,
- ),
- );
- }
- // Nothing is sent while the socket is down, so the timer rolls it back.
- }, [schedulePendingVote]);
-
- const removeVote = useCallback((dumpId: string, shownCount?: number) => {
- const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
- const prevVoted = myVotesRef.current.has(dumpId);
- if (!prevVoted) return;
-
- setMyVotes((prev) => {
- const n = new Set(prev);
- n.delete(dumpId);
- return n;
- });
- setVoteCounts((prev) => ({
+ setCounts((prev) => ({
...prev,
- [dumpId]: Math.max(0, prevCount - 1),
+ [id]: Math.max(0, prevCount + (on ? 1 : -1)),
}));
-
- schedulePendingVote(dumpId, () => {
- setMyVotes((prev) => {
- const n = new Set(prev);
- n.add(dumpId);
- return n;
- });
- setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
- });
-
+ toggles.begin(id, prevCount, prevOn);
if (socketRef.current?.readyState === WebSocket.OPEN) {
- socketRef.current.send(
- JSON.stringify(
- { type: "vote_remove", dumpId } satisfies ClientToServerMessage,
- ),
- );
+ socketRef.current.send(JSON.stringify(message));
}
- }, [schedulePendingVote]);
+ }, []);
+
+ const castVote = useCallback(
+ (dumpId: string, shownCount?: number) =>
+ toggle(
+ votes,
+ myVotesRef,
+ voteCountsRef,
+ setMyVotes,
+ setVoteCounts,
+ dumpId,
+ true,
+ shownCount,
+ { type: "vote_cast", dumpId },
+ ),
+ [toggle, votes],
+ );
+
+ const removeVote = useCallback(
+ (dumpId: string, shownCount?: number) =>
+ toggle(
+ votes,
+ myVotesRef,
+ voteCountsRef,
+ setMyVotes,
+ setVoteCounts,
+ dumpId,
+ false,
+ shownCount,
+ { type: "vote_remove", dumpId },
+ ),
+ [toggle, votes],
+ );
const castCommentLike = useCallback(
- (commentId: string, shownCount?: number) => {
- const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
- const prevLiked = myLikesRef.current.has(commentId);
- if (prevLiked) return;
-
- setMyLikes((prev) => {
- const n = new Set(prev);
- n.add(commentId);
- return n;
- });
- setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 }));
-
- schedulePendingLike(commentId, () => {
- setMyLikes((prev) => {
- const n = new Set(prev);
- n.delete(commentId);
- return n;
- });
- setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
- });
-
- if (socketRef.current?.readyState === WebSocket.OPEN) {
- socketRef.current.send(
- JSON.stringify(
- {
- type: "comment_like_cast",
- commentId,
- } satisfies ClientToServerMessage,
- ),
- );
- }
- },
- [schedulePendingLike],
+ (commentId: string, shownCount?: number) =>
+ toggle(
+ likes,
+ myLikesRef,
+ likeCountsRef,
+ setMyLikes,
+ setLikeCounts,
+ commentId,
+ true,
+ shownCount,
+ { type: "comment_like_cast", commentId },
+ ),
+ [toggle, likes],
);
const removeCommentLike = useCallback(
- (commentId: string, shownCount?: number) => {
- const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
- const prevLiked = myLikesRef.current.has(commentId);
- if (!prevLiked) return;
-
- setMyLikes((prev) => {
- const n = new Set(prev);
- n.delete(commentId);
- return n;
- });
- setLikeCounts((prev) => ({
- ...prev,
- [commentId]: Math.max(0, prevCount - 1),
- }));
-
- schedulePendingLike(commentId, () => {
- setMyLikes((prev) => {
- const n = new Set(prev);
- n.add(commentId);
- return n;
- });
- setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
- });
-
- if (socketRef.current?.readyState === WebSocket.OPEN) {
- socketRef.current.send(
- JSON.stringify(
- {
- type: "comment_like_remove",
- commentId,
- } satisfies ClientToServerMessage,
- ),
- );
- }
- },
- [schedulePendingLike],
+ (commentId: string, shownCount?: number) =>
+ toggle(
+ likes,
+ myLikesRef,
+ likeCountsRef,
+ setMyLikes,
+ setLikeCounts,
+ commentId,
+ false,
+ shownCount,
+ { type: "comment_like_remove", commentId },
+ ),
+ [toggle, likes],
);
const injectDump = useCallback((dump: Dump) => {
@@ -1143,7 +1120,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
recentDumps,
deletedDumpIds,
lastVoteEvent,
- lastLikeEvent,
lastDumpEvent,
lastPlaylistEvent,
deletedPlaylistIds,
@@ -1152,6 +1128,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
unreadNotificationCount,
lastNotification,
connectionEpoch,
+ followsVersion,
castVote,
removeVote,
castCommentLike,
@@ -1168,7 +1145,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
recentDumps,
deletedDumpIds,
lastVoteEvent,
- lastLikeEvent,
lastDumpEvent,
lastPlaylistEvent,
deletedPlaylistIds,
@@ -1177,6 +1153,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
unreadNotificationCount,
lastNotification,
connectionEpoch,
+ followsVersion,
castVote,
removeVote,
castCommentLike,
diff --git a/src/hooks/dump/useDumpListSync.ts b/src/hooks/dump/useDumpListSync.ts
index 6e0e5263..8534d325 100644
--- a/src/hooks/dump/useDumpListSync.ts
+++ b/src/hooks/dump/useDumpListSync.ts
@@ -10,29 +10,20 @@ import type { Dump } from "../../../shared/dump.ts";
import { useWS } from "../ws/useWS.ts";
/**
- * Passed to {@link useDumpListSync} by a profile's list of dumps. The feeds
- * pass none.
+ * Passed to {@link useDumpListSync} by a profile's own list of dumps. The
+ * feeds pass none.
*/
export interface DumpListSyncOptions {
/** Keeps an edited dump that is now private, for its owner's own list. */
isOwner?: boolean;
- /**
- * Prepends an edited dump missing from the list when `ownerId` posted it.
- * Without it, a missing dump is never added.
- */
- ownerId?: string;
- /**
- * Never adds a missing dump, whatever `ownerId`. A list that restores
- * dumps at their old position, with `usePositionAwareSync`, sets it.
- */
- skipReinsert?: boolean;
}
/**
* Applies the socket's dump and comment events to the list that `setDumps`
* updates. A deleted dump is removed, an edited one is replaced in place, and
- * a new or deleted comment moves its dump's `commentCount`. It doesn't add new
- * dumps: the feeds merge `WSContext`'s `recentDumps` themselves.
+ * a new or deleted comment moves its dump's `commentCount`. It never adds a
+ * dump: the feeds merge `WSContext`'s `recentDumps` themselves, and the
+ * profiles put back a dump that returns with `usePositionAwareSync`.
*/
export function useDumpListSync(
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
@@ -57,25 +48,18 @@ export function useDumpListSync(
useEffect(() => {
if (!lastDumpEvent) return;
- const { isOwner, ownerId, skipReinsert } = optionsRef.current ?? {};
+ const isOwner = optionsRef.current?.isOwner;
const dump = lastDumpEvent;
setDumpsRef.current((prev) => {
const idx = prev.findIndex((d) => d.id === dump.id);
-
- if (idx !== -1) {
- if (dump.isPrivate && !isOwner) {
- return prev.filter((d) => d.id !== dump.id);
- }
- const next = [...prev];
- next[idx] = dump;
- return next;
+ if (idx === -1) return prev;
+ if (dump.isPrivate && !isOwner) {
+ return prev.filter((d) => d.id !== dump.id);
}
-
- if (skipReinsert || !ownerId) return prev;
- if (dump.userId !== ownerId) return prev;
- if (dump.isPrivate && !isOwner) return prev;
- return [dump, ...prev];
+ const next = [...prev];
+ next[idx] = dump;
+ return next;
});
}, [lastDumpEvent]);
diff --git a/src/hooks/dump/useUserDumpFeed.ts b/src/hooks/dump/useUserDumpFeed.ts
index 633f7973..e051bf76 100644
--- a/src/hooks/dump/useUserDumpFeed.ts
+++ b/src/hooks/dump/useUserDumpFeed.ts
@@ -15,7 +15,7 @@ import {
} from "react";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
-import { friendlyFetchError } from "../../utils/shared/api.ts";
+import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { PaginatedData, Wire } from "../../../shared/api.ts";
import type { PublicUser } from "../../../shared/user.ts";
@@ -163,9 +163,8 @@ export function useUserDumpFeed(
`${API_URL}/api/users/${username}/${endpoint}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
- .then((r) => r.json())
- .then((body) => {
- const { items, hasMore } = body.data as PaginatedData>;
+ .then((res) => expectOk>>(res))
+ .then(({ items, hasMore }) => {
const newItems = items.map(deserializeDump);
setState((s) =>
s.status === "loaded"
diff --git a/src/hooks/feed/useDefaultFeedTab.ts b/src/hooks/feed/useDefaultFeedTab.ts
index 0ca6a0fa..681cd0f8 100644
--- a/src/hooks/feed/useDefaultFeedTab.ts
+++ b/src/hooks/feed/useDefaultFeedTab.ts
@@ -4,7 +4,7 @@
* @module
*/
-import { useCallback, useState } from "react";
+import { useSyncExternalStore } from "react";
import {
DEFAULT_FEED_TAB,
FEED_TABS,
@@ -13,28 +13,53 @@ import {
const STORAGE_KEY = "default-feed-tab";
+/** The components reading the tab, told when this tab changes it. */
+const listeners = new Set<() => void>();
+
+/** The choice made while storage is blocked, which lasts until a reload. */
+let unstored: FeedTab | null = null;
+
/** Returns {@link DEFAULT_FEED_TAB} when the stored value isn't a tab. */
function readStored(): FeedTab {
- const stored = localStorage.getItem(STORAGE_KEY);
+ let stored: string | null;
+ try {
+ stored = localStorage.getItem(STORAGE_KEY);
+ } catch {
+ stored = unstored;
+ }
return stored && (FEED_TABS as readonly string[]).includes(stored)
? stored as FeedTab
: DEFAULT_FEED_TAB;
}
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ // Another browser tab's choice arrives as a `storage` event.
+ globalThis.addEventListener("storage", listener);
+ return () => {
+ listeners.delete(listener);
+ globalThis.removeEventListener("storage", listener);
+ };
+}
+
+/** Stores `tab`, and tells every component that reads it. */
+function setDefaultFeedTab(tab: FeedTab): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, tab);
+ } catch {
+ unstored = tab;
+ }
+ for (const listener of listeners) listener();
+}
+
/**
* Returns the feed tab the index opens on, and a setter that stores a new
* choice. The profile's settings set it. The index's redirect, its tab bar and
* `Index` read it, and each falls back to {@link DEFAULT_FEED_TAB} when it is
- * "followed" and nobody is signed in. Each call reads the stored tab once, so
- * a change shows in other components only once they remount.
+ * "followed" and nobody is signed in. A new choice reaches every reader at
+ * once, in this browser tab and the others.
*/
export function useDefaultFeedTab(): [FeedTab, (tab: FeedTab) => void] {
- const [tab, setTabState] = useState(readStored);
-
- const setTab = useCallback((t: FeedTab) => {
- localStorage.setItem(STORAGE_KEY, t);
- setTabState(t);
- }, []);
-
- return [tab, setTab];
+ const tab = useSyncExternalStore(subscribe, readStored);
+ return [tab, setDefaultFeedTab];
}
diff --git a/src/hooks/feed/useFeedCache.ts b/src/hooks/feed/useFeedCache.ts
index a7e8af05..47f7099f 100644
--- a/src/hooks/feed/useFeedCache.ts
+++ b/src/hooks/feed/useFeedCache.ts
@@ -7,6 +7,7 @@
import { useCallback, useState } from "react";
import { useNavigationType } from "react-router";
+import { useAuth } from "../auth/useAuth.ts";
/** Past this age, a saved feed is refetched rather than restored. */
const TTL = 10 * 60 * 1000; // 10 minutes
@@ -30,7 +31,7 @@ export interface FeedCacheEntry {
export interface UseFeedCacheResult {
/**
* The saved feed, when the page was reached by going back or forward and the
- * feed was saved less than 10 minutes ago. It doesn't change after mount.
+ * feed was saved less than 10 minutes ago. It changes only with the key.
*/
cached: Omit, "savedAt"> | null;
/** Usually called through `useScrollSave`, as the page is left. */
@@ -42,38 +43,56 @@ export interface UseFeedCacheResult {
) => void;
}
+/** Returns the entry saved under `key`, or null if none is fresh enough. */
+function readEntry(
+ key: string,
+ hydrateItem: (raw: T) => T,
+): Omit, "savedAt"> | null {
+ try {
+ const raw = sessionStorage.getItem(key);
+ if (!raw) return null;
+ const entry = JSON.parse(raw) as FeedCacheEntry;
+ if (Date.now() - entry.savedAt > TTL) return null;
+ return { ...entry, items: entry.items.map(hydrateItem) };
+ } catch {
+ return null;
+ }
+}
+
/**
* Returns the feed saved under `key` and a way to save it. The feed pages and
* `useUserDumpFeed` use it. `key` must tell apart every list whose items
- * differ, the viewer included when they see private items. `hydrateItem`
- * restores what JSON lost, such as dates. A full or blocked storage is
- * ignored.
+ * differ; the viewer is added to it here, since they see their own private
+ * items. `hydrateItem` restores what JSON lost, such as dates. A full or
+ * blocked storage is ignored.
*/
export function useFeedCache(
key: string,
hydrateItem: (raw: T) => T,
): UseFeedCacheResult {
const navType = useNavigationType();
+ const { user } = useAuth();
+ const fullKey = `${key}:${user?.id ?? "guest"}`;
- // Read once: a new navigation to the page must not pick up what it saves.
- const [cached] = useState, "savedAt"> | null>(() => {
- if (navType !== "POP") return null;
- try {
- const raw = sessionStorage.getItem(key);
- if (!raw) return null;
- const entry = JSON.parse(raw) as FeedCacheEntry;
- if (Date.now() - entry.savedAt > TTL) return null;
- return { ...entry, items: entry.items.map(hydrateItem) };
- } catch {
- return null;
- }
- });
+ // Read per key, not on every render: a page that saves as it is left must
+ // not pick up its own save. A new key (another profile, in the same page)
+ // is read afresh.
+ const [read, setRead] = useState(() => ({
+ key: fullKey,
+ cached: navType === "POP" ? readEntry(fullKey, hydrateItem) : null,
+ }));
+ if (read.key !== fullKey) {
+ setRead({
+ key: fullKey,
+ cached: navType === "POP" ? readEntry(fullKey, hydrateItem) : null,
+ });
+ }
const saveState = useCallback(
(items: T[], page: number, hasMore: boolean, scrollY: number) => {
try {
sessionStorage.setItem(
- key,
+ fullKey,
JSON.stringify({
items,
page,
@@ -86,8 +105,11 @@ export function useFeedCache(
// Full or blocked storage: the page just refetches next time.
}
},
- [key],
+ [fullKey],
);
- return { cached, saveState };
+ return {
+ cached: read.key === fullKey ? read.cached : null,
+ saveState,
+ };
}
diff --git a/src/hooks/feed/usePositionAwareSync.ts b/src/hooks/feed/usePositionAwareSync.ts
index 10c9d42b..617d8c97 100644
--- a/src/hooks/feed/usePositionAwareSync.ts
+++ b/src/hooks/feed/usePositionAwareSync.ts
@@ -8,45 +8,37 @@
import { useEffect, useLayoutEffect, useRef } from "react";
/**
- * Remembers the index of an item that `lastEvent` removes from `items`, and
- * puts the item back there when a later event makes it visible again. The
- * profile pages use it with `WSContext`'s latest dump or playlist edit.
- * `items` is the loaded list, or `[]` before it loads, and `setItems` the
- * setter also passed to `useDumpListSync` or `usePlaylistListSync`.
- * `isRemoving` tells an event that takes the item out, and `shouldReinsert`
- * one that brings it back.
- *
- * It must be called before the sync hook, so that its effects run first: it
- * saves the index before the sync hook removes the item. The sync hook must
- * get `skipReinsert`, or it would add the item a second time.
+ * Remembers the index of every item that leaves `items`, whatever took it
+ * out, and puts it back there when `lastEvent` shows it again and
+ * `shouldReinsert` accepts it. The profile pages use it with `WSContext`'s
+ * latest dump or playlist event, beside `useDumpListSync` or
+ * `usePlaylistListSync`, which never re-add an item themselves. `items` is the
+ * loaded list, or `[]` before it loads, and `setItems` its setter.
*/
export function usePositionAwareSync(
items: T[],
setItems: (fn: (prev: T[]) => T[]) => void,
lastEvent: T | null,
- isRemoving: (item: T) => boolean,
shouldReinsert: (item: T) => boolean,
): void {
- const itemsRef = useRef(items);
const setItemsRef = useRef(setItems);
- const isRemovingRef = useRef(isRemoving);
const shouldReinsertRef = useRef(shouldReinsert);
useLayoutEffect(() => {
- itemsRef.current = items;
setItemsRef.current = setItems;
- isRemovingRef.current = isRemoving;
shouldReinsertRef.current = shouldReinsert;
});
+ const previousRef = useRef(items);
const removedPositionsRef = useRef>(new Map());
useEffect(() => {
- if (!lastEvent || !isRemovingRef.current(lastEvent)) return;
- const idx = itemsRef.current.findIndex((item) => item.id === lastEvent.id);
- if (idx !== -1) {
- removedPositionsRef.current.set(lastEvent.id, idx);
- }
- }, [lastEvent]);
+ const present = new Set(items.map((item) => item.id));
+ previousRef.current.forEach((item, idx) => {
+ if (!present.has(item.id)) removedPositionsRef.current.set(item.id, idx);
+ });
+ for (const id of present) removedPositionsRef.current.delete(id);
+ previousRef.current = items;
+ }, [items]);
useEffect(() => {
if (!lastEvent || !shouldReinsertRef.current(lastEvent)) return;
diff --git a/src/hooks/playlist/usePlaylistListSync.ts b/src/hooks/playlist/usePlaylistListSync.ts
index 5bc1f30b..924af43c 100644
--- a/src/hooks/playlist/usePlaylistListSync.ts
+++ b/src/hooks/playlist/usePlaylistListSync.ts
@@ -24,19 +24,14 @@ export interface PlaylistListSyncOptions {
* and go.
*/
noNewEntries?: boolean;
- /**
- * Leaves out a playlist that comes back into view, for a caller that puts it
- * back where it was with `usePositionAwareSync`.
- */
- skipReinsert?: boolean;
}
/**
* Applies the socket's playlist events to the list set through
* `setPlaylists`. A created playlist goes first, an updated one changes in
* place or leaves when it turns private (unless `isOwner`), a deleted one
- * leaves, and a
- * playlist's `dumpCount` follows its dumps.
+ * leaves, and a playlist's `dumpCount` follows its dumps. An update never
+ * adds a playlist the list doesn't hold.
*/
export function usePlaylistListSync(
setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void,
@@ -67,15 +62,9 @@ export function usePlaylistListSync(
if (ev.type === "updated" && ev.playlist) {
const idx = prev.findIndex((p) => p.id === ev.playlist!.id);
- if (idx === -1) {
- // Left the list when it turned private, and may be back. A followed
- // list can't tell whether the user still follows it.
- if (noNewEntries) return prev;
- if (ownerId && ev.playlist.userId !== ownerId) return prev;
- if (!ev.playlist.isPublic && !isOwner) return prev;
- if (optionsRef.current?.skipReinsert) return prev;
- return [ev.playlist, ...prev];
- }
+ // One that left when it turned private is put back by
+ // `usePositionAwareSync`, where it was.
+ if (idx === -1) return prev;
if (!ev.playlist.isPublic && !isOwner) {
return prev.filter((p) => p.id !== ev.playlist!.id);
}
diff --git a/src/locales/en.js b/src/locales/en.js
index 5785dcc8..e5ab9685 100644
--- a/src/locales/en.js
+++ b/src/locales/en.js
@@ -1,5 +1,5 @@
/*eslint-disable*/ module.exports = {
messages: JSON.parse(
- '{"-K9EZb":["Add email…"],"-OxI15":["Followed playlists"],"-Ya-b9":["Failed to save"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Search dumps, users, playlists…"],"1QfxQT":["Dismiss"],"1YKhJ3":["1 new message"],"1cbYY_":["Upvoted (",["0"],["1"],")"],"1njn7W":["Light"],"1utXA6":["Dumps"],"26iNma":["Post comment"],"2DoBvq":["Feeds"],"2Hlmdt":["Write a reply…"],"2_DHgA":["URL slug"],"2nrsEn":["deleted message"],"2ygf_L":["← Back"],"3KKSM4":["private"],"3QuNUr":["Several people are typing…"],"3SEwY1":["Delete category \\"",["0"],"\\"? This cannot be undone."],"3qkggm":["Fullscreen"],"3yfh3D":["<0>",["0"],"0> followed your playlist <1>",["1"],"1>"],"48e2R8":["Edit email"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Dumped!"],"4GKuCs":["Login failed"],"4HH9iB":["Not following anyone yet."],"4OjqAQ":["Keep editing"],"4RtQ1k":["Unfollow ",["targetUsername"]],"4c-qBx":["Your password has been changed. You can now log in."],"4yj9xV":["Live updates are temporarily disconnected. Trying to reconnect…"],"5KHDCi":[["0"]," and ",["1"]," are typing…"],"5TviPn":["Cancel search"],"5cC8f2":["Edited ",["0"]],"5oD9f_":["Earlier"],"6Qly-0":["a comment"],"6YE0P6":["Editing ",["0"]],"6YtxFj":["Name"],"6gRgw8":["Retry"],"7PHCIN":["Cancel removal"],"7PzzBU":["User"],"7d1a0d":["Public"],"7sNhEz":["Username"],"8-8abB":["Disconnected — reconnecting…"],"8F1i42":["Page not found"],"8Ug9jB":["Related"],"8ZsakT":["Password"],"92o1-U":["Failed to delete"],"9BruTc":["Why?"],"9l4qcT":["Drop a replacement here"],"9uI_rE":["Undo"],"9xDZu_":["Could not load."],"A0y396":["+ Invite someone"],"A1taO8":["Search"],"AEfu-D":["In collections"],"AHZflp":["Chat"],"AQbgNR":["Follow ",["targetUsername"]],"AS7WoE":["Start fresh"],"ATGYL1":["Email address"],"AZctoV":[["0","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"Ade-6d":["Live updates unavailable."],"AeXO77":["Account"],"AhNP40":["Seek"],"AnRu_j":["Cancel reply"],"B4LeYP":["Delete failed (",["0"],")"],"BjitBo":["Download ",["0"]],"BlpJ2G":["Picked up where you left off."],"CI50ct":["Upvoted"],"C_qQE4":["Already dumped by ",["0"]," ",["1"]],"Cj24wt":["Registering…"],"D8DRYV":["Delete this message?"],"DI3NBO":["Open on ",["0"]],"DPfwMq":["Done"],"DdeHXH":["Delete this dump? This cannot be undone."],"Dp1JhP":["<0>",["0"],"0> upvoted <1>",["1"],"1>"],"EBU6O6":["Connection lost — this message may not have been sent"],"ECiS12":["View dump →"],"ED8n0z":["the chat"],"EarrCe":["Search emoji"],"Evcm2Z":["Close without posting? The attached file won\'t be kept — everything you typed will."],"EwVrWF":["The dump couldn\'t be removed. Try again."],"ExR0Fr":["URL is required."],"F5Js1v":["Unfollow playlist"],"FdTlEG":["Track ",["0"]],"FgAxTj":["Log out"],"Fxf4jq":["Description (optional)"],"GCWCCo":["Close player"],"GDvlUT":["Role"],"GLukaH":["Moderator"],"GNSsCc":["Failed to create playlist"],"GQ38dl":["Chat (",["unreadChatCount"]," unread)"],"GY6uSc":["Next →"],"GbqhrN":[["0"],"–",["1"]," characters: letters, numbers, or underscores"],"GptGxg":["Change password"],"H4MGkI":["Expand player"],"H4o4sk":["No one yet."],"H8pzW-":["Failed to update avatar"],"HTLDA4":["Loading more…"],"H_PnT8":["Insert emoji"],"I-x669":["Invitees"],"IO9KL_":["Next track"],"IZX7TO":["Failed to generate invite"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Reply"],"JJ-Bhk":["Your email address"],"JRQitQ":["Confirm new password"],"JXr41k":["<0>",["0"],"0> commented on <1>",["1"],"1>"],"Jd58Fo":["Hot"],"Jf0PuK":["Go to login"],"JlFcis":["Send"],"K7tIrx":["Category"],"KDGWg5":["Remove from playlist"],"KDw4GX":["Try again"],"KKZxBU":["Uploading ",["0"],"%"],"K_F6pa":["Saving…"],"KtYF-t":["Sounds"],"L-rMC9":["Reset to default"],"L7_rsx":["Uploading image…"],"LLyMkV":["Followed (",["0"],["1"],")"],"MHrjPM":["Title"],"MKEPCY":["Follow"],"MTb2Ot":["No messages yet. Say hello!"],"Mj6oCJ":[["0"]," is typing…"],"N40H-G":["All"],"N6j2JH":["Edit ",["0"]],"NUrY9o":["Categories"],"O32mmL":["Edit description"],"Oprv1v":["Password (min. ",["0"]," characters)"],"Oz0N9s":["new"],"PN3pBl":["No reply from the server — this message may not have been sent"],"PUe8Yk":["Collapse player"],"PiH3UR":["Copied!"],"Pn2B7_":["Current password"],"Pwqkdw":["Loading…"],"Q4WUjR":["Search emoji…"],"Q6n4F4":["Refresh metadata"],"QGaQRo":["Failed to update role"],"QKsaQr":["or <0>browse files0>"],"QLtPBd":["No dumps in this playlist yet."],"R7SsBE":["Mute"],"R9Khdg":["Auto"],"RCcPrX":["Delete this playlist? This cannot be undone."],"RTksSy":["<0>",["0"],"0> started following you"],"RaKjrM":["Failed to save edit"],"RcUHRT":["Followed"],"RfwZxd":["Reset password"],"Rrp6-J":["Log in to like"],"RvVJBH":["Refreshing stream…"],"S1xV5B":["At most ",["maxLength"]," characters."],"SBTElJ":["Searching…"],"Sad2tK":["Sending…"],"So7gCD":["Paste a link…"],"StovX6":["This page does not exist."],"Sxm8rQ":["Users"],"T8ejtZ":["This comment couldn\'t be deleted. Try again."],"T91vKp":["Play"],"T9bjWt":["<0>",["0"],"0> was added to <1>",["1"],"1>"],"TAtbho":["Loading messages…"],"TM1ZbA":["Playlists (",["0"],["1"],")"],"TN382O":["Invalid link"],"Tv9vbB":["Follow playlist"],"Tz0i8g":["Settings"],"U3pytU":["Admin"],"UNMVei":["Forgot password?"],"UOZith":["Failed to post"],"URAieT":["Log in to vote"],"VCoEm-":["Back to login"],"VKsaTi":["Replying to"],"V_e7nf":["Set new password"],"VnNJbN":["From playlists"],"VskHIx":["Delete category"],"VyTYmS":["Change avatar"],"W9FRBT":["Like"],"WAcnnR":["The image couldn\'t be uploaded."],"WE_r9K":["Failed to create category"],"WfffrI":["slug"],"WhimMi":["Reset failed"],"WpXcBJ":["Nothing here yet."],"XHqhoS":["Couldn\'t load the chat."],"XJVYo8":["Why & where"],"XJy2oN":["Logging in…"],"Xan6QP":["New dump"],"XgRtUf":["Could not change password"],"Xi0Mn4":["← Back to profile"],"XnL-Eu":["No users match \\"",["q"],"\\"."],"Xs2Lez":["This reset link is missing or malformed."],"YK1Dhc":["a post"],"YZxXK8":["Close emoji picker"],"YcjhhX":["User management"],"Ye9RMF":["<0>",["0"],"0> liked your comment on <1>",["1"],"1>"],"YpkCca":["No followed playlists yet."],"Z5HWHd":["On"],"ZBdbv9":["Edit title"],"ZCpU0u":["No playlists match \\"",["q"],"\\"."],"ZWxjuD":["Some images couldn\'t be uploaded."],"ZmD2o6":["Create & Add"],"_3O5R_":["Request failed"],"_DwR-n":["Creating…"],"_R_sGB":["Password changed successfully."],"_aept4":["Post reply"],"_cF7Rs":["Volume"],"_karOe":["You\'re offline — not sent"],"_nT6AE":["New password"],"_t4W-i":["From people"],"aAIQg2":["Appearance"],"aDvLhk":["Add a comment…"],"az8lvo":["Off"],"b3Thhd":["Upload failed"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"bQhwn-":["Loading playlist…"],"bzSI52":["Discard"],"cILfnJ":["Remove file"],"cNu4my":[["0"]," more online: ",["restNames"]],"cYP9Sb":["+ Playlist"],"cbeBbZ":["At least ",["0"]," characters"],"cnGeoo":["Delete"],"dAs22m":["Replace file"],"dEgA5A":["Cancel"],"dMizp8":["New playlist"],"dau1Pl":[["unseenCount"]," new messages"],"dqwCfR":["Couldn\'t load a preview for this link — check it, or post it as-is and refresh the preview later."],"dznB-Y":["Only images can be dropped here."],"eFSqvc":["Failed to post reply"],"eIEHHV":["Change skin tone"],"eOfXq3":["Title is required."],"ePK91l":["Edit"],"eaUTwS":["Send reset link"],"ecUA8p":["Today"],"ef9nPf":["Loading dump…"],"eloqXQ":["You can post it anyway."],"en9o7K":["Failed to post comment"],"etFQQS":["What makes it worth it?"],"fC6mXb":["Upvote"],"fI-mNw":["Playlists"],"f_akpP":["Max 50 MB"],"fgLNSM":["Register"],"gANddk":["Uploading…"],"gGx5tM":["Editing"],"gIQQwD":["Failed to load"],"gL9KKi":["Who can see it"],"gLfZlz":["Add to playlist"],"gjJ-sb":["Can\'t connect to the live updates server. Upvotes and notifications may not sync until it reconnects."],"goWEhR":["Previous track"],"hBuUKa":["Change password…"],"hD7w09":["You\'ve reached the end."],"hJSliC":["<0>",["0"],"0> posted <1>",["1"],"1>"],"hT8hgO":["Reset to the suggested title"],"hYgDIe":["Create"],"he3ygx":["Copy"],"hpCc_Z":["Close anyway"],"i7K_Te":["Who am I?"],"iDNBZe":["Notifications"],"iWpEwy":["Go home"],"ipYn7W":["If that address is registered you\'ll receive a reset link shortly."],"isRobC":["New"],"jbernk":["Loading profile…"],"joEmfT":["Server unreachable"],"jrZTZl":["No dumps yet. Be the first!"],"k112DD":["Load older messages"],"k36iJ-":["Failed to save category"],"k4b5_X":["edited"],"kLttbL":["Registration failed"],"lUDifl":["Created (",["0"],["1"],")"],"lUanmi":["You\'ll be notified when someone follows your playlists, upvotes your dumps, or posts new content."],"lY5h1V":[["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]],"lcfvr_":["Delete this comment?"],"lpIMne":["Passwords do not match"],"mb_Wju":["Drop a file here, or paste one"],"mt6O6E":["This is a mirage."],"nbm5sI":["No dumps match \\"",["q"],"\\"."],"nrjqON":["Checking invite…"],"nwtY4N":["Something went wrong"],"nx4kaN":["Could not save"],"oTYvvN":[["0","plural",{"one":["and ","#"," earlier dump"],"other":["and ","#"," earlier dumps"]}]],"ogtYkT":["Password updated"],"pCpd9p":["<0>",["0"],"0> mentioned you in <1>",["where"],"1>"],"pSheLH":["No invitees yet."],"pvnfJD":["Dark"],"qIMfNQ":["Delete playlist"],"qbDAcy":["Dump it"],"qgx_78":["Follow some public playlists to see their dumps here."],"qsqt0w":["Invite link"],"qvFa8r":["public"],"qvz_Pp":["Dump"],"rCbqPX":["This invite link is missing, expired, or already used."],"rDMzDU":["Type a message…"],"rg9pXu":["Search failed"],"rn6SBY":["Unmute"],"rtpJqV":["Dumps (",["0"],["1"],")"],"sEXJPi":["Link or file"],"sGeXL3":["Thumbnail"],"sQia9P":["Log in"],"sTiqbm":["invited by"],"sdP5Aa":["[deleted]"],"sgX7B-":["Single use, valid for 7 days."],"shHs8T":["Enter a query to search."],"smeBfS":["Invalid invite"],"teaneZ":["image"],"tfDRzk":["Save"],"tvmuQ0":["Color scheme"],"u1lDX2":["Fetching preview…"],"uD0qXQ":["Drop a file here"],"uISnsY":["Could not delete"],"uMGUnV":["No playlists yet."],"ub1EEL":["edited ",["0"]],"uomLas":["Add category"],"v-Wp--":["Open source"],"vJBF1r":["Posting…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," unread)"],"vQMkHu":["Remove vote"],"vuosjb":["User menu"],"wbXKOv":["File too large (max 50 MB)."],"wckWOP":["Manage"],"wixIgH":["Already have an account? <0>Log in0>"],"x6tjuK":["Default tab"],"xEWkgZ":["← Back to all dumps"],"xPHtx0":["Submit search"],"xVuNgt":["+ New playlist"],"xc9O_u":["Delete dump"],"y6sq5j":["Following"],"y7oaHj":["Playlist title"],"yA_6BX":["View all →"],"yBBtRm":["Follow some users to see their dumps here."],"yQ2kGp":["Load more"],"y_0uwd":["Yesterday"],"yz7wBu":["Close"],"yzF66j":["Link"],"z0ROB3":["Remove like"],"z1uNN0":["No emoji found."],"zVuxvN":["Refreshing…"],"zwBp5t":["Private"]}',
+ '{"-K9EZb":["Add email…"],"-OxI15":["Followed playlists"],"-Ya-b9":["Failed to save"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Search dumps, users, playlists…"],"1QfxQT":["Dismiss"],"1YKhJ3":["1 new message"],"1cbYY_":["Upvoted (",["0"],["1"],")"],"1njn7W":["Light"],"1utXA6":["Dumps"],"26iNma":["Post comment"],"2DoBvq":["Feeds"],"2Hlmdt":["Write a reply…"],"2_DHgA":["URL slug"],"2nrsEn":["deleted message"],"2ygf_L":["← Back"],"3KKSM4":["private"],"3QuNUr":["Several people are typing…"],"3SEwY1":["Delete category \\"",["0"],"\\"? This cannot be undone."],"3qkggm":["Fullscreen"],"3yfh3D":["<0>",["0"],"0> followed your playlist <1>",["1"],"1>"],"48e2R8":["Edit email"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Dumped!"],"4GKuCs":["Login failed"],"4HH9iB":["Not following anyone yet."],"4OjqAQ":["Keep editing"],"4RtQ1k":["Unfollow ",["targetUsername"]],"4c-qBx":["Your password has been changed. You can now log in."],"4yj9xV":["Live updates are temporarily disconnected. Trying to reconnect…"],"5KHDCi":[["0"]," and ",["1"]," are typing…"],"5TviPn":["Cancel search"],"5cC8f2":["Edited ",["0"]],"5oD9f_":["Earlier"],"6Qly-0":["a comment"],"6YE0P6":["Editing ",["0"]],"6YtxFj":["Name"],"6gRgw8":["Retry"],"7PHCIN":["Cancel removal"],"7PzzBU":["User"],"7d1a0d":["Public"],"7sNhEz":["Username"],"8-8abB":["Disconnected — reconnecting…"],"8F1i42":["Page not found"],"8Ug9jB":["Related"],"8ZsakT":["Password"],"92o1-U":["Failed to delete"],"9BruTc":["Why?"],"9l4qcT":["Drop a replacement here"],"9uI_rE":["Undo"],"9xDZu_":["Could not load."],"A0y396":["+ Invite someone"],"A1taO8":["Search"],"AEfu-D":["In collections"],"AHZflp":["Chat"],"AQbgNR":["Follow ",["targetUsername"]],"AS7WoE":["Start fresh"],"ATGYL1":["Email address"],"AZctoV":[["0","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"Ade-6d":["Live updates unavailable."],"AeXO77":["Account"],"AhNP40":["Seek"],"AnRu_j":["Cancel reply"],"B4LeYP":["Delete failed (",["0"],")"],"BjitBo":["Download ",["0"]],"BlpJ2G":["Picked up where you left off."],"CI50ct":["Upvoted"],"C_qQE4":["Already dumped by ",["0"]," ",["1"]],"Cj24wt":["Registering…"],"CnqayG":["Couldn\'t update. Try again."],"D8DRYV":["Delete this message?"],"DI3NBO":["Open on ",["0"]],"DPfwMq":["Done"],"DdeHXH":["Delete this dump? This cannot be undone."],"Dp1JhP":["<0>",["0"],"0> upvoted <1>",["1"],"1>"],"EBU6O6":["Connection lost — this message may not have been sent"],"ECiS12":["View dump →"],"ED8n0z":["the chat"],"EarrCe":["Search emoji"],"Evcm2Z":["Close without posting? The attached file won\'t be kept — everything you typed will."],"EwVrWF":["The dump couldn\'t be removed. Try again."],"ExR0Fr":["URL is required."],"F5Js1v":["Unfollow playlist"],"FdTlEG":["Track ",["0"]],"FgAxTj":["Log out"],"Fxf4jq":["Description (optional)"],"GCWCCo":["Close player"],"GDvlUT":["Role"],"GLukaH":["Moderator"],"GNSsCc":["Failed to create playlist"],"GQ38dl":["Chat (",["unreadChatCount"]," unread)"],"GY6uSc":["Next →"],"GbqhrN":[["0"],"–",["1"]," characters: letters, numbers, or underscores"],"GptGxg":["Change password"],"Gv5nA5":["Unexpected response format"],"H4MGkI":["Expand player"],"H4o4sk":["No one yet."],"H8pzW-":["Failed to update avatar"],"HTLDA4":["Loading more…"],"H_PnT8":["Insert emoji"],"I-x669":["Invitees"],"IO9KL_":["Next track"],"IZX7TO":["Failed to generate invite"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Reply"],"JJ-Bhk":["Your email address"],"JRQitQ":["Confirm new password"],"JXr41k":["<0>",["0"],"0> commented on <1>",["1"],"1>"],"Jd58Fo":["Hot"],"Jf0PuK":["Go to login"],"JlFcis":["Send"],"K7tIrx":["Category"],"KDGWg5":["Remove from playlist"],"KDw4GX":["Try again"],"KKZxBU":["Uploading ",["0"],"%"],"K_F6pa":["Saving…"],"KtYF-t":["Sounds"],"L-rMC9":["Reset to default"],"L7_rsx":["Uploading image…"],"LLyMkV":["Followed (",["0"],["1"],")"],"MHrjPM":["Title"],"MKEPCY":["Follow"],"MTb2Ot":["No messages yet. Say hello!"],"Mj6oCJ":[["0"]," is typing…"],"N40H-G":["All"],"N6j2JH":["Edit ",["0"]],"NUrY9o":["Categories"],"O32mmL":["Edit description"],"Oprv1v":["Password (min. ",["0"]," characters)"],"Oz0N9s":["new"],"P5MuiB":["Add image"],"PN3pBl":["No reply from the server — this message may not have been sent"],"PUe8Yk":["Collapse player"],"PiH3UR":["Copied!"],"Pn2B7_":["Current password"],"Pwqkdw":["Loading…"],"Q4WUjR":["Search emoji…"],"Q6n4F4":["Refresh metadata"],"QGaQRo":["Failed to update role"],"QKsaQr":["or <0>browse files0>"],"QLtPBd":["No dumps in this playlist yet."],"QctOgc":["Could not connect to the server. Check your connection and try again."],"R7SsBE":["Mute"],"R9Khdg":["Auto"],"RCcPrX":["Delete this playlist? This cannot be undone."],"RTksSy":["<0>",["0"],"0> started following you"],"RaKjrM":["Failed to save edit"],"RcUHRT":["Followed"],"RfwZxd":["Reset password"],"Rrp6-J":["Log in to like"],"RvVJBH":["Refreshing stream…"],"S1xV5B":["At most ",["maxLength"]," characters."],"SBTElJ":["Searching…"],"Sad2tK":["Sending…"],"So7gCD":["Paste a link…"],"StovX6":["This page does not exist."],"Sxm8rQ":["Users"],"T8ejtZ":["This comment couldn\'t be deleted. Try again."],"T91vKp":["Play"],"T9bjWt":["<0>",["0"],"0> was added to <1>",["1"],"1>"],"TAtbho":["Loading messages…"],"TM1ZbA":["Playlists (",["0"],["1"],")"],"TN382O":["Invalid link"],"Tv9vbB":["Follow playlist"],"Tz0i8g":["Settings"],"U3pytU":["Admin"],"UNMVei":["Forgot password?"],"UOZith":["Failed to post"],"URAieT":["Log in to vote"],"V38P77":["Only its poster or a moderator can edit this dump."],"VCoEm-":["Back to login"],"VKsaTi":["Replying to"],"VLAd8f":["Max ",["limit"]],"V_e7nf":["Set new password"],"VnNJbN":["From playlists"],"VskHIx":["Delete category"],"VyTYmS":["Change avatar"],"W5A0Ly":["An unexpected error occurred."],"W9FRBT":["Like"],"WAcnnR":["The image couldn\'t be uploaded."],"WE_r9K":["Failed to create category"],"WfffrI":["slug"],"WhimMi":["Reset failed"],"WpXcBJ":["Nothing here yet."],"XHqhoS":["Couldn\'t load the chat."],"XJVYo8":["Why & where"],"XJy2oN":["Logging in…"],"Xan6QP":["New dump"],"XgRtUf":["Could not change password"],"Xi0Mn4":["← Back to profile"],"XnL-Eu":["No users match \\"",["q"],"\\"."],"Xs2Lez":["This reset link is missing or malformed."],"YK1Dhc":["a post"],"YZxXK8":["Close emoji picker"],"YcjhhX":["User management"],"Ye9RMF":["<0>",["0"],"0> liked your comment on <1>",["1"],"1>"],"YpkCca":["No followed playlists yet."],"Z5HWHd":["On"],"ZBdbv9":["Edit title"],"ZCpU0u":["No playlists match \\"",["q"],"\\"."],"ZWxjuD":["Some images couldn\'t be uploaded."],"ZmD2o6":["Create & Add"],"_3O5R_":["Request failed"],"_DwR-n":["Creating…"],"_R_sGB":["Password changed successfully."],"_aept4":["Post reply"],"_cF7Rs":["Volume"],"_karOe":["You\'re offline — not sent"],"_nT6AE":["New password"],"_t4W-i":["From people"],"aAIQg2":["Appearance"],"aDvLhk":["Add a comment…"],"az8lvo":["Off"],"b3Thhd":["Upload failed"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," comment"],"other":["#"," comments"]}]],"bQhwn-":["Loading playlist…"],"bzSI52":["Discard"],"cILfnJ":["Remove file"],"cNu4my":[["0"]," more online: ",["restNames"]],"cYP9Sb":["+ Playlist"],"cbeBbZ":["At least ",["0"]," characters"],"cnGeoo":["Delete"],"dAs22m":["Replace file"],"dEgA5A":["Cancel"],"dMizp8":["New playlist"],"dau1Pl":[["unseenCount"]," new messages"],"dqwCfR":["Couldn\'t load a preview for this link — check it, or post it as-is and refresh the preview later."],"dznB-Y":["Only images can be dropped here."],"eFSqvc":["Failed to post reply"],"eIEHHV":["Change skin tone"],"eOfXq3":["Title is required."],"ePK91l":["Edit"],"eaUTwS":["Send reset link"],"ecUA8p":["Today"],"ef9nPf":["Loading dump…"],"eloqXQ":["You can post it anyway."],"en9o7K":["Failed to post comment"],"etFQQS":["What makes it worth it?"],"f3cEDO":["Change image"],"fC6mXb":["Upvote"],"fI-mNw":["Playlists"],"fgLNSM":["Register"],"gANddk":["Uploading…"],"gGx5tM":["Editing"],"gIQQwD":["Failed to load"],"gL9KKi":["Who can see it"],"gLfZlz":["Add to playlist"],"gjJ-sb":["Can\'t connect to the live updates server. Upvotes and notifications may not sync until it reconnects."],"goWEhR":["Previous track"],"hBuUKa":["Change password…"],"hD7w09":["You\'ve reached the end."],"hJSliC":["<0>",["0"],"0> posted <1>",["1"],"1>"],"hT8hgO":["Reset to the suggested title"],"hYgDIe":["Create"],"he3ygx":["Copy"],"hfk-mb":["The dump couldn\'t be put back. Try again."],"hpCc_Z":["Close anyway"],"i7K_Te":["Who am I?"],"iDNBZe":["Notifications"],"iWpEwy":["Go home"],"ipYn7W":["If that address is registered you\'ll receive a reset link shortly."],"isRobC":["New"],"jbernk":["Loading profile…"],"joEmfT":["Server unreachable"],"jrZTZl":["No dumps yet. Be the first!"],"k112DD":["Load older messages"],"k36iJ-":["Failed to save category"],"k4b5_X":["edited"],"kLttbL":["Registration failed"],"lUDifl":["Created (",["0"],["1"],")"],"lUanmi":["You\'ll be notified when someone follows your playlists, upvotes your dumps, or posts new content."],"lY5h1V":[["0","plural",{"one":["#"," dump"],"other":["#"," dumps"]}]],"lcfvr_":["Delete this comment?"],"lpIMne":["Passwords do not match"],"mb_Wju":["Drop a file here, or paste one"],"mt6O6E":["This is a mirage."],"nbm5sI":["No dumps match \\"",["q"],"\\"."],"nrjqON":["Checking invite…"],"nwtY4N":["Something went wrong"],"nx4kaN":["Could not save"],"oTYvvN":[["0","plural",{"one":["and ","#"," earlier dump"],"other":["and ","#"," earlier dumps"]}]],"ogtYkT":["Password updated"],"pCpd9p":["<0>",["0"],"0> mentioned you in <1>",["where"],"1>"],"pSheLH":["No invitees yet."],"pd3FCq":["That file is over ",["limit"],"."],"pvnfJD":["Dark"],"qIMfNQ":["Delete playlist"],"qbDAcy":["Dump it"],"qgx_78":["Follow some public playlists to see their dumps here."],"qsqt0w":["Invite link"],"qvFa8r":["public"],"qvz_Pp":["Dump"],"rCbqPX":["This invite link is missing, expired, or already used."],"rDMzDU":["Type a message…"],"rg9pXu":["Search failed"],"rn6SBY":["Unmute"],"rtpJqV":["Dumps (",["0"],["1"],")"],"sEXJPi":["Link or file"],"sGeXL3":["Thumbnail"],"sQia9P":["Log in"],"sTiqbm":["invited by"],"sdP5Aa":["[deleted]"],"sgX7B-":["Single use, valid for 7 days."],"shHs8T":["Enter a query to search."],"smeBfS":["Invalid invite"],"teaneZ":["image"],"tfDRzk":["Save"],"tvmuQ0":["Color scheme"],"u1lDX2":["Fetching preview…"],"uD0qXQ":["Drop a file here"],"uISnsY":["Could not delete"],"uMGUnV":["No playlists yet."],"ub1EEL":["edited ",["0"]],"uomLas":["Add category"],"v-Wp--":["Open source"],"vJBF1r":["Posting…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," unread)"],"vQMkHu":["Remove vote"],"vuosjb":["User menu"],"wckWOP":["Manage"],"wixIgH":["Already have an account? <0>Log in0>"],"wtxFp4":["File too large (max ",["max"],")."],"x6tjuK":["Default tab"],"xEWkgZ":["← Back to all dumps"],"xPHtx0":["Submit search"],"xVuNgt":["+ New playlist"],"xc9O_u":["Delete dump"],"y6sq5j":["Following"],"y7oaHj":["Playlist title"],"yA_6BX":["View all →"],"yBBtRm":["Follow some users to see their dumps here."],"yQ2kGp":["Load more"],"y_0uwd":["Yesterday"],"yz7wBu":["Close"],"yzF66j":["Link"],"z0ROB3":["Remove like"],"z1uNN0":["No emoji found."],"zVuxvN":["Refreshing…"],"zwBp5t":["Private"]}',
),
};
diff --git a/src/locales/en.po b/src/locales/en.po
index e62ec567..27d32613 100644
--- a/src/locales/en.po
+++ b/src/locales/en.po
@@ -13,1587 +13,1623 @@ msgstr ""
"Language-Team: \n"
"Plural-Forms: \n"
-#: src/components/comment/CommentNode.tsx:123
+#: src/components/comment/CommentNode.tsx:139
msgid "[deleted]"
msgstr "[deleted]"
#. placeholder {0}: dump.commentCount
-#: src/components/dump/DumpCard.tsx:116
-#: src/components/dump/JournalCard.tsx:120
+#: src/components/dump/DumpCard.tsx:139
+#: src/components/dump/JournalCard.tsx:133
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}"
#. placeholder {0}: playlist.dumpCount
-#: src/components/playlist/PlaylistCard.tsx:87
+#: src/components/playlist/PlaylistCard.tsx:108
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# dump} other {# dumps}}"
#. placeholder {0}: rest.length
-#: src/components/dump/create/DuplicateNotice.tsx:34
+#: src/components/dump/create/DuplicateNotice.tsx:52
msgid "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
#. placeholder {0}: typingNames[0]
#. placeholder {1}: typingNames[1]
-#: src/components/chat/ChatModal.tsx:606
+#: src/components/chat/ChatModal.tsx:627
msgid "{0} and {1} are typing…"
msgstr "{0} and {1} are typing…"
#. placeholder {0}: typingNames[0]
-#: src/components/chat/ChatModal.tsx:604
+#: src/components/chat/ChatModal.tsx:625
msgid "{0} is typing…"
msgstr "{0} is typing…"
#. placeholder {0}: rest.length
-#: src/components/feed/PresenceRow.tsx:66
+#: src/components/feed/PresenceRow.tsx:81
msgid "{0} more online: {restNames}"
msgstr "{0} more online: {restNames}"
#. placeholder {0}: VALIDATION.USERNAME_MIN
#. placeholder {1}: VALIDATION.USERNAME_MAX
-#: src/pages/UserRegister.tsx:127
+#: src/pages/UserRegister.tsx:145
msgid "{0}–{1} characters: letters, numbers, or underscores"
msgstr "{0}–{1} characters: letters, numbers, or underscores"
-#: src/pages/Search.tsx:190
+#: src/pages/Search.tsx:201
msgid "{label} ({count})"
msgstr "{label} ({count})"
-#: src/components/chat/ChatModal.tsx:735
+#: src/components/chat/ChatModal.tsx:756
msgid "{unseenCount} new messages"
msgstr "{unseenCount} new messages"
-#: src/components/comment/CommentThread.tsx:61
+#: src/components/comment/CommentThread.tsx:82
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
-#: src/components/dump/create/DumpCreateModal.tsx:766
-#: src/pages/PlaylistDetail.tsx:603
-#: src/pages/UserPublicProfile.tsx:816
+#: src/components/dump/create/DumpCreateModal.tsx:778
+#: src/pages/PlaylistDetail.tsx:630
+#: src/pages/UserPublicProfile.tsx:853
msgid "← Back"
msgstr "← Back"
-#: src/pages/Dump.tsx:287
-#: src/pages/Dump.tsx:530
-#: src/pages/DumpEdit.tsx:193
+#: src/pages/Dump.tsx:314
+#: src/pages/Dump.tsx:554
+#: src/pages/DumpEdit.tsx:217
msgid "← Back to all dumps"
msgstr "← Back to all dumps"
-#: src/pages/UserDumps.tsx:66
-#: src/pages/UserPlaylists.tsx:364
-#: src/pages/UserUpvoted.tsx:144
+#: src/pages/UserDumps.tsx:76
+#: src/pages/UserPlaylists.tsx:380
+#: src/pages/UserUpvoted.tsx:169
msgid "← Back to profile"
msgstr "← Back to profile"
-#: src/pages/UserPublicProfile.tsx:134
+#: src/pages/UserPublicProfile.tsx:142
msgid "+ Invite someone"
msgstr "+ Invite someone"
-#: src/components/playlist/PlaylistMembershipPanel.tsx:90
+#: src/components/playlist/PlaylistMembershipPanel.tsx:107
msgid "+ New playlist"
msgstr "+ New playlist"
-#: src/pages/Dump.tsx:365
+#: src/pages/Dump.tsx:391
msgid "+ Playlist"
msgstr "+ Playlist"
#. placeholder {0}: d.commenterUsername
#. placeholder {1}: d.dumpTitle
-#: src/pages/Notifications.tsx:198
+#: src/pages/Notifications.tsx:211
msgid "<0>{0}0> commented on <1>{1}1>"
msgstr "<0>{0}0> commented on <1>{1}1>"
#. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle
-#: src/pages/Notifications.tsx:158
+#: src/pages/Notifications.tsx:171
msgid "<0>{0}0> followed your playlist <1>{1}1>"
msgstr "<0>{0}0> followed your playlist <1>{1}1>"
#. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle
-#: src/pages/Notifications.tsx:208
+#: src/pages/Notifications.tsx:221
msgid "<0>{0}0> liked your comment on <1>{1}1>"
msgstr "<0>{0}0> liked your comment on <1>{1}1>"
#. placeholder {0}: d.mentionerUsername
-#: src/pages/Notifications.tsx:224
+#: src/pages/Notifications.tsx:237
msgid "<0>{0}0> mentioned you in <1>{where}1>"
msgstr "<0>{0}0> mentioned you in <1>{where}1>"
#. placeholder {0}: d.dumperUsername
#. placeholder {1}: d.dumpTitle
-#: src/pages/Notifications.tsx:168
+#: src/pages/Notifications.tsx:181
msgid "<0>{0}0> posted <1>{1}1>"
msgstr "<0>{0}0> posted <1>{1}1>"
#. placeholder {0}: d.followerUsername
-#: src/pages/Notifications.tsx:149
+#: src/pages/Notifications.tsx:162
msgid "<0>{0}0> started following you"
msgstr "<0>{0}0> started following you"
#. placeholder {0}: d.voterUsername
#. placeholder {1}: d.dumpTitle
-#: src/pages/Notifications.tsx:188
+#: src/pages/Notifications.tsx:201
msgid "<0>{0}0> upvoted <1>{1}1>"
msgstr "<0>{0}0> upvoted <1>{1}1>"
#. placeholder {0}: d.dumpTitle
#. placeholder {1}: d.playlistTitle
-#: src/pages/Notifications.tsx:178
+#: src/pages/Notifications.tsx:191
msgid "<0>{0}0> was added to <1>{1}1>"
msgstr "<0>{0}0> was added to <1>{1}1>"
-#: src/components/chat/ChatModal.tsx:734
+#: src/components/chat/ChatModal.tsx:755
msgid "1 new message"
msgstr "1 new message"
-#: src/pages/Notifications.tsx:219
+#: src/pages/Notifications.tsx:232
msgid "a comment"
msgstr "a comment"
-#: src/pages/Notifications.tsx:222
+#: src/pages/Notifications.tsx:235
msgid "a post"
msgstr "a post"
-#: src/pages/UserPublicProfile.tsx:1224
+#: src/pages/UserPublicProfile.tsx:1261
msgid "Account"
msgstr "Account"
-#: src/components/comment/CommentThread.tsx:81
+#: src/components/comment/CommentThread.tsx:102
msgid "Add a comment…"
msgstr "Add a comment…"
-#: src/components/category/CategoryManager.tsx:95
-#: src/components/category/CategoryManager.tsx:96
+#: src/components/category/CategoryManager.tsx:104
+#: src/components/category/CategoryManager.tsx:105
msgid "Add category"
msgstr "Add category"
-#: src/pages/UserPublicProfile.tsx:902
+#: src/pages/UserPublicProfile.tsx:939
msgid "Add email…"
msgstr "Add email…"
-#: src/components/dump/create/DumpCreateModal.tsx:487
-#: src/components/playlist/AddToPlaylistModal.tsx:60
+#: src/components/shared/ImagePicker.tsx:63
+msgid "Add image"
+msgstr "Add image"
+
+#: src/components/dump/create/DumpCreateModal.tsx:500
+#: src/components/playlist/AddToPlaylistModal.tsx:72
msgid "Add to playlist"
msgstr "Add to playlist"
-#: src/pages/UserPublicProfile.tsx:1694
+#: src/pages/UserPublicProfile.tsx:1726
msgid "Admin"
msgstr "Admin"
-#: src/components/category/CategorySwitcher.tsx:80
-#: src/components/category/CategorySwitcher.tsx:83
+#: src/components/category/CategorySwitcher.tsx:84
+#: src/components/category/CategorySwitcher.tsx:87
msgid "All"
msgstr "All"
#. placeholder {0}: first.username
#. placeholder {1}: relativeTime(first.createdAt)
-#: src/components/dump/create/DuplicateNotice.tsx:21
+#: src/components/dump/create/DuplicateNotice.tsx:39
msgid "Already dumped by {0} {1}"
msgstr "Already dumped by {0} {1}"
-#: src/pages/UserRegister.tsx:157
+#: src/pages/UserRegister.tsx:175
msgid "Already have an account? <0>Log in0>"
msgstr "Already have an account? <0>Log in0>"
-#: src/pages/UserPublicProfile.tsx:1243
+#: src/utils/shared/api.ts:34
+msgid "An unexpected error occurred."
+msgstr "An unexpected error occurred."
+
+#: src/pages/UserPublicProfile.tsx:1280
msgid "Appearance"
msgstr "Appearance"
#. placeholder {0}: VALIDATION.PASSWORD_MIN
-#: src/components/profile/ChangePasswordModal.tsx:88
-#: src/pages/ResetPassword.tsx:110
-#: src/pages/UserRegister.tsx:146
+#: src/components/profile/ChangePasswordModal.tsx:102
+#: src/pages/ResetPassword.tsx:121
+#: src/pages/UserRegister.tsx:164
msgid "At least {0} characters"
msgstr "At least {0} characters"
-#: src/components/form/RichTextField.tsx:56
+#: src/components/form/RichTextField.tsx:66
msgid "At most {maxLength} characters."
msgstr "At most {maxLength} characters."
-#: src/pages/UserPublicProfile.tsx:1305
+#: src/pages/UserPublicProfile.tsx:1342
msgid "Auto"
msgstr "Auto"
-#: src/pages/ResetPassword.tsx:60
-#: src/pages/ResetPassword.tsx:133
+#: src/pages/ResetPassword.tsx:71
+#: src/pages/ResetPassword.tsx:144
msgid "Back to login"
msgstr "Back to login"
-#: src/contexts/ws/WSProvider.tsx:333
-#: src/contexts/ws/WSProvider.tsx:716
+#: src/contexts/ws/WSProvider.tsx:392
+#: src/contexts/ws/WSProvider.tsx:758
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."
-#: src/components/category/CategoryManager.tsx:212
-#: src/components/category/CategoryManager.tsx:213
-#: src/components/chat/ChatEditBox.tsx:70
-#: src/components/comment/CommentForm.tsx:105
-#: src/components/dump/create/DumpCreateModal.tsx:776
-#: src/components/form/FormActions.tsx:32
-#: src/components/shared/ConfirmModal.tsx:47
-#: src/pages/Dump.tsx:406
-#: src/pages/DumpEdit.tsx:508
-#: src/pages/PlaylistDetail.tsx:971
-#: src/pages/UserPublicProfile.tsx:1823
-#: src/pages/UserPublicProfile.tsx:1898
+#: src/components/category/CategoryManager.tsx:225
+#: src/components/category/CategoryManager.tsx:226
+#: src/components/chat/ChatEditBox.tsx:88
+#: src/components/comment/CommentForm.tsx:127
+#: src/components/dump/create/DumpCreateModal.tsx:788
+#: src/components/form/FormActions.tsx:42
+#: src/components/shared/ConfirmModal.tsx:63
+#: src/pages/Dump.tsx:432
+#: src/pages/DumpEdit.tsx:542
+#: src/pages/PlaylistDetail.tsx:1014
+#: src/pages/UserPublicProfile.tsx:1863
+#: src/pages/UserPublicProfile.tsx:1942
msgid "Cancel"
msgstr "Cancel"
-#: src/pages/PlaylistDetail.tsx:782
+#: src/pages/PlaylistDetail.tsx:808
msgid "Cancel removal"
msgstr "Cancel removal"
-#: src/components/chat/ChatModal.tsx:795
-#: src/components/chat/ChatModal.tsx:796
+#: src/components/chat/ChatModal.tsx:816
+#: src/components/chat/ChatModal.tsx:817
msgid "Cancel reply"
msgstr "Cancel reply"
-#: src/components/layout/AppHeader.tsx:130
+#: src/components/layout/AppHeader.tsx:162
msgid "Cancel search"
msgstr "Cancel search"
-#: src/components/category/CategorySelect.tsx:22
-#: src/pages/UserPublicProfile.tsx:1420
+#: src/components/category/CategorySelect.tsx:33
+#: src/pages/UserPublicProfile.tsx:1457
msgid "Categories"
msgstr "Categories"
-#: src/components/category/CategorySwitcher.tsx:103
+#: src/components/category/CategorySwitcher.tsx:107
msgid "Category"
msgstr "Category"
-#: src/pages/UserPublicProfile.tsx:843
+#: src/pages/UserPublicProfile.tsx:880
msgid "Change avatar"
msgstr "Change avatar"
-#: src/components/profile/ChangePasswordModal.tsx:53
-#: src/components/profile/ChangePasswordModal.tsx:107
+#: src/components/shared/ImagePicker.tsx:63
+msgid "Change image"
+msgstr "Change image"
+
+#: src/components/profile/ChangePasswordModal.tsx:67
+#: src/components/profile/ChangePasswordModal.tsx:121
msgid "Change password"
msgstr "Change password"
-#: src/pages/UserPublicProfile.tsx:1236
+#: src/pages/UserPublicProfile.tsx:1273
msgid "Change password…"
msgstr "Change password…"
-#: src/components/editor/EmojiPickerPopup.tsx:85
+#: src/components/editor/EmojiPickerPopup.tsx:108
msgid "Change skin tone"
msgstr "Change skin tone"
-#: src/components/chat/ChatModal.tsx:695
-#: src/components/layout/ChatButton.tsx:17
-#: src/components/layout/ChatFab.tsx:49
-#: src/components/layout/ChatFab.tsx:50
+#: src/components/chat/ChatModal.tsx:716
+#: src/components/layout/ChatButton.tsx:26
+#: src/components/layout/ChatFab.tsx:53
+#: src/components/layout/ChatFab.tsx:54
msgid "Chat"
msgstr "Chat"
-#: src/components/layout/ChatButton.tsx:16
-#: src/components/layout/ChatFab.tsx:48
+#: src/components/layout/ChatButton.tsx:25
+#: src/components/layout/ChatFab.tsx:52
msgid "Chat ({unreadChatCount} unread)"
msgstr "Chat ({unreadChatCount} unread)"
-#: src/pages/UserRegister.tsx:86
+#: src/pages/UserRegister.tsx:106
msgid "Checking invite…"
msgstr "Checking invite…"
-#: src/components/profile/ChangePasswordModal.tsx:62
-#: src/components/shared/Modal.tsx:60
+#: src/components/profile/ChangePasswordModal.tsx:76
+#: src/components/shared/Modal.tsx:76
msgid "Close"
msgstr "Close"
-#: src/components/dump/create/DumpCreateModal.tsx:518
+#: src/components/dump/create/DumpCreateModal.tsx:531
msgid "Close anyway"
msgstr "Close anyway"
-#: src/components/editor/EmojiPickerPopup.tsx:90
+#: src/components/editor/EmojiPickerPopup.tsx:113
msgid "Close emoji picker"
msgstr "Close emoji picker"
-#: src/components/player/GlobalPlayer.tsx:242
+#: src/components/player/GlobalPlayer.tsx:262
msgid "Close player"
msgstr "Close player"
-#: src/components/dump/create/DumpCreateModal.tsx:508
+#: src/components/dump/create/DumpCreateModal.tsx:521
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Close without posting? The attached file won't be kept — everything you typed will."
-#: src/components/player/GlobalPlayer.tsx:234
+#: src/components/player/GlobalPlayer.tsx:254
msgid "Collapse player"
msgstr "Collapse player"
-#: src/pages/UserPublicProfile.tsx:1297
+#: src/pages/UserPublicProfile.tsx:1334
msgid "Color scheme"
msgstr "Color scheme"
-#: src/components/profile/ChangePasswordModal.tsx:95
-#: src/pages/ResetPassword.tsx:117
+#: src/components/profile/ChangePasswordModal.tsx:109
+#: src/pages/ResetPassword.tsx:128
msgid "Confirm new password"
msgstr "Confirm new password"
-#: src/contexts/ws/WSProvider.tsx:709
+#: src/contexts/ws/WSProvider.tsx:751
msgid "Connection lost — this message may not have been sent"
msgstr "Connection lost — this message may not have been sent"
-#: src/pages/UserPublicProfile.tsx:159
+#: src/pages/UserPublicProfile.tsx:167
msgid "Copied!"
msgstr "Copied!"
-#: src/pages/UserPublicProfile.tsx:159
+#: src/pages/UserPublicProfile.tsx:167
msgid "Copy"
msgstr "Copy"
-#: src/components/profile/ChangePasswordModal.tsx:104
+#: src/components/profile/ChangePasswordModal.tsx:118
msgid "Could not change password"
msgstr "Could not change password"
-#: src/pages/DumpEdit.tsx:496
-#: src/pages/UserPlaylists.tsx:396
+#: src/utils/shared/api.ts:32
+msgid "Could not connect to the server. Check your connection and try again."
+msgstr "Could not connect to the server. Check your connection and try again."
+
+#: src/pages/DumpEdit.tsx:530
+#: src/pages/UserPlaylists.tsx:412
msgid "Could not delete"
msgstr "Could not delete"
-#: src/components/social/UserListPopover.tsx:138
+#: src/components/social/UserListPopover.tsx:166
msgid "Could not load."
msgstr "Could not load."
-#: src/pages/DumpEdit.tsx:456
+#: src/pages/DumpEdit.tsx:490
msgid "Could not save"
msgstr "Could not save"
-#: src/components/dump/create/DumpCreateModal.tsx:630
+#: src/components/dump/create/DumpCreateModal.tsx:643
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/chat/ChatModal.tsx:621
+#: src/components/chat/ChatModal.tsx:642
msgid "Couldn't load the chat."
msgstr "Couldn't load the chat."
-#: src/components/playlist/PlaylistCreateForm.tsx:93
+#: src/components/social/FollowButton.tsx:112
+msgid "Couldn't update. Try again."
+msgstr "Couldn't update. Try again."
+
+#: src/components/playlist/PlaylistCreateForm.tsx:112
msgid "Create"
msgstr "Create"
-#: src/components/playlist/PlaylistCreateForm.tsx:93
+#: src/components/playlist/PlaylistCreateForm.tsx:112
msgid "Create & Add"
msgstr "Create & Add"
#. placeholder {0}: created.items.length
#. placeholder {1}: created.hasMore ? "+" : ""
-#: src/pages/UserPlaylists.tsx:402
+#: src/pages/UserPlaylists.tsx:418
msgid "Created ({0}{1})"
msgstr "Created ({0}{1})"
-#: src/components/playlist/PlaylistCreateForm.tsx:92
+#: src/components/playlist/PlaylistCreateForm.tsx:111
msgid "Creating…"
msgstr "Creating…"
-#: src/components/profile/ChangePasswordModal.tsx:73
+#: src/components/profile/ChangePasswordModal.tsx:87
msgid "Current password"
msgstr "Current password"
-#: src/pages/UserPublicProfile.tsx:1319
+#: src/pages/UserPublicProfile.tsx:1356
msgid "Dark"
msgstr "Dark"
-#: src/pages/UserPublicProfile.tsx:1332
+#: src/pages/UserPublicProfile.tsx:1369
msgid "Default tab"
msgstr "Default tab"
-#: src/components/category/CategoryManager.tsx:223
-#: src/components/category/CategoryManager.tsx:224
-#: src/components/chat/ChatMessageItem.tsx:88
-#: src/components/chat/ChatMessageItem.tsx:89
-#: src/components/chat/ChatMessageItem.tsx:187
-#: src/components/comment/CommentNode.tsx:248
-#: src/components/comment/CommentNode.tsx:254
-#: src/components/shared/ConfirmModal.tsx:17
-#: src/pages/PlaylistDetail.tsx:967
+#: src/components/category/CategoryManager.tsx:236
+#: src/components/category/CategoryManager.tsx:237
+#: src/components/chat/ChatMessageItem.tsx:114
+#: src/components/chat/ChatMessageItem.tsx:115
+#: src/components/chat/ChatMessageItem.tsx:213
+#: src/components/comment/CommentNode.tsx:263
+#: src/components/comment/CommentNode.tsx:269
+#: src/components/shared/ConfirmModal.tsx:34
+#: src/pages/PlaylistDetail.tsx:1010
msgid "Delete"
msgstr "Delete"
-#: src/components/category/CategoryManager.tsx:240
+#: src/components/category/CategoryManager.tsx:253
msgid "Delete category"
msgstr "Delete category"
#. placeholder {0}: category.name
-#: src/components/category/CategoryManager.tsx:239
+#: src/components/category/CategoryManager.tsx:252
msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Delete category \"{0}\"? This cannot be undone."
-#: src/pages/DumpEdit.tsx:273
-#: src/pages/DumpEdit.tsx:504
+#: src/pages/DumpEdit.tsx:299
+#: src/pages/DumpEdit.tsx:538
msgid "Delete dump"
msgstr "Delete dump"
#. placeholder {0}: res.status
-#: src/pages/DumpEdit.tsx:155
+#: src/pages/DumpEdit.tsx:178
msgid "Delete failed ({0})"
msgstr "Delete failed ({0})"
-#: src/components/playlist/PlaylistCard.tsx:110
-#: src/pages/PlaylistDetail.tsx:795
-#: src/pages/UserPlaylists.tsx:479
+#: src/components/playlist/PlaylistCard.tsx:132
+#: src/pages/PlaylistDetail.tsx:821
+#: src/pages/UserPlaylists.tsx:495
msgid "Delete playlist"
msgstr "Delete playlist"
-#: src/components/comment/CommentNode.tsx:253
+#: src/components/comment/CommentNode.tsx:268
msgid "Delete this comment?"
msgstr "Delete this comment?"
-#: src/pages/DumpEdit.tsx:272
+#: src/pages/DumpEdit.tsx:298
msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone."
-#: src/components/chat/ChatMessageItem.tsx:186
+#: src/components/chat/ChatMessageItem.tsx:212
msgid "Delete this message?"
msgstr "Delete this message?"
-#: src/pages/PlaylistDetail.tsx:794
-#: src/pages/UserPlaylists.tsx:478
+#: src/pages/PlaylistDetail.tsx:820
+#: src/pages/UserPlaylists.tsx:494
msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone."
-#: src/components/chat/ReplyRef.tsx:31
+#: src/components/chat/ReplyRef.tsx:49
msgid "deleted message"
msgstr "deleted message"
-#: src/components/playlist/PlaylistCreateForm.tsx:85
-#: src/pages/PlaylistDetail.tsx:928
+#: src/components/playlist/PlaylistCreateForm.tsx:104
+#: src/pages/PlaylistDetail.tsx:971
msgid "Description (optional)"
msgstr "Description (optional)"
-#: src/components/chat/PendingItem.tsx:84
+#: src/components/chat/PendingItem.tsx:100
msgid "Discard"
msgstr "Discard"
-#: src/components/chat/ChatModal.tsx:699
+#: src/components/chat/ChatModal.tsx:720
msgid "Disconnected — reconnecting…"
msgstr "Disconnected — reconnecting…"
-#: src/components/chat/ChatModal.tsx:771
-#: src/components/chat/ChatModal.tsx:772
+#: src/components/chat/ChatModal.tsx:792
+#: src/components/chat/ChatModal.tsx:793
msgid "Dismiss"
msgstr "Dismiss"
-#: src/components/dump/create/DumpCreateModal.tsx:569
+#: src/components/dump/create/DumpCreateModal.tsx:582
msgid "Done"
msgstr "Done"
#. placeholder {0}: dump.fileName
-#: src/components/dump/FilePreview.tsx:334
+#: src/components/dump/FilePreview.tsx:358
msgid "Download {0}"
msgstr "Download {0}"
-#: src/components/form/FileDropZone.tsx:29
+#: src/components/form/FileDropZone.tsx:55
msgid "Drop a file here"
msgstr "Drop a file here"
-#: src/components/dump/create/DumpCreateModal.tsx:677
+#: src/components/dump/create/DumpCreateModal.tsx:689
msgid "Drop a file here, or paste one"
msgstr "Drop a file here, or paste one"
-#: src/pages/DumpEdit.tsx:448
+#: src/pages/DumpEdit.tsx:481
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
-#: src/components/layout/AppHeader.tsx:170
+#: src/components/layout/AppHeader.tsx:201
msgid "Dump"
msgstr "Dump"
-#: src/components/dump/create/DumpCreateModal.tsx:789
+#: src/components/dump/create/DumpCreateModal.tsx:801
msgid "Dump it"
msgstr "Dump it"
-#: src/components/dump/create/DumpCreateModal.tsx:546
+#: src/components/dump/create/DumpCreateModal.tsx:559
msgid "Dumped!"
msgstr "Dumped!"
-#: src/pages/Search.tsx:186
-#: src/pages/UserDumps.tsx:80
-#: src/pages/UserPublicProfile.tsx:989
+#: src/pages/Search.tsx:197
+#: src/pages/UserDumps.tsx:90
+#: src/pages/UserPublicProfile.tsx:1026
msgid "Dumps"
msgstr "Dumps"
#. placeholder {0}: dumps.items.length
#. placeholder {1}: dumps.hasMore ? "+" : ""
-#: src/pages/UserPublicProfile.tsx:1009
+#: src/pages/UserPublicProfile.tsx:1046
msgid "Dumps ({0}{1})"
msgstr "Dumps ({0}{1})"
-#: src/pages/Notifications.tsx:434
+#: src/pages/Notifications.tsx:472
msgid "Earlier"
msgstr "Earlier"
-#: src/components/chat/ChatMessageItem.tsx:77
-#: src/components/chat/ChatMessageItem.tsx:78
-#: src/components/chat/PendingItem.tsx:77
-#: src/components/comment/CommentNode.tsx:239
-#: src/pages/Dump.tsx:526
-#: src/pages/PlaylistDetail.tsx:658
+#: src/components/chat/ChatMessageItem.tsx:103
+#: src/components/chat/ChatMessageItem.tsx:104
+#: src/components/chat/PendingItem.tsx:93
+#: src/components/comment/CommentNode.tsx:254
+#: src/pages/Dump.tsx:550
+#: src/pages/PlaylistDetail.tsx:684
msgid "Edit"
msgstr "Edit"
#. placeholder {0}: category.name
-#: src/components/category/CategoryManager.tsx:75
+#: src/components/category/CategoryManager.tsx:84
msgid "Edit {0}"
msgstr "Edit {0}"
-#: src/pages/UserPublicProfile.tsx:953
+#: src/pages/UserPublicProfile.tsx:990
msgid "Edit description"
msgstr "Edit description"
-#: src/pages/UserPublicProfile.tsx:900
+#: src/pages/UserPublicProfile.tsx:937
msgid "Edit email"
msgstr "Edit email"
-#: src/components/dump/create/TitleField.tsx:55
-#: src/pages/Dump.tsx:422
+#: src/components/dump/create/TitleField.tsx:69
+#: src/pages/Dump.tsx:448
msgid "Edit title"
msgstr "Edit title"
-#: src/components/chat/ChatMessageItem.tsx:101
+#: src/components/chat/ChatMessageItem.tsx:127
msgid "edited"
msgstr "edited"
#. placeholder {0}: relativeTime(comment.updatedAt)
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
-#: src/components/chat/ChatMessageItem.tsx:102
-#: src/components/comment/CommentNode.tsx:188
-#: src/pages/Dump.tsx:465
-#: src/pages/PlaylistDetail.tsx:697
+#: src/components/chat/ChatMessageItem.tsx:128
+#: src/components/comment/CommentNode.tsx:203
+#: src/pages/Dump.tsx:491
+#: src/pages/PlaylistDetail.tsx:723
msgid "edited {0}"
msgstr "edited {0}"
#. placeholder {0}: comment.updatedAt.toLocaleString()
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
-#: src/components/chat/ChatMessageItem.tsx:98
-#: src/components/comment/CommentNode.tsx:186
-#: src/pages/Dump.tsx:463
-#: src/pages/PlaylistDetail.tsx:694
+#: src/components/chat/ChatMessageItem.tsx:124
+#: src/components/comment/CommentNode.tsx:201
+#: src/pages/Dump.tsx:489
+#: src/pages/PlaylistDetail.tsx:720
msgid "Edited {0}"
msgstr "Edited {0}"
-#: src/pages/DumpEdit.tsx:218
+#: src/pages/DumpEdit.tsx:242
msgid "Editing"
msgstr "Editing"
#. placeholder {0}: state.dump.title
-#: src/pages/DumpEdit.tsx:55
+#: src/pages/DumpEdit.tsx:72
msgid "Editing {0}"
msgstr "Editing {0}"
-#: src/pages/UserRegister.tsx:134
+#: src/pages/UserRegister.tsx:152
msgid "Email address"
msgstr "Email address"
-#: src/pages/Search.tsx:219
+#: src/pages/Search.tsx:230
msgid "Enter a query to search."
msgstr "Enter a query to search."
-#: src/components/player/GlobalPlayer.tsx:234
+#: src/components/player/GlobalPlayer.tsx:254
msgid "Expand player"
msgstr "Expand player"
-#: src/components/category/CategoryManager.tsx:233
+#: src/components/category/CategoryManager.tsx:246
msgid "Failed to create category"
msgstr "Failed to create category"
-#: src/components/playlist/PlaylistCreateForm.tsx:90
+#: src/components/playlist/PlaylistCreateForm.tsx:109
msgid "Failed to create playlist"
msgstr "Failed to create playlist"
-#: src/components/category/CategoryManager.tsx:156
+#: src/components/category/CategoryManager.tsx:169
msgid "Failed to delete"
msgstr "Failed to delete"
-#: src/pages/UserPublicProfile.tsx:104
-#: src/pages/UserPublicProfile.tsx:107
-#: src/pages/UserPublicProfile.tsx:168
+#: src/pages/UserPublicProfile.tsx:111
+#: src/pages/UserPublicProfile.tsx:114
+#: src/pages/UserPublicProfile.tsx:176
msgid "Failed to generate invite"
msgstr "Failed to generate invite"
-#: src/components/feed/DumpFeed.tsx:59
-#: src/components/feed/FollowedFeed.tsx:92
-#: src/components/feed/JournalFeed.tsx:33
-#: src/pages/Notifications.tsx:408
-#: src/pages/UserPublicProfile.tsx:1111
-#: src/pages/UserPublicProfile.tsx:1157
-#: src/pages/UserPublicProfile.tsx:1202
+#: src/components/feed/DumpFeed.tsx:87
+#: src/components/feed/FollowedFeed.tsx:105
+#: src/components/feed/JournalFeed.tsx:45
+#: src/pages/Notifications.tsx:446
+#: src/pages/UserPublicProfile.tsx:1148
+#: src/pages/UserPublicProfile.tsx:1194
+#: src/pages/UserPublicProfile.tsx:1239
msgid "Failed to load"
msgstr "Failed to load"
-#: src/components/dump/create/DumpCreateModal.tsx:578
+#: src/components/dump/create/DumpCreateModal.tsx:591
msgid "Failed to post"
msgstr "Failed to post"
-#: src/components/comment/CommentThread.tsx:80
+#: src/components/comment/CommentThread.tsx:101
msgid "Failed to post comment"
msgstr "Failed to post comment"
-#: src/components/comment/CommentNode.tsx:274
+#: src/components/comment/CommentNode.tsx:289
msgid "Failed to post reply"
msgstr "Failed to post reply"
-#: src/pages/PlaylistDetail.tsx:959
-#: src/pages/UserPublicProfile.tsx:1826
-#: src/pages/UserPublicProfile.tsx:1900
+#: src/pages/PlaylistDetail.tsx:1002
+#: src/pages/UserPublicProfile.tsx:1866
+#: src/pages/UserPublicProfile.tsx:1944
msgid "Failed to save"
msgstr "Failed to save"
-#: src/components/category/CategoryManager.tsx:234
+#: src/components/category/CategoryManager.tsx:247
msgid "Failed to save category"
msgstr "Failed to save category"
-#: src/components/comment/CommentNode.tsx:201
+#: src/components/comment/CommentNode.tsx:216
msgid "Failed to save edit"
msgstr "Failed to save edit"
-#: src/pages/UserPublicProfile.tsx:911
+#: src/pages/UserPublicProfile.tsx:948
msgid "Failed to update avatar"
msgstr "Failed to update avatar"
-#: src/pages/UserPublicProfile.tsx:1764
+#: src/pages/UserPublicProfile.tsx:1800
msgid "Failed to update role"
msgstr "Failed to update role"
-#: src/pages/UserPublicProfile.tsx:1327
+#: src/pages/UserPublicProfile.tsx:1364
msgid "Feeds"
msgstr "Feeds"
-#: src/components/dump/create/DumpCreateModal.tsx:617
+#: src/components/dump/create/DumpCreateModal.tsx:630
msgid "Fetching preview…"
msgstr "Fetching preview…"
-#: src/components/dump/create/DumpCreateModal.tsx:404
-msgid "File too large (max 50 MB)."
-msgstr "File too large (max 50 MB)."
+#: src/components/dump/create/DumpCreateModal.tsx:420
+msgid "File too large (max {max})."
+msgstr "File too large (max {max})."
-#: src/components/social/FollowButton.tsx:37
-#: src/components/social/FollowButton.tsx:64
+#: src/components/social/FollowButton.tsx:62
+#: src/components/social/FollowButton.tsx:102
msgid "Follow"
msgstr "Follow"
-#: src/components/social/FollowButton.tsx:35
+#: src/components/social/FollowButton.tsx:60
msgid "Follow {targetUsername}"
msgstr "Follow {targetUsername}"
-#: src/components/social/FollowButton.tsx:62
+#: src/components/social/FollowButton.tsx:100
msgid "Follow playlist"
msgstr "Follow playlist"
-#: src/components/feed/FollowedFeed.tsx:378
+#: src/components/feed/FollowedFeed.tsx:388
msgid "Follow some public playlists to see their dumps here."
msgstr "Follow some public playlists to see their dumps here."
-#: src/components/feed/FollowedFeed.tsx:364
+#: src/components/feed/FollowedFeed.tsx:375
msgid "Follow some users to see their dumps here."
msgstr "Follow some users to see their dumps here."
-#: src/components/feed/FeedTabBar.tsx:40
-#: src/pages/UserPublicProfile.tsx:991
-#: src/pages/UserPublicProfile.tsx:1361
+#: src/components/feed/FeedTabBar.tsx:48
+#: src/pages/UserPublicProfile.tsx:1028
+#: src/pages/UserPublicProfile.tsx:1398
msgid "Followed"
msgstr "Followed"
#. placeholder {0}: followed.items.length
#. placeholder {1}: followed.hasMore ? "+" : ""
-#: src/pages/UserPlaylists.tsx:444
+#: src/pages/UserPlaylists.tsx:460
msgid "Followed ({0}{1})"
msgstr "Followed ({0}{1})"
-#: src/pages/UserPublicProfile.tsx:1146
+#: src/pages/UserPublicProfile.tsx:1183
msgid "Followed playlists"
msgstr "Followed playlists"
-#: src/components/social/FollowButton.tsx:37
-#: src/components/social/FollowButton.tsx:64
-#: src/pages/UserPublicProfile.tsx:1100
+#: src/components/social/FollowButton.tsx:62
+#: src/components/social/FollowButton.tsx:102
+#: src/pages/UserPublicProfile.tsx:1137
msgid "Following"
msgstr "Following"
-#: src/pages/UserLogin.tsx:87
+#: src/pages/UserLogin.tsx:97
msgid "Forgot password?"
msgstr "Forgot password?"
-#: src/components/feed/FollowedFeed.tsx:347
+#: src/components/feed/FollowedFeed.tsx:359
msgid "From people"
msgstr "From people"
-#: src/components/feed/FollowedFeed.tsx:348
+#: src/components/feed/FollowedFeed.tsx:360
msgid "From playlists"
msgstr "From playlists"
-#: src/components/player/MediaPlayer.tsx:353
+#: src/components/player/MediaPlayer.tsx:373
msgid "Fullscreen"
msgstr "Fullscreen"
-#: src/pages/NotFound.tsx:16
+#: src/pages/NotFound.tsx:24
msgid "Go home"
msgstr "Go home"
-#: src/pages/ResetPassword.tsx:82
+#: src/pages/ResetPassword.tsx:93
msgid "Go to login"
msgstr "Go to login"
-#: src/components/feed/FeedTabBar.tsx:36
-#: src/pages/UserPublicProfile.tsx:1340
+#: src/components/feed/FeedTabBar.tsx:44
+#: src/pages/UserPublicProfile.tsx:1377
msgid "Hot"
msgstr "Hot"
-#: src/pages/UserLogin.tsx:122
+#: src/pages/UserLogin.tsx:136
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "If that address is registered you'll receive a reset link shortly."
-#: src/utils/chat/message.ts:24
+#: src/utils/chat/message.ts:43
msgid "image"
msgstr "image"
-#: src/pages/Dump.tsx:560
+#: src/pages/Dump.tsx:582
msgid "In collections"
msgstr "In collections"
-#: src/components/editor/TextEditor.tsx:503
-#: src/components/editor/TextEditor.tsx:505
+#: src/components/editor/TextEditor.tsx:544
+#: src/components/editor/TextEditor.tsx:546
msgid "Insert emoji"
msgstr "Insert emoji"
-#: src/pages/UserRegister.tsx:97
+#: src/pages/UserRegister.tsx:117
msgid "Invalid invite"
msgstr "Invalid invite"
-#: src/pages/ResetPassword.tsx:50
+#: src/pages/ResetPassword.tsx:61
msgid "Invalid link"
msgstr "Invalid link"
-#: src/pages/UserPublicProfile.tsx:142
-#: src/pages/UserPublicProfile.tsx:149
+#: src/pages/UserPublicProfile.tsx:150
+#: src/pages/UserPublicProfile.tsx:157
msgid "Invite link"
msgstr "Invite link"
-#: src/pages/UserPublicProfile.tsx:865
+#: src/pages/UserPublicProfile.tsx:902
msgid "invited by"
msgstr "invited by"
-#: src/pages/UserPublicProfile.tsx:992
-#: src/pages/UserPublicProfile.tsx:1191
+#: src/pages/UserPublicProfile.tsx:1029
+#: src/pages/UserPublicProfile.tsx:1228
msgid "Invitees"
msgstr "Invitees"
-#: src/components/feed/FeedTabBar.tsx:38
-#: src/pages/UserPublicProfile.tsx:1354
+#: src/components/feed/FeedTabBar.tsx:46
+#: src/pages/UserPublicProfile.tsx:1391
msgid "Journal"
msgstr "Journal"
-#: src/components/dump/create/DumpCreateModal.tsx:515
+#: src/components/dump/create/DumpCreateModal.tsx:528
msgid "Keep editing"
msgstr "Keep editing"
-#: src/pages/UserPublicProfile.tsx:1312
+#: src/pages/UserPublicProfile.tsx:1349
msgid "Light"
msgstr "Light"
-#: src/components/social/LikeButton.tsx:31
+#: src/components/social/LikeButton.tsx:54
msgid "Like"
msgstr "Like"
-#: src/components/dump/create/DumpCreateModal.tsx:592
+#: src/components/dump/create/DumpCreateModal.tsx:605
msgid "Link"
msgstr "Link"
-#: src/components/dump/create/StepIndicator.tsx:12
+#: src/components/dump/create/StepIndicator.tsx:19
msgid "Link or file"
msgstr "Link or file"
-#: src/contexts/ws/WSProvider.tsx:715
+#: src/contexts/ws/WSProvider.tsx:757
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
-#: src/components/layout/AppHeader.tsx:205
+#: src/components/layout/AppHeader.tsx:236
msgid "Live updates unavailable."
msgstr "Live updates unavailable."
-#: src/components/social/UserListPopover.tsx:234
-#: src/pages/Notifications.tsx:481
-#: src/pages/UserPublicProfile.tsx:1137
+#: src/components/social/UserListPopover.tsx:262
+#: src/pages/Notifications.tsx:519
+#: src/pages/UserPublicProfile.tsx:1174
msgid "Load more"
msgstr "Load more"
-#: src/components/chat/ChatModal.tsx:720
+#: src/components/chat/ChatModal.tsx:741
msgid "Load older messages"
msgstr "Load older messages"
-#: src/pages/Dump.tsx:263
-#: src/pages/DumpEdit.tsx:169
+#: src/pages/Dump.tsx:290
+#: src/pages/DumpEdit.tsx:193
msgid "Loading dump…"
msgstr "Loading dump…"
-#: src/components/chat/ChatModal.tsx:613
+#: src/components/chat/ChatModal.tsx:634
msgid "Loading messages…"
msgstr "Loading messages…"
-#: src/components/feed/DumpFeed.tsx:87
-#: src/components/feed/FollowedFeed.tsx:120
-#: src/components/feed/JournalFeed.tsx:75
-#: src/pages/Search.tsx:256
-#: src/pages/UserDumps.tsx:121
-#: src/pages/UserPlaylists.tsx:431
-#: src/pages/UserPlaylists.tsx:466
-#: src/pages/UserUpvoted.tsx:197
+#: src/components/feed/DumpFeed.tsx:115
+#: src/components/feed/FollowedFeed.tsx:131
+#: src/components/feed/JournalFeed.tsx:86
+#: src/pages/Search.tsx:267
+#: src/pages/UserDumps.tsx:131
+#: src/pages/UserPlaylists.tsx:447
+#: src/pages/UserPlaylists.tsx:482
+#: src/pages/UserUpvoted.tsx:222
msgid "Loading more…"
msgstr "Loading more…"
-#: src/pages/PlaylistDetail.tsx:587
+#: src/pages/PlaylistDetail.tsx:614
msgid "Loading playlist…"
msgstr "Loading playlist…"
-#: src/pages/UserPublicProfile.tsx:799
+#: src/pages/UserPublicProfile.tsx:836
msgid "Loading profile…"
msgstr "Loading profile…"
-#: src/components/category/CategoryManager.tsx:51
-#: src/components/chat/ChatModal.tsx:719
-#: src/components/editor/EmojiPickerPopup.tsx:102
-#: src/components/feed/DumpFeed.tsx:55
-#: src/components/feed/FollowedFeed.tsx:87
-#: src/components/feed/JournalFeed.tsx:29
-#: src/components/playlist/PlaylistMembershipPanel.tsx:28
-#: src/components/social/UserListPopover.tsx:195
-#: src/components/social/UserListPopover.tsx:233
-#: src/pages/Notifications.tsx:404
-#: src/pages/Notifications.tsx:480
-#: src/pages/UserDumps.tsx:54
-#: src/pages/UserPlaylists.tsx:352
-#: src/pages/UserPublicProfile.tsx:1105
-#: src/pages/UserPublicProfile.tsx:1136
-#: src/pages/UserPublicProfile.tsx:1151
-#: src/pages/UserPublicProfile.tsx:1196
-#: src/pages/UserUpvoted.tsx:132
+#: src/components/category/CategoryManager.tsx:60
+#: src/components/chat/ChatModal.tsx:740
+#: src/components/editor/EmojiPickerPopup.tsx:125
+#: src/components/feed/DumpFeed.tsx:83
+#: src/components/feed/FollowedFeed.tsx:100
+#: src/components/feed/JournalFeed.tsx:41
+#: src/components/playlist/PlaylistMembershipPanel.tsx:45
+#: src/components/social/UserListPopover.tsx:223
+#: src/components/social/UserListPopover.tsx:261
+#: src/pages/Notifications.tsx:442
+#: src/pages/Notifications.tsx:518
+#: src/pages/UserDumps.tsx:64
+#: src/pages/UserPlaylists.tsx:368
+#: src/pages/UserPublicProfile.tsx:1142
+#: src/pages/UserPublicProfile.tsx:1173
+#: src/pages/UserPublicProfile.tsx:1188
+#: src/pages/UserPublicProfile.tsx:1233
+#: src/pages/UserUpvoted.tsx:157
msgid "Loading…"
msgstr "Loading…"
-#: src/components/layout/AppHeader.tsx:178
-#: src/pages/UserLogin.tsx:31
-#: src/pages/UserLogin.tsx:57
-#: src/pages/UserLogin.tsx:76
+#: src/components/layout/AppHeader.tsx:209
+#: src/pages/UserLogin.tsx:41
+#: src/pages/UserLogin.tsx:67
+#: src/pages/UserLogin.tsx:86
msgid "Log in"
msgstr "Log in"
-#: src/components/social/LikeButton.tsx:32
+#: src/components/social/LikeButton.tsx:55
msgid "Log in to like"
msgstr "Log in to like"
-#: src/components/social/VoteButton.tsx:29
+#: src/components/social/VoteButton.tsx:53
msgid "Log in to vote"
msgstr "Log in to vote"
-#: src/pages/UserPublicProfile.tsx:820
-#: src/pages/UserPublicProfile.tsx:924
+#: src/pages/UserPublicProfile.tsx:857
+#: src/pages/UserPublicProfile.tsx:961
msgid "Log out"
msgstr "Log out"
-#: src/pages/UserLogin.tsx:75
+#: src/pages/UserLogin.tsx:85
msgid "Logging in…"
msgstr "Logging in…"
-#: src/pages/UserLogin.tsx:62
+#: src/pages/UserLogin.tsx:72
msgid "Login failed"
msgstr "Login failed"
-#: src/pages/UserPublicProfile.tsx:997
+#: src/pages/UserPublicProfile.tsx:1034
msgid "Manage"
msgstr "Manage"
-#: src/components/form/FileDropZone.tsx:140
-msgid "Max 50 MB"
-msgstr "Max 50 MB"
+#: src/components/form/FileDropZone.tsx:169
+msgid "Max {limit}"
+msgstr "Max {limit}"
-#: src/pages/UserPublicProfile.tsx:1693
+#: src/pages/UserPublicProfile.tsx:1725
msgid "Moderator"
msgstr "Moderator"
-#: src/components/player/MediaPlayer.tsx:326
+#: src/components/player/MediaPlayer.tsx:346
msgid "Mute"
msgstr "Mute"
-#: src/components/category/CategoryManager.tsx:179
-#: src/components/category/CategoryManager.tsx:180
+#: src/components/category/CategoryManager.tsx:192
+#: src/components/category/CategoryManager.tsx:193
msgid "Name"
msgstr "Name"
-#: src/pages/Notifications.tsx:397
+#: src/pages/Notifications.tsx:435
msgid "new"
msgstr "new"
-#: src/components/chat/ChatModal.tsx:648
-#: src/components/feed/FeedTabBar.tsx:37
-#: src/pages/UserPublicProfile.tsx:1347
+#: src/components/chat/ChatModal.tsx:669
+#: src/components/feed/FeedTabBar.tsx:45
+#: src/pages/UserPublicProfile.tsx:1384
msgid "New"
msgstr "New"
-#: src/components/dump/create/DumpCreateModal.tsx:487
+#: src/components/dump/create/DumpCreateModal.tsx:500
+#: src/components/layout/DumpFab.tsx:66
#: src/components/layout/DumpFab.tsx:67
-#: src/components/layout/DumpFab.tsx:68
-#: src/pages/UserDumps.tsx:88
-#: src/pages/UserPublicProfile.tsx:1466
+#: src/pages/UserDumps.tsx:98
+#: src/pages/UserPublicProfile.tsx:1505
msgid "New dump"
msgstr "New dump"
-#: src/components/profile/ChangePasswordModal.tsx:81
-#: src/pages/ResetPassword.tsx:102
+#: src/components/profile/ChangePasswordModal.tsx:95
+#: src/pages/ResetPassword.tsx:113
msgid "New password"
msgstr "New password"
-#: src/components/playlist/NewPlaylistButton.tsx:33
-#: src/components/playlist/NewPlaylistButton.tsx:40
+#: src/components/playlist/NewPlaylistButton.tsx:49
+#: src/components/playlist/NewPlaylistButton.tsx:56
msgid "New playlist"
msgstr "New playlist"
-#: src/components/dump/create/DumpCreateModal.tsx:788
+#: src/components/dump/create/DumpCreateModal.tsx:800
msgid "Next →"
msgstr "Next →"
-#: src/components/player/GlobalPlayer.tsx:224
+#: src/components/player/GlobalPlayer.tsx:244
msgid "Next track"
msgstr "Next track"
-#: src/pages/PlaylistDetail.tsx:717
+#: src/pages/PlaylistDetail.tsx:743
msgid "No dumps in this playlist yet."
msgstr "No dumps in this playlist yet."
-#: src/pages/Search.tsx:236
+#: src/pages/Search.tsx:247
msgid "No dumps match \"{q}\"."
msgstr "No dumps match \"{q}\"."
-#: src/components/feed/DumpFeed.tsx:63
-#: src/components/feed/JournalFeed.tsx:37
+#: src/components/feed/DumpFeed.tsx:91
+#: src/components/feed/JournalFeed.tsx:49
msgid "No dumps yet. Be the first!"
msgstr "No dumps yet. Be the first!"
-#: src/components/editor/EmojiPickerPopup.tsx:105
+#: src/components/editor/EmojiPickerPopup.tsx:128
msgid "No emoji found."
msgstr "No emoji found."
-#: src/pages/UserPlaylists.tsx:453
-#: src/pages/UserPublicProfile.tsx:1164
+#: src/pages/UserPlaylists.tsx:469
+#: src/pages/UserPublicProfile.tsx:1201
msgid "No followed playlists yet."
msgstr "No followed playlists yet."
-#: src/pages/UserPublicProfile.tsx:1209
+#: src/pages/UserPublicProfile.tsx:1246
msgid "No invitees yet."
msgstr "No invitees yet."
-#: src/components/chat/ChatModal.tsx:632
+#: src/components/chat/ChatModal.tsx:653
msgid "No messages yet. Say hello!"
msgstr "No messages yet. Say hello!"
-#: src/components/social/UserListPopover.tsx:203
+#: src/components/social/UserListPopover.tsx:231
msgid "No one yet."
msgstr "No one yet."
-#: src/pages/Search.tsx:295
+#: src/pages/Search.tsx:306
msgid "No playlists match \"{q}\"."
msgstr "No playlists match \"{q}\"."
-#: src/components/playlist/PlaylistMembershipPanel.tsx:34
-#: src/pages/UserPlaylists.tsx:411
-#: src/pages/UserPublicProfile.tsx:1071
+#: src/components/playlist/PlaylistMembershipPanel.tsx:51
+#: src/pages/UserPlaylists.tsx:427
+#: src/pages/UserPublicProfile.tsx:1108
msgid "No playlists yet."
msgstr "No playlists yet."
-#: src/contexts/ws/WSProvider.tsx:949
+#: src/contexts/ws/WSProvider.tsx:926
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"
-#: src/pages/Search.tsx:269
+#: src/pages/Search.tsx:280
msgid "No users match \"{q}\"."
msgstr "No users match \"{q}\"."
-#: src/pages/UserPublicProfile.tsx:1118
+#: src/pages/UserPublicProfile.tsx:1155
msgid "Not following anyone yet."
msgstr "Not following anyone yet."
-#: src/pages/Notifications.tsx:415
-#: src/pages/UserDumps.tsx:99
-#: src/pages/UserPublicProfile.tsx:1477
-#: src/pages/UserPublicProfile.tsx:1605
-#: src/pages/UserUpvoted.tsx:166
+#: src/pages/Notifications.tsx:453
+#: src/pages/UserDumps.tsx:109
+#: src/pages/UserPublicProfile.tsx:1516
+#: src/pages/UserPublicProfile.tsx:1639
+#: src/pages/UserUpvoted.tsx:191
msgid "Nothing here yet."
msgstr "Nothing here yet."
-#: src/components/layout/NotificationBell.tsx:42
-#: src/pages/Notifications.tsx:267
-#: src/pages/Notifications.tsx:393
-#: src/pages/UserPublicProfile.tsx:1369
+#: src/components/layout/NotificationBell.tsx:54
+#: src/pages/Notifications.tsx:294
+#: src/pages/Notifications.tsx:431
+#: src/pages/UserPublicProfile.tsx:1406
msgid "Notifications"
msgstr "Notifications"
-#: src/components/layout/NotificationBell.tsx:41
+#: src/components/layout/NotificationBell.tsx:53
msgid "Notifications ({unreadNotificationCount} unread)"
msgstr "Notifications ({unreadNotificationCount} unread)"
-#: src/pages/UserPublicProfile.tsx:1393
+#: src/pages/UserPublicProfile.tsx:1430
msgid "Off"
msgstr "Off"
-#: src/pages/UserPublicProfile.tsx:1386
+#: src/pages/UserPublicProfile.tsx:1423
msgid "On"
msgstr "On"
-#: src/components/editor/TextEditor.tsx:317
+#: src/components/editor/TextEditor.tsx:359
msgid "Only images can be dropped here."
msgstr "Only images can be dropped here."
+#: src/pages/DumpEdit.tsx:103
+msgid "Only its poster or a moderator can edit this dump."
+msgstr "Only its poster or a moderator can edit this dump."
+
#. placeholder {0}: richContent.siteName
-#: src/components/dump/RichContentCard.tsx:101
+#: src/components/dump/RichContentCard.tsx:122
msgid "Open on {0}"
msgstr "Open on {0}"
-#: src/components/dump/RichContentCard.tsx:102
+#: src/components/dump/RichContentCard.tsx:123
msgid "Open source"
msgstr "Open source"
-#: src/components/form/FileDropZone.tsx:134
+#: src/components/form/FileDropZone.tsx:163
msgid "or <0>browse files0>"
msgstr "or <0>browse files0>"
-#: src/pages/NotFound.tsx:8
+#: src/pages/NotFound.tsx:16
msgid "Page not found"
msgstr "Page not found"
-#: src/pages/UserLogin.tsx:72
-#: src/pages/UserPublicProfile.tsx:1229
+#: src/pages/UserLogin.tsx:82
+#: src/pages/UserPublicProfile.tsx:1266
msgid "Password"
msgstr "Password"
#. placeholder {0}: VALIDATION.PASSWORD_MIN
-#: src/pages/UserRegister.tsx:140
+#: src/pages/UserRegister.tsx:158
msgid "Password (min. {0} characters)"
msgstr "Password (min. {0} characters)"
-#: src/components/profile/ChangePasswordModal.tsx:58
+#: src/components/profile/ChangePasswordModal.tsx:72
msgid "Password changed successfully."
msgstr "Password changed successfully."
-#: src/pages/ResetPassword.tsx:72
+#: src/pages/ResetPassword.tsx:83
msgid "Password updated"
msgstr "Password updated"
-#: src/components/profile/ChangePasswordModal.tsx:101
-#: src/pages/ResetPassword.tsx:122
+#: src/components/profile/ChangePasswordModal.tsx:115
+#: src/pages/ResetPassword.tsx:133
msgid "Passwords do not match"
msgstr "Passwords do not match"
-#: src/components/dump/create/DumpCreateModal.tsx:610
+#: src/components/dump/create/DumpCreateModal.tsx:623
msgid "Paste a link…"
msgstr "Paste a link…"
-#: src/components/dump/FilePreview.tsx:119
-#: src/components/dump/FilePreview.tsx:286
-#: src/components/dump/RichContentCard.tsx:83
-#: src/components/player/MediaPlayer.tsx:310
+#: src/components/dump/FilePreview.tsx:134
+#: src/components/dump/FilePreview.tsx:310
+#: src/components/dump/RichContentCard.tsx:103
+#: src/components/player/MediaPlayer.tsx:330
msgid "Pause"
msgstr "Pause"
-#: src/components/dump/create/DumpCreateModal.tsx:527
+#: src/components/dump/create/DumpCreateModal.tsx:540
msgid "Picked up where you left off."
msgstr "Picked up where you left off."
-#: src/components/dump/FilePreview.tsx:119
-#: src/components/dump/FilePreview.tsx:230
-#: src/components/dump/FilePreview.tsx:251
-#: src/components/dump/FilePreview.tsx:286
-#: src/components/dump/RichContentCard.tsx:83
-#: src/components/dump/RichContentCard.tsx:129
-#: src/components/player/MediaPlayer.tsx:310
+#: src/components/dump/FilePreview.tsx:134
+#: src/components/dump/FilePreview.tsx:253
+#: src/components/dump/FilePreview.tsx:275
+#: src/components/dump/FilePreview.tsx:310
+#: src/components/dump/RichContentCard.tsx:103
+#: src/components/dump/RichContentCard.tsx:150
+#: src/components/player/MediaPlayer.tsx:330
msgid "Play"
msgstr "Play"
-#: src/pages/PlaylistDetail.tsx:912
+#: src/pages/PlaylistDetail.tsx:955
msgid "Playlist title"
msgstr "Playlist title"
-#: src/components/dump/create/StepIndicator.tsx:14
-#: src/components/layout/AppHeader.tsx:151
-#: src/components/layout/UserMenu.tsx:62
-#: src/pages/Search.tsx:189
-#: src/pages/UserPlaylists.tsx:378
-#: src/pages/UserPublicProfile.tsx:990
+#: src/components/dump/create/StepIndicator.tsx:21
+#: src/components/layout/AppHeader.tsx:182
+#: src/components/layout/UserMenu.tsx:78
+#: src/pages/Search.tsx:200
+#: src/pages/UserPlaylists.tsx:394
+#: src/pages/UserPublicProfile.tsx:1027
msgid "Playlists"
msgstr "Playlists"
#. placeholder {0}: playlists.items.length
#. placeholder {1}: playlists.hasMore ? "+" : ""
-#: src/pages/UserPublicProfile.tsx:1040
+#: src/pages/UserPublicProfile.tsx:1077
msgid "Playlists ({0}{1})"
msgstr "Playlists ({0}{1})"
-#: src/components/comment/CommentThread.tsx:78
+#: src/components/comment/CommentThread.tsx:99
msgid "Post comment"
msgstr "Post comment"
-#: src/components/comment/CommentNode.tsx:272
+#: src/components/comment/CommentNode.tsx:287
msgid "Post reply"
msgstr "Post reply"
-#: src/components/comment/CommentNode.tsx:273
-#: src/components/comment/CommentThread.tsx:79
-#: src/components/dump/create/DumpCreateModal.tsx:782
+#: src/components/comment/CommentNode.tsx:288
+#: src/components/comment/CommentThread.tsx:100
+#: src/components/dump/create/DumpCreateModal.tsx:794
msgid "Posting…"
msgstr "Posting…"
-#: src/components/player/GlobalPlayer.tsx:208
+#: src/components/player/GlobalPlayer.tsx:228
msgid "Previous track"
msgstr "Previous track"
-#: src/components/dump/DumpCard.tsx:125
-#: src/components/dump/JournalCard.tsx:129
-#: src/components/playlist/PlaylistCard.tsx:74
-#: src/components/playlist/PlaylistMembershipPanel.tsx:64
-#: src/pages/Dump.tsx:471
-#: src/pages/PlaylistDetail.tsx:677
+#: src/components/dump/DumpCard.tsx:148
+#: src/components/dump/JournalCard.tsx:142
+#: src/components/playlist/PlaylistCard.tsx:95
+#: src/components/playlist/PlaylistMembershipPanel.tsx:81
+#: src/pages/Dump.tsx:497
+#: src/pages/PlaylistDetail.tsx:703
msgid "private"
msgstr "private"
-#: src/components/form/SegmentedField.tsx:81
-#: src/pages/PlaylistDetail.tsx:953
+#: src/components/form/SegmentedField.tsx:100
+#: src/pages/PlaylistDetail.tsx:996
msgid "Private"
msgstr "Private"
-#: src/components/playlist/PlaylistCard.tsx:73
-#: src/pages/PlaylistDetail.tsx:676
+#: src/components/playlist/PlaylistCard.tsx:94
+#: src/pages/PlaylistDetail.tsx:702
msgid "public"
msgstr "public"
-#: src/components/form/SegmentedField.tsx:80
-#: src/pages/PlaylistDetail.tsx:946
+#: src/components/form/SegmentedField.tsx:99
+#: src/pages/PlaylistDetail.tsx:989
msgid "Public"
msgstr "Public"
-#: src/pages/DumpEdit.tsx:251
+#: src/pages/DumpEdit.tsx:275
msgid "Refresh metadata"
msgstr "Refresh metadata"
-#: src/components/player/GlobalPlayer.tsx:304
+#: src/components/player/GlobalPlayer.tsx:324
msgid "Refreshing stream…"
msgstr "Refreshing stream…"
-#: src/pages/DumpEdit.tsx:250
+#: src/pages/DumpEdit.tsx:274
msgid "Refreshing…"
msgstr "Refreshing…"
-#: src/pages/UserRegister.tsx:34
-#: src/pages/UserRegister.tsx:109
-#: src/pages/UserRegister.tsx:151
+#: src/pages/UserRegister.tsx:45
+#: src/pages/UserRegister.tsx:129
+#: src/pages/UserRegister.tsx:169
msgid "Register"
msgstr "Register"
-#: src/pages/UserRegister.tsx:150
+#: src/pages/UserRegister.tsx:168
msgid "Registering…"
msgstr "Registering…"
-#: src/pages/UserRegister.tsx:114
+#: src/pages/UserRegister.tsx:134
msgid "Registration failed"
msgstr "Registration failed"
-#: src/pages/Dump.tsx:538
+#: src/pages/Dump.tsx:561
msgid "Related"
msgstr "Related"
-#: src/components/form/FileDropZone.tsx:110
+#: src/components/form/FileDropZone.tsx:139
msgid "Remove file"
msgstr "Remove file"
-#: src/pages/PlaylistDetail.tsx:772
+#: src/pages/PlaylistDetail.tsx:798
msgid "Remove from playlist"
msgstr "Remove from playlist"
-#: src/components/social/LikeButton.tsx:31
+#: src/components/social/LikeButton.tsx:54
msgid "Remove like"
msgstr "Remove like"
-#: src/components/social/VoteButton.tsx:28
+#: src/components/social/VoteButton.tsx:52
msgid "Remove vote"
msgstr "Remove vote"
-#: src/pages/DumpEdit.tsx:440
+#: src/pages/DumpEdit.tsx:473
msgid "Replace file"
msgstr "Replace file"
-#: src/components/chat/ChatMessageItem.tsx:67
-#: src/components/chat/ChatMessageItem.tsx:68
-#: src/components/comment/CommentNode.tsx:227
+#: src/components/chat/ChatMessageItem.tsx:93
+#: src/components/chat/ChatMessageItem.tsx:94
+#: src/components/comment/CommentNode.tsx:242
msgid "Reply"
msgstr "Reply"
-#: src/components/chat/ChatModal.tsx:783
+#: src/components/chat/ChatModal.tsx:804
msgid "Replying to"
msgstr "Replying to"
-#: src/pages/UserLogin.tsx:134
+#: src/pages/UserLogin.tsx:148
msgid "Request failed"
msgstr "Request failed"
-#: src/pages/ResetPassword.tsx:98
+#: src/pages/ResetPassword.tsx:109
msgid "Reset failed"
msgstr "Reset failed"
-#: src/pages/ResetPassword.tsx:23
+#: src/pages/ResetPassword.tsx:34
msgid "Reset password"
msgstr "Reset password"
-#: src/pages/DumpEdit.tsx:416
-#: src/pages/DumpEdit.tsx:470
+#: src/pages/DumpEdit.tsx:449
+#: src/pages/DumpEdit.tsx:504
msgid "Reset to default"
msgstr "Reset to default"
-#: src/components/dump/create/TitleField.tsx:66
-#: src/components/dump/create/TitleField.tsx:67
+#: src/components/dump/create/TitleField.tsx:80
+#: src/components/dump/create/TitleField.tsx:81
msgid "Reset to the suggested title"
msgstr "Reset to the suggested title"
-#: src/components/chat/PendingItem.tsx:70
-#: src/pages/Dump.tsx:280
-#: src/pages/DumpEdit.tsx:186
+#: src/components/chat/PendingItem.tsx:86
+#: src/pages/Dump.tsx:307
+#: src/pages/DumpEdit.tsx:210
msgid "Retry"
msgstr "Retry"
-#: src/pages/UserPublicProfile.tsx:1748
+#: src/pages/UserPublicProfile.tsx:1784
msgid "Role"
msgstr "Role"
-#: src/components/category/CategoryManager.tsx:203
-#: src/components/chat/ChatEditBox.tsx:67
-#: src/components/comment/CommentNode.tsx:199
-#: src/pages/Dump.tsx:398
-#: src/pages/DumpEdit.tsx:511
-#: src/pages/PlaylistDetail.tsx:978
-#: src/pages/UserPublicProfile.tsx:1815
-#: src/pages/UserPublicProfile.tsx:1890
+#: src/components/category/CategoryManager.tsx:216
+#: src/components/chat/ChatEditBox.tsx:85
+#: src/components/comment/CommentNode.tsx:214
+#: src/pages/Dump.tsx:424
+#: src/pages/DumpEdit.tsx:545
+#: src/pages/PlaylistDetail.tsx:1021
+#: src/pages/UserPublicProfile.tsx:1855
+#: src/pages/UserPublicProfile.tsx:1934
msgid "Save"
msgstr "Save"
-#: src/components/comment/CommentNode.tsx:200
-#: src/components/profile/ChangePasswordModal.tsx:106
-#: src/pages/Dump.tsx:397
-#: src/pages/PlaylistDetail.tsx:974
-#: src/pages/ResetPassword.tsx:125
-#: src/pages/UserPublicProfile.tsx:1812
-#: src/pages/UserPublicProfile.tsx:1887
+#: src/components/comment/CommentNode.tsx:215
+#: src/components/profile/ChangePasswordModal.tsx:120
+#: src/pages/Dump.tsx:423
+#: src/pages/PlaylistDetail.tsx:1017
+#: src/pages/ResetPassword.tsx:136
+#: src/pages/UserPublicProfile.tsx:1852
+#: src/pages/UserPublicProfile.tsx:1931
msgid "Saving…"
msgstr "Saving…"
-#: src/components/layout/AppHeader.tsx:130
-#: src/components/layout/SearchBar.tsx:73
-#: src/pages/Search.tsx:49
+#: src/components/layout/AppHeader.tsx:162
+#: src/components/layout/SearchBar.tsx:82
+#: src/pages/Search.tsx:63
msgid "Search"
msgstr "Search"
-#: src/components/layout/SearchBar.tsx:69
+#: src/components/layout/SearchBar.tsx:78
msgid "Search dumps, users, playlists…"
msgstr "Search dumps, users, playlists…"
-#: src/components/editor/EmojiPickerPopup.tsx:81
+#: src/components/editor/EmojiPickerPopup.tsx:104
msgid "Search emoji"
msgstr "Search emoji"
-#: src/components/editor/EmojiPickerPopup.tsx:80
+#: src/components/editor/EmojiPickerPopup.tsx:103
msgid "Search emoji…"
msgstr "Search emoji…"
-#: src/pages/Search.tsx:230
+#: src/pages/Search.tsx:241
msgid "Search failed"
msgstr "Search failed"
-#: src/pages/Search.tsx:225
+#: src/pages/Search.tsx:236
msgid "Searching…"
msgstr "Searching…"
-#: src/components/player/SeekBar.tsx:34
+#: src/components/player/SeekBar.tsx:50
msgid "Seek"
msgstr "Seek"
-#: src/components/chat/ChatModal.tsx:823
+#: src/components/chat/ChatModal.tsx:844
msgid "Send"
msgstr "Send"
-#: src/pages/UserLogin.tsx:143
+#: src/pages/UserLogin.tsx:157
msgid "Send reset link"
msgstr "Send reset link"
-#: src/components/chat/PendingItem.tsx:88
-#: src/pages/UserLogin.tsx:142
+#: src/components/chat/PendingItem.tsx:104
+#: src/pages/UserLogin.tsx:156
msgid "Sending…"
msgstr "Sending…"
-#: src/components/layout/AppHeader.tsx:167
+#: src/components/layout/AppHeader.tsx:198
msgid "Server unreachable"
msgstr "Server unreachable"
-#: src/pages/ResetPassword.tsx:93
-#: src/pages/ResetPassword.tsx:126
+#: src/pages/ResetPassword.tsx:104
+#: src/pages/ResetPassword.tsx:137
msgid "Set new password"
msgstr "Set new password"
-#: src/pages/UserPublicProfile.tsx:994
+#: src/pages/UserPublicProfile.tsx:1031
msgid "Settings"
msgstr "Settings"
-#: src/components/chat/ChatModal.tsx:607
+#: src/components/chat/ChatModal.tsx:628
msgid "Several people are typing…"
msgstr "Several people are typing…"
-#: src/pages/UserPublicProfile.tsx:162
+#: src/pages/UserPublicProfile.tsx:170
msgid "Single use, valid for 7 days."
msgstr "Single use, valid for 7 days."
-#: src/components/category/CategoryManager.tsx:192
+#: src/components/category/CategoryManager.tsx:205
msgid "slug"
msgstr "slug"
-#: src/components/editor/TextEditor.tsx:276
+#: src/components/editor/TextEditor.tsx:317
msgid "Some images couldn't be uploaded."
msgstr "Some images couldn't be uploaded."
-#: src/components/form/FormError.tsx:16
-#: src/components/layout/PageError.tsx:13
-#: src/contexts/ws/WSProvider.tsx:669
-#: src/pages/PlaylistDetail.tsx:711
+#: src/components/form/FormError.tsx:31
+#: src/components/layout/PageError.tsx:29
+#: src/contexts/ws/WSProvider.tsx:726
+#: src/pages/PlaylistDetail.tsx:737
msgid "Something went wrong"
msgstr "Something went wrong"
-#: src/pages/UserPublicProfile.tsx:1374
+#: src/pages/UserPublicProfile.tsx:1411
msgid "Sounds"
msgstr "Sounds"
-#: src/components/dump/create/DumpCreateModal.tsx:534
+#: src/components/dump/create/DumpCreateModal.tsx:547
msgid "Start fresh"
msgstr "Start fresh"
-#: src/pages/UserPublicProfile.tsx:1248
+#: src/pages/UserPublicProfile.tsx:1285
msgid "Style"
msgstr "Style"
-#: src/components/layout/SearchBar.tsx:79
+#: src/components/layout/SearchBar.tsx:88
msgid "Submit search"
msgstr "Submit search"
-#: src/pages/Notifications.tsx:221
+#: src/components/form/FileField.tsx:60
+msgid "That file is over {limit}."
+msgstr "That file is over {limit}."
+
+#: src/pages/Notifications.tsx:234
msgid "the chat"
msgstr "the chat"
-#: src/pages/PlaylistDetail.tsx:501
+#: src/pages/PlaylistDetail.tsx:563
+msgid "The dump couldn't be put back. Try again."
+msgstr "The dump couldn't be put back. Try again."
+
+#: src/pages/PlaylistDetail.tsx:511
msgid "The dump couldn't be removed. Try again."
msgstr "The dump couldn't be removed. Try again."
-#: src/components/editor/TextEditor.tsx:277
+#: src/components/editor/TextEditor.tsx:318
msgid "The image couldn't be uploaded."
msgstr "The image couldn't be uploaded."
-#: src/components/comment/CommentNode.tsx:265
+#: src/components/comment/CommentNode.tsx:280
msgid "This comment couldn't be deleted. Try again."
msgstr "This comment couldn't be deleted. Try again."
-#: src/pages/UserRegister.tsx:98
+#: src/pages/UserRegister.tsx:118
msgid "This invite link is missing, expired, or already used."
msgstr "This invite link is missing, expired, or already used."
-#: src/pages/UserLogin.tsx:94
+#: src/pages/UserLogin.tsx:104
msgid "This is a mirage."
msgstr "This is a mirage."
-#: src/pages/NotFound.tsx:13
+#: src/pages/NotFound.tsx:21
msgid "This page does not exist."
msgstr "This page does not exist."
-#: src/pages/ResetPassword.tsx:53
+#: src/pages/ResetPassword.tsx:64
msgid "This reset link is missing or malformed."
msgstr "This reset link is missing or malformed."
-#: src/pages/DumpEdit.tsx:400
+#: src/pages/DumpEdit.tsx:433
msgid "Thumbnail"
msgstr "Thumbnail"
-#: src/components/dump/create/TitleField.tsx:32
-#: src/components/playlist/PlaylistCreateForm.tsx:77
-#: src/pages/DumpEdit.tsx:460
+#: src/components/dump/create/TitleField.tsx:45
+#: src/components/playlist/PlaylistCreateForm.tsx:96
+#: src/pages/DumpEdit.tsx:494
msgid "Title"
msgstr "Title"
-#: src/components/dump/create/DumpCreateModal.tsx:402
+#: src/components/dump/create/DumpCreateModal.tsx:417
msgid "Title is required."
msgstr "Title is required."
-#: src/pages/Notifications.tsx:431
-#: src/utils/chat/time.ts:16
+#: src/pages/Notifications.tsx:469
+#: src/utils/chat/time.ts:24
msgid "Today"
msgstr "Today"
#. placeholder {0}: i + 1
-#: src/components/player/GlobalPlayer.tsx:318
+#: src/components/player/GlobalPlayer.tsx:338
msgid "Track {0}"
msgstr "Track {0}"
-#: src/components/chat/ChatModal.tsx:624
+#: src/components/chat/ChatModal.tsx:645
msgid "Try again"
msgstr "Try again"
-#: src/components/chat/ChatModal.tsx:808
+#: src/components/chat/ChatModal.tsx:829
msgid "Type a message…"
msgstr "Type a message…"
-#: src/pages/PlaylistDetail.tsx:784
+#: src/pages/PlaylistDetail.tsx:810
msgid "Undo"
msgstr "Undo"
-#: src/components/social/FollowButton.tsx:34
+#: src/utils/shared/api.ts:21
+msgid "Unexpected response format"
+msgstr "Unexpected response format"
+
+#: src/components/social/FollowButton.tsx:59
msgid "Unfollow {targetUsername}"
msgstr "Unfollow {targetUsername}"
-#: src/components/social/FollowButton.tsx:62
+#: src/components/social/FollowButton.tsx:100
msgid "Unfollow playlist"
msgstr "Unfollow playlist"
-#: src/components/player/MediaPlayer.tsx:326
+#: src/components/player/MediaPlayer.tsx:346
msgid "Unmute"
msgstr "Unmute"
-#: src/pages/UserPublicProfile.tsx:770
+#: src/pages/UserPublicProfile.tsx:807
msgid "Upload failed"
msgstr "Upload failed"
#. placeholder {0}: Math.round((uploadProgress ?? 0) * 100)
-#: src/components/dump/create/DumpCreateModal.tsx:470
+#: src/components/dump/create/DumpCreateModal.tsx:484
msgid "Uploading {0}%"
msgstr "Uploading {0}%"
-#: src/components/editor/TextEditor.tsx:525
+#: src/components/editor/TextEditor.tsx:566
msgid "Uploading image…"
msgstr "Uploading image…"
-#: src/components/dump/create/DumpCreateModal.tsx:785
+#: src/components/dump/create/DumpCreateModal.tsx:797
msgid "Uploading…"
msgstr "Uploading…"
-#: src/components/social/VoteButton.tsx:28
+#: src/components/social/VoteButton.tsx:52
msgid "Upvote"
msgstr "Upvote"
-#: src/pages/UserUpvoted.tsx:161
+#: src/pages/UserUpvoted.tsx:186
msgid "Upvoted"
msgstr "Upvoted"
#. placeholder {0}: votes.items.length
#. placeholder {1}: votes.hasMore ? "+" : ""
-#: src/pages/UserPublicProfile.tsx:1020
+#: src/pages/UserPublicProfile.tsx:1057
msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})"
-#: src/pages/DumpEdit.tsx:432
+#: src/pages/DumpEdit.tsx:465
msgid "URL"
msgstr "URL"
-#: src/components/dump/create/DumpCreateModal.tsx:386
+#: src/components/dump/create/DumpCreateModal.tsx:401
msgid "URL is required."
msgstr "URL is required."
-#: src/components/category/CategoryManager.tsx:193
+#: src/components/category/CategoryManager.tsx:206
msgid "URL slug"
msgstr "URL slug"
-#: src/pages/UserPublicProfile.tsx:1692
+#: src/pages/UserPublicProfile.tsx:1724
msgid "User"
msgstr "User"
-#: src/pages/UserPublicProfile.tsx:1405
+#: src/pages/UserPublicProfile.tsx:1442
msgid "User management"
msgstr "User management"
-#: src/components/layout/UserMenu.tsx:37
+#: src/components/layout/UserMenu.tsx:53
msgid "User menu"
msgstr "User menu"
-#: src/pages/UserLogin.tsx:65
-#: src/pages/UserRegister.tsx:117
+#: src/pages/UserLogin.tsx:75
+#: src/pages/UserRegister.tsx:137
msgid "Username"
msgstr "Username"
-#: src/pages/Search.tsx:188
+#: src/pages/Search.tsx:199
msgid "Users"
msgstr "Users"
-#: src/pages/UserPublicProfile.tsx:1090
-#: src/pages/UserPublicProfile.tsx:1179
-#: src/pages/UserPublicProfile.tsx:1498
-#: src/pages/UserPublicProfile.tsx:1635
+#: src/pages/UserPublicProfile.tsx:1127
+#: src/pages/UserPublicProfile.tsx:1216
+#: src/pages/UserPublicProfile.tsx:1537
+#: src/pages/UserPublicProfile.tsx:1669
msgid "View all →"
msgstr "View all →"
-#: src/components/dump/create/DumpCreateModal.tsx:548
+#: src/components/dump/create/DumpCreateModal.tsx:561
msgid "View dump →"
msgstr "View dump →"
-#: src/components/player/MediaPlayer.tsx:343
+#: src/components/player/MediaPlayer.tsx:363
msgid "Volume"
msgstr "Volume"
-#: src/components/dump/create/DumpCreateModal.tsx:736
-#: src/pages/DumpEdit.tsx:481
+#: src/components/dump/create/DumpCreateModal.tsx:748
+#: src/pages/DumpEdit.tsx:515
msgid "What makes it worth it?"
msgstr "What makes it worth it?"
-#: src/pages/UserPublicProfile.tsx:974
-#: src/pages/UserPublicProfile.tsx:1879
+#: src/pages/UserPublicProfile.tsx:1011
+#: src/pages/UserPublicProfile.tsx:1923
msgid "Who am I?"
msgstr "Who am I?"
-#: src/components/dump/create/DumpCreateModal.tsx:751
+#: src/components/dump/create/DumpCreateModal.tsx:763
msgid "Who can see it"
msgstr "Who can see it"
-#: src/components/dump/create/StepIndicator.tsx:13
+#: src/components/dump/create/StepIndicator.tsx:20
msgid "Why & where"
msgstr "Why & where"
-#: src/components/dump/create/DumpCreateModal.tsx:735
-#: src/pages/DumpEdit.tsx:480
+#: src/components/dump/create/DumpCreateModal.tsx:747
+#: src/pages/DumpEdit.tsx:514
msgid "Why?"
msgstr "Why?"
-#: src/components/comment/CommentNode.tsx:276
+#: src/components/comment/CommentNode.tsx:291
msgid "Write a reply…"
msgstr "Write a reply…"
-#: src/pages/Notifications.tsx:433
-#: src/utils/chat/time.ts:19
+#: src/pages/Notifications.tsx:471
+#: src/utils/chat/time.ts:27
msgid "Yesterday"
msgstr "Yesterday"
-#: src/components/dump/create/DuplicateNotice.tsx:42
+#: src/components/dump/create/DuplicateNotice.tsx:60
msgid "You can post it anyway."
msgstr "You can post it anyway."
-#: src/pages/Notifications.tsx:418
+#: src/pages/Notifications.tsx:456
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/ws/WSProvider.tsx:930
+#: src/contexts/ws/WSProvider.tsx:907
msgid "You're offline — not sent"
msgstr "You're offline — not sent"
-#: src/components/feed/DumpFeed.tsx:92
-#: src/components/feed/FollowedFeed.tsx:125
-#: src/components/feed/JournalFeed.tsx:80
-#: src/pages/Search.tsx:261
-#: src/pages/UserDumps.tsx:126
-#: src/pages/UserPlaylists.tsx:436
-#: src/pages/UserPlaylists.tsx:471
-#: src/pages/UserUpvoted.tsx:202
+#: src/components/feed/DumpFeed.tsx:120
+#: src/components/feed/FollowedFeed.tsx:136
+#: src/components/feed/JournalFeed.tsx:91
+#: src/pages/Search.tsx:272
+#: src/pages/UserDumps.tsx:136
+#: src/pages/UserPlaylists.tsx:452
+#: src/pages/UserPlaylists.tsx:487
+#: src/pages/UserUpvoted.tsx:227
msgid "You've reached the end."
msgstr "You've reached the end."
-#: src/pages/UserLogin.tsx:138
+#: src/pages/UserLogin.tsx:152
msgid "Your email address"
msgstr "Your email address"
-#: src/pages/ResetPassword.tsx:75
+#: src/pages/ResetPassword.tsx:86
msgid "Your password has been changed. You can now log in."
msgstr "Your password has been changed. You can now log in."
diff --git a/src/locales/fr.js b/src/locales/fr.js
index d7ef7dce..e51baa41 100644
--- a/src/locales/fr.js
+++ b/src/locales/fr.js
@@ -1,5 +1,5 @@
/*eslint-disable*/ module.exports = {
messages: JSON.parse(
- '{"-K9EZb":["Ajouter un e-mail…"],"-OxI15":["Collections suivies"],"-Ya-b9":["Enregistrement échoué"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Rechercher des recos, utilisateurs, collections…"],"1QfxQT":["Fermer"],"1YKhJ3":["1 nouveau message"],"1cbYY_":["Votés (",["0"],["1"],")"],"1njn7W":["Clair"],"1utXA6":["Recos"],"26iNma":["Publier le commentaire"],"2DoBvq":["Flux"],"2Hlmdt":["Écrire une réponse…"],"2_DHgA":["Identifiant d’URL"],"2nrsEn":["message supprimé"],"2ygf_L":["← Retour"],"3KKSM4":["privé"],"3QuNUr":["Plusieurs personnes écrivent…"],"3SEwY1":["Supprimer la catégorie \\"",["0"],"\\" ? Cette action est irréversible."],"3qkggm":["Plein écran"],"3yfh3D":["<0>",["0"],"0> a suivi votre collection <1>",["1"],"1>"],"48e2R8":["Modifier l\'e-mail"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Recommandé !"],"4GKuCs":["Connexion échouée"],"4HH9iB":["Aucun abonnement pour le moment."],"4OjqAQ":["Continuer l\'édition"],"4RtQ1k":["Ne plus suivre ",["targetUsername"]],"4c-qBx":["Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."],"4yj9xV":["Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"],"5KHDCi":[["0"]," et ",["1"]," sont en train d\'écrire…"],"5TviPn":["Annuler la recherche"],"5cC8f2":["Modifié le ",["0"]],"5oD9f_":["Plus tôt"],"6Qly-0":["un commentaire"],"6YE0P6":["Édition de ",["0"]],"6YtxFj":["Nom"],"6gRgw8":["Réessayer"],"7PHCIN":["Annuler la suppression"],"7PzzBU":["Utilisateur"],"7d1a0d":["Public"],"7sNhEz":["Nom d\'utilisateur"],"8-8abB":["Déconnecté — reconnexion…"],"8F1i42":["Page introuvable"],"8Ug9jB":["Connexe"],"8ZsakT":["Mot de passe"],"92o1-U":["Suppression échouée"],"9BruTc":["Pourquoi ?"],"9l4qcT":["Déposez un fichier de remplacement ici"],"9uI_rE":["Annuler"],"9xDZu_":["Impossible de charger."],"A0y396":["+ Inviter quelqu\'un"],"A1taO8":["Rechercher"],"AEfu-D":["Dans les collections"],"AHZflp":["Tribune"],"AQbgNR":["Suivre ",["targetUsername"]],"AS7WoE":["Repartir de zéro"],"ATGYL1":["Adresse e-mail"],"AZctoV":[["0","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"Ade-6d":["Mises à jour en direct indisponibles."],"AeXO77":["Compte"],"AhNP40":["Position"],"AnRu_j":["Annuler la réponse"],"B4LeYP":["Échec de la suppression (",["0"],")"],"BjitBo":["Télécharger ",["0"]],"BlpJ2G":["Reprise de votre brouillon."],"CI50ct":["Voté"],"C_qQE4":["Déjà recommandé par ",["0"]," ",["1"]],"Cj24wt":["Inscription…"],"D8DRYV":["Supprimer ce message ?"],"DI3NBO":["Ouvrir sur ",["0"]],"DPfwMq":["Terminé"],"DdeHXH":["Supprimer cette reco ? Cette action est irréversible."],"Dp1JhP":["<0>",["0"],"0> a voté pour <1>",["1"],"1>"],"EBU6O6":["Connexion perdue — ce message n\'a peut-être pas été envoyé"],"ECiS12":["Voir la reco →"],"ED8n0z":["la tribune"],"EarrCe":["Rechercher un emoji"],"Evcm2Z":["Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."],"EwVrWF":["Le dump n\'a pas pu être retiré. Réessayez."],"ExR0Fr":["L\'URL est obligatoire."],"F5Js1v":["Ne plus suivre la collection"],"FdTlEG":["Piste ",["0"]],"FgAxTj":["Se déconnecter"],"Fxf4jq":["Description (facultatif)"],"GCWCCo":["Fermer le lecteur"],"GDvlUT":["Rôle"],"GLukaH":["Modérateur"],"GNSsCc":["Impossible de créer la collection"],"GQ38dl":[["unreadChatCount"]," messages non lus"],"GY6uSc":["Suivant →"],"GbqhrN":[["0"],"–",["1"]," caractères : lettres, chiffres ou tirets bas"],"GptGxg":["Changer le mot de passe"],"H4MGkI":["Agrandir le lecteur"],"H4o4sk":["Personne pour le moment."],"H8pzW-":["Impossible de mettre à jour l\'avatar"],"HTLDA4":["Chargement…"],"H_PnT8":["Insérer un emoji"],"I-x669":["Invités"],"IO9KL_":["Piste suivante"],"IZX7TO":["Impossible de générer une invitation"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Répondre"],"JJ-Bhk":["Votre adresse e-mail"],"JRQitQ":["Confirmer le nouveau mot de passe"],"JXr41k":["<0>",["0"],"0> a commenté sur <1>",["1"],"1>"],"Jd58Fo":["Tendances"],"Jf0PuK":["Aller à la connexion"],"JlFcis":["Envoyer"],"K7tIrx":["Catégorie"],"KDGWg5":["Retirer de la collection"],"KDw4GX":["Réessayer"],"KKZxBU":["Envoi ",["0"]," %"],"K_F6pa":["Enregistrement…"],"KtYF-t":["Sons"],"L-rMC9":["Réinitialiser par défaut"],"L7_rsx":["Envoi de l\'image…"],"LLyMkV":["Suivies (",["0"],["1"],")"],"MHrjPM":["Titre"],"MKEPCY":["Suivre"],"MTb2Ot":["Aucun message pour l\'instant. Dites bonjour !"],"Mj6oCJ":[["0"]," est en train d\'écrire…"],"N40H-G":["Tout"],"N6j2JH":["Modifier ",["0"]],"NUrY9o":["Catégories"],"O32mmL":["Modifier la description"],"Oprv1v":["Mot de passe (min. ",["0"]," caractères)"],"Oz0N9s":["nouveau"],"PN3pBl":["Pas de réponse du serveur — ce message n\'a peut-être pas été envoyé"],"PUe8Yk":["Réduire le lecteur"],"PiH3UR":["Copié !"],"Pn2B7_":["Mot de passe actuel"],"Pwqkdw":["Chargement…"],"Q4WUjR":["Rechercher un emoji…"],"Q6n4F4":["Actualiser les métadonnées"],"QGaQRo":["Erreur lors de la mise à jour du rôle"],"QKsaQr":["ou <0>parcourir les fichiers0>"],"QLtPBd":["Aucune reco dans cette collection pour l\'instant."],"R7SsBE":["Couper le son"],"R9Khdg":["Auto"],"RCcPrX":["Supprimer cette collection ? Cette action est irréversible."],"RTksSy":["<0>",["0"],"0> a commencé à vous suivre"],"RaKjrM":["Impossible d\'enregistrer la modification"],"RcUHRT":["Suivi"],"RfwZxd":["Réinitialiser le mot de passe"],"Rrp6-J":["Se connecter pour aimer"],"RvVJBH":["Actualisation du flux…"],"S1xV5B":[["maxLength"]," caractères au maximum."],"SBTElJ":["Recherche…"],"Sad2tK":["Envoi…"],"So7gCD":["Collez un lien…"],"StovX6":["Rien à voir, circulez."],"Sxm8rQ":["Utilisateurs"],"T8ejtZ":["Ce commentaire n\'a pas pu être supprimé. Réessayez."],"T91vKp":["Lecture"],"T9bjWt":["<0>",["0"],"0> a été ajouté à <1>",["1"],"1>"],"TAtbho":["Chargement des messages…"],"TM1ZbA":["Collections (",["0"],["1"],")"],"TN382O":["Lien invalide"],"Tv9vbB":["Suivre la collection"],"Tz0i8g":["Paramètres"],"U3pytU":["Administrateur"],"UNMVei":["Mot de passe oublié ?"],"UOZith":["Publication échouée"],"URAieT":["Se connecter pour voter"],"VCoEm-":["Retour à la connexion"],"VKsaTi":["En réponse à"],"V_e7nf":["Définir un nouveau mot de passe"],"VnNJbN":["De collections"],"VskHIx":["Supprimer la catégorie"],"VyTYmS":["Changer l\'avatar"],"W9FRBT":["Aimer"],"WAcnnR":["L\'image n\'a pas pu être envoyée."],"WE_r9K":["Échec de la création de la catégorie"],"WfffrI":["identifiant"],"WhimMi":["Échec de la réinitialisation"],"WpXcBJ":["Rien ici pour l\'instant."],"XHqhoS":["Impossible de charger le chat."],"XJVYo8":["Pourquoi & où"],"XJy2oN":["Connexion…"],"Xan6QP":["Nouvelle reco"],"XgRtUf":["Impossible de changer le mot de passe"],"Xi0Mn4":["← Retour au profil"],"XnL-Eu":["Aucun utilisateur ne correspond à « ",["q"]," »."],"Xs2Lez":["Ce lien de réinitialisation est absent ou malformé."],"YK1Dhc":["une publication"],"YZxXK8":["Fermer le sélecteur d\'emoji"],"YcjhhX":["Gestion utilisateur"],"Ye9RMF":["<0>",["0"],"0> a aimé votre commentaire sur <1>",["1"],"1>"],"YpkCca":["Pas encore de collections suivies."],"Z5HWHd":["Activés"],"ZBdbv9":["Modifier le titre"],"ZCpU0u":["Aucune collection ne correspond à « ",["q"]," »."],"ZWxjuD":["Certaines images n\'ont pas pu être envoyées."],"ZmD2o6":["Créer et ajouter"],"_3O5R_":["Échec de la demande"],"_DwR-n":["Création…"],"_R_sGB":["Mot de passe modifié avec succès."],"_aept4":["Publier la réponse"],"_cF7Rs":["Volume"],"_karOe":["Hors ligne — message non envoyé"],"_nT6AE":["Nouveau mot de passe"],"_t4W-i":["De personnes"],"aAIQg2":["Apparence"],"aDvLhk":["Ajouter un commentaire…"],"az8lvo":["Désactivés"],"b3Thhd":["Envoi échoué"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"bQhwn-":["Chargement de la collection…"],"bzSI52":["Abandonner"],"cILfnJ":["Supprimer le fichier"],"cNu4my":[["0"]," de plus en ligne : ",["restNames"]],"cYP9Sb":["+ Collection"],"cbeBbZ":["Au moins ",["0"]," caractères"],"cnGeoo":["Supprimer"],"dAs22m":["Remplacer le fichier"],"dEgA5A":["Annuler"],"dMizp8":["Nouvelle collection"],"dau1Pl":[["unseenCount"]," nouveaux messages"],"dqwCfR":["Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l\'aperçu plus tard."],"dznB-Y":["Seules les images peuvent être déposées ici."],"eFSqvc":["Impossible de publier la réponse"],"eIEHHV":["Changer la couleur de peau"],"eOfXq3":["Un titre est requis."],"ePK91l":["Modifier"],"eaUTwS":["Envoyer le lien de réinitialisation"],"ecUA8p":["Aujourd\'hui"],"ef9nPf":["Chargement de la reco…"],"eloqXQ":["Vous pouvez la publier quand même."],"en9o7K":["Impossible de publier le commentaire"],"etFQQS":["Pourquoi on en voudrait ?"],"fC6mXb":["Voter"],"fI-mNw":["Collections"],"f_akpP":["Max 50 Mo"],"fgLNSM":["S\'inscrire"],"gANddk":["Envoi…"],"gGx5tM":["Modification"],"gIQQwD":["Chargement échoué"],"gL9KKi":["Qui peut la voir"],"gLfZlz":["Ajouter à la collection"],"gjJ-sb":["Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."],"goWEhR":["Piste précédente"],"hBuUKa":["Changer le mot de passe…"],"hD7w09":["Vous avez tout lu, tout vu, tout bu."],"hJSliC":["<0>",["0"],"0> a publié <1>",["1"],"1>"],"hT8hgO":["Rétablir le titre suggéré"],"hYgDIe":["Créer"],"he3ygx":["Copier"],"hpCc_Z":["Fermer quand même"],"i7K_Te":["Qui suis-je ?"],"iDNBZe":["Notifications"],"iWpEwy":["Accueil"],"ipYn7W":["Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."],"isRobC":["Nouveau"],"jbernk":["Chargement du profil…"],"joEmfT":["Serveur inaccessible"],"jrZTZl":["Pas encore de recos. Soyez le premier !"],"k112DD":["Charger les messages plus anciens"],"k36iJ-":["Échec de l’enregistrement de la catégorie"],"k4b5_X":["modifié"],"kLttbL":["Inscription échouée"],"lUDifl":["Créées (",["0"],["1"],")"],"lUanmi":["Vous serez notifié lorsque quelqu\'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."],"lY5h1V":[["0","plural",{"one":["#"," reco"],"other":["#"," recos"]}]],"lcfvr_":["Supprimer ce commentaire ?"],"lpIMne":["Les mots de passe ne correspondent pas"],"mb_Wju":["Déposez un fichier ici, ou collez-en un"],"mt6O6E":["C\'est un mirage."],"nbm5sI":["Aucune reco ne correspond à « ",["q"]," »."],"nrjqON":["Vérification de l\'invitation…"],"nwtY4N":["Une erreur est survenue"],"nx4kaN":["Sauvegarde impossible"],"oTYvvN":[["0","plural",{"one":["et ","#"," reco plus ancienne"],"other":["et ","#"," recos plus anciennes"]}]],"ogtYkT":["Mot de passe mis à jour"],"pCpd9p":["<0>",["0"],"0> vous a mentionné dans <1>",["where"],"1>"],"pSheLH":["Aucun invité pour le moment."],"pvnfJD":["Sombre"],"qIMfNQ":["Supprimer la collection"],"qbDAcy":["Recommander"],"qgx_78":["Suivez des collections publiques pour voir leurs recos ici."],"qsqt0w":["Lien d\'invitation"],"qvFa8r":["public"],"qvz_Pp":["Reco"],"rCbqPX":["Ce lien d\'invitation est manquant, expiré ou déjà utilisé."],"rDMzDU":["Écrivez un message…"],"rg9pXu":["Recherche échouée"],"rn6SBY":["Rétablir le son"],"rtpJqV":["Recos (",["0"],["1"],")"],"sEXJPi":["Lien ou fichier"],"sGeXL3":["Miniature"],"sQia9P":["Se connecter"],"sTiqbm":["invité par"],"sdP5Aa":["[supprimé]"],"sgX7B-":["Usage unique, valable 7 jours."],"shHs8T":["Saisissez une recherche."],"smeBfS":["Invitation invalide"],"teaneZ":["image"],"tfDRzk":["Enregistrer"],"tvmuQ0":["Thème de couleur"],"u1lDX2":["Récupération de l\'aperçu…"],"uD0qXQ":["Déposez un fichier ici"],"uISnsY":["Suppression impossible"],"uMGUnV":["Pas encore de collections."],"ub1EEL":["modifié ",["0"]],"uomLas":["Ajouter une catégorie"],"v-Wp--":["Ouvrir la source"],"vJBF1r":["Publication…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," non lues)"],"vQMkHu":["Retirer le vote"],"vuosjb":["Menu utilisateur"],"wbXKOv":["Fichier trop volumineux (max 50 Mo)."],"wckWOP":["Administration"],"wixIgH":["Vous avez déjà un compte ? <0>Se connecter0>"],"x6tjuK":["Onglet par défaut"],"xEWkgZ":["← Retour à toutes les recos"],"xPHtx0":["Lancer la recherche"],"xVuNgt":["+ Nouvelle collection"],"xc9O_u":["Supprimer la reco"],"y6sq5j":["Abonné"],"y7oaHj":["Titre de la collection"],"yA_6BX":["Tout voir →"],"yBBtRm":["Suivez des utilisateurs pour voir leurs recos ici."],"yQ2kGp":["Charger plus"],"y_0uwd":["Hier"],"yz7wBu":["Fermer"],"yzF66j":["Lien"],"z0ROB3":["Retirer le j\'aime"],"z1uNN0":["Aucun emoji trouvé."],"zVuxvN":["Actualisation…"],"zwBp5t":["Privé"]}',
+ '{"-K9EZb":["Ajouter un e-mail…"],"-OxI15":["Collections suivies"],"-Ya-b9":["Enregistrement échoué"],"-siMqD":["Journal"],"1CalO6":["Style"],"1HfJWf":["Rechercher des recos, utilisateurs, collections…"],"1QfxQT":["Fermer"],"1YKhJ3":["1 nouveau message"],"1cbYY_":["Votés (",["0"],["1"],")"],"1njn7W":["Clair"],"1utXA6":["Recos"],"26iNma":["Publier le commentaire"],"2DoBvq":["Flux"],"2Hlmdt":["Écrire une réponse…"],"2_DHgA":["Identifiant d’URL"],"2nrsEn":["message supprimé"],"2ygf_L":["← Retour"],"3KKSM4":["privé"],"3QuNUr":["Plusieurs personnes écrivent…"],"3SEwY1":["Supprimer la catégorie \\"",["0"],"\\" ? Cette action est irréversible."],"3qkggm":["Plein écran"],"3yfh3D":["<0>",["0"],"0> a suivi votre collection <1>",["1"],"1>"],"48e2R8":["Modifier l\'e-mail"],"49voTZ":[["label"]," (",["count"],")"],"4B6w_o":["Recommandé !"],"4GKuCs":["Connexion échouée"],"4HH9iB":["Aucun abonnement pour le moment."],"4OjqAQ":["Continuer l\'édition"],"4RtQ1k":["Ne plus suivre ",["targetUsername"]],"4c-qBx":["Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."],"4yj9xV":["Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"],"5KHDCi":[["0"]," et ",["1"]," sont en train d\'écrire…"],"5TviPn":["Annuler la recherche"],"5cC8f2":["Modifié le ",["0"]],"5oD9f_":["Plus tôt"],"6Qly-0":["un commentaire"],"6YE0P6":["Édition de ",["0"]],"6YtxFj":["Nom"],"6gRgw8":["Réessayer"],"7PHCIN":["Annuler la suppression"],"7PzzBU":["Utilisateur"],"7d1a0d":["Public"],"7sNhEz":["Nom d\'utilisateur"],"8-8abB":["Déconnecté — reconnexion…"],"8F1i42":["Page introuvable"],"8Ug9jB":["Connexe"],"8ZsakT":["Mot de passe"],"92o1-U":["Suppression échouée"],"9BruTc":["Pourquoi ?"],"9l4qcT":["Déposez un fichier de remplacement ici"],"9uI_rE":["Annuler"],"9xDZu_":["Impossible de charger."],"A0y396":["+ Inviter quelqu\'un"],"A1taO8":["Rechercher"],"AEfu-D":["Dans les collections"],"AHZflp":["Tribune"],"AQbgNR":["Suivre ",["targetUsername"]],"AS7WoE":["Repartir de zéro"],"ATGYL1":["Adresse e-mail"],"AZctoV":[["0","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"Ade-6d":["Mises à jour en direct indisponibles."],"AeXO77":["Compte"],"AhNP40":["Position"],"AnRu_j":["Annuler la réponse"],"B4LeYP":["Échec de la suppression (",["0"],")"],"BjitBo":["Télécharger ",["0"]],"BlpJ2G":["Reprise de votre brouillon."],"CI50ct":["Voté"],"C_qQE4":["Déjà recommandé par ",["0"]," ",["1"]],"Cj24wt":["Inscription…"],"CnqayG":["La mise à jour a échoué. Réessayez."],"D8DRYV":["Supprimer ce message ?"],"DI3NBO":["Ouvrir sur ",["0"]],"DPfwMq":["Terminé"],"DdeHXH":["Supprimer cette reco ? Cette action est irréversible."],"Dp1JhP":["<0>",["0"],"0> a voté pour <1>",["1"],"1>"],"EBU6O6":["Connexion perdue — ce message n\'a peut-être pas été envoyé"],"ECiS12":["Voir la reco →"],"ED8n0z":["la tribune"],"EarrCe":["Rechercher un emoji"],"Evcm2Z":["Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."],"EwVrWF":["Le dump n\'a pas pu être retiré. Réessayez."],"ExR0Fr":["L\'URL est obligatoire."],"F5Js1v":["Ne plus suivre la collection"],"FdTlEG":["Piste ",["0"]],"FgAxTj":["Se déconnecter"],"Fxf4jq":["Description (facultatif)"],"GCWCCo":["Fermer le lecteur"],"GDvlUT":["Rôle"],"GLukaH":["Modérateur"],"GNSsCc":["Impossible de créer la collection"],"GQ38dl":[["unreadChatCount"]," messages non lus"],"GY6uSc":["Suivant →"],"GbqhrN":[["0"],"–",["1"]," caractères : lettres, chiffres ou tirets bas"],"GptGxg":["Changer le mot de passe"],"Gv5nA5":["Format de réponse inattendu"],"H4MGkI":["Agrandir le lecteur"],"H4o4sk":["Personne pour le moment."],"H8pzW-":["Impossible de mettre à jour l\'avatar"],"HTLDA4":["Chargement…"],"H_PnT8":["Insérer un emoji"],"I-x669":["Invités"],"IO9KL_":["Piste suivante"],"IZX7TO":["Impossible de générer une invitation"],"IagCbF":["URL"],"IgrLD_":["Pause"],"ImOQa9":["Répondre"],"JJ-Bhk":["Votre adresse e-mail"],"JRQitQ":["Confirmer le nouveau mot de passe"],"JXr41k":["<0>",["0"],"0> a commenté sur <1>",["1"],"1>"],"Jd58Fo":["Tendances"],"Jf0PuK":["Aller à la connexion"],"JlFcis":["Envoyer"],"K7tIrx":["Catégorie"],"KDGWg5":["Retirer de la collection"],"KDw4GX":["Réessayer"],"KKZxBU":["Envoi ",["0"]," %"],"K_F6pa":["Enregistrement…"],"KtYF-t":["Sons"],"L-rMC9":["Réinitialiser par défaut"],"L7_rsx":["Envoi de l\'image…"],"LLyMkV":["Suivies (",["0"],["1"],")"],"MHrjPM":["Titre"],"MKEPCY":["Suivre"],"MTb2Ot":["Aucun message pour l\'instant. Dites bonjour !"],"Mj6oCJ":[["0"]," est en train d\'écrire…"],"N40H-G":["Tout"],"N6j2JH":["Modifier ",["0"]],"NUrY9o":["Catégories"],"O32mmL":["Modifier la description"],"Oprv1v":["Mot de passe (min. ",["0"]," caractères)"],"Oz0N9s":["nouveau"],"P5MuiB":["Ajouter une image"],"PN3pBl":["Pas de réponse du serveur — ce message n\'a peut-être pas été envoyé"],"PUe8Yk":["Réduire le lecteur"],"PiH3UR":["Copié !"],"Pn2B7_":["Mot de passe actuel"],"Pwqkdw":["Chargement…"],"Q4WUjR":["Rechercher un emoji…"],"Q6n4F4":["Actualiser les métadonnées"],"QGaQRo":["Erreur lors de la mise à jour du rôle"],"QKsaQr":["ou <0>parcourir les fichiers0>"],"QLtPBd":["Aucune reco dans cette collection pour l\'instant."],"QctOgc":["Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."],"R7SsBE":["Couper le son"],"R9Khdg":["Auto"],"RCcPrX":["Supprimer cette collection ? Cette action est irréversible."],"RTksSy":["<0>",["0"],"0> a commencé à vous suivre"],"RaKjrM":["Impossible d\'enregistrer la modification"],"RcUHRT":["Suivi"],"RfwZxd":["Réinitialiser le mot de passe"],"Rrp6-J":["Se connecter pour aimer"],"RvVJBH":["Actualisation du flux…"],"S1xV5B":[["maxLength"]," caractères au maximum."],"SBTElJ":["Recherche…"],"Sad2tK":["Envoi…"],"So7gCD":["Collez un lien…"],"StovX6":["Rien à voir, circulez."],"Sxm8rQ":["Utilisateurs"],"T8ejtZ":["Ce commentaire n\'a pas pu être supprimé. Réessayez."],"T91vKp":["Lecture"],"T9bjWt":["<0>",["0"],"0> a été ajouté à <1>",["1"],"1>"],"TAtbho":["Chargement des messages…"],"TM1ZbA":["Collections (",["0"],["1"],")"],"TN382O":["Lien invalide"],"Tv9vbB":["Suivre la collection"],"Tz0i8g":["Paramètres"],"U3pytU":["Administrateur"],"UNMVei":["Mot de passe oublié ?"],"UOZith":["Publication échouée"],"URAieT":["Se connecter pour voter"],"V38P77":["Seuls son auteur et les modérateurs peuvent modifier ce dump."],"VCoEm-":["Retour à la connexion"],"VKsaTi":["En réponse à"],"VLAd8f":["Max ",["limit"]],"V_e7nf":["Définir un nouveau mot de passe"],"VnNJbN":["De collections"],"VskHIx":["Supprimer la catégorie"],"VyTYmS":["Changer l\'avatar"],"W5A0Ly":["Une erreur inattendue s\'est produite."],"W9FRBT":["Aimer"],"WAcnnR":["L\'image n\'a pas pu être envoyée."],"WE_r9K":["Échec de la création de la catégorie"],"WfffrI":["identifiant"],"WhimMi":["Échec de la réinitialisation"],"WpXcBJ":["Rien ici pour l\'instant."],"XHqhoS":["Impossible de charger le chat."],"XJVYo8":["Pourquoi & où"],"XJy2oN":["Connexion…"],"Xan6QP":["Nouvelle reco"],"XgRtUf":["Impossible de changer le mot de passe"],"Xi0Mn4":["← Retour au profil"],"XnL-Eu":["Aucun utilisateur ne correspond à « ",["q"]," »."],"Xs2Lez":["Ce lien de réinitialisation est absent ou malformé."],"YK1Dhc":["une publication"],"YZxXK8":["Fermer le sélecteur d\'emoji"],"YcjhhX":["Gestion utilisateur"],"Ye9RMF":["<0>",["0"],"0> a aimé votre commentaire sur <1>",["1"],"1>"],"YpkCca":["Pas encore de collections suivies."],"Z5HWHd":["Activés"],"ZBdbv9":["Modifier le titre"],"ZCpU0u":["Aucune collection ne correspond à « ",["q"]," »."],"ZWxjuD":["Certaines images n\'ont pas pu être envoyées."],"ZmD2o6":["Créer et ajouter"],"_3O5R_":["Échec de la demande"],"_DwR-n":["Création…"],"_R_sGB":["Mot de passe modifié avec succès."],"_aept4":["Publier la réponse"],"_cF7Rs":["Volume"],"_karOe":["Hors ligne — message non envoyé"],"_nT6AE":["Nouveau mot de passe"],"_t4W-i":["De personnes"],"aAIQg2":["Apparence"],"aDvLhk":["Ajouter un commentaire…"],"az8lvo":["Désactivés"],"b3Thhd":["Envoi échoué"],"b8XMJ8":[["visibleCount","plural",{"one":["#"," commentaire"],"other":["#"," commentaires"]}]],"bQhwn-":["Chargement de la collection…"],"bzSI52":["Abandonner"],"cILfnJ":["Supprimer le fichier"],"cNu4my":[["0"]," de plus en ligne : ",["restNames"]],"cYP9Sb":["+ Collection"],"cbeBbZ":["Au moins ",["0"]," caractères"],"cnGeoo":["Supprimer"],"dAs22m":["Remplacer le fichier"],"dEgA5A":["Annuler"],"dMizp8":["Nouvelle collection"],"dau1Pl":[["unseenCount"]," nouveaux messages"],"dqwCfR":["Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l\'aperçu plus tard."],"dznB-Y":["Seules les images peuvent être déposées ici."],"eFSqvc":["Impossible de publier la réponse"],"eIEHHV":["Changer la couleur de peau"],"eOfXq3":["Un titre est requis."],"ePK91l":["Modifier"],"eaUTwS":["Envoyer le lien de réinitialisation"],"ecUA8p":["Aujourd\'hui"],"ef9nPf":["Chargement de la reco…"],"eloqXQ":["Vous pouvez la publier quand même."],"en9o7K":["Impossible de publier le commentaire"],"etFQQS":["Pourquoi on en voudrait ?"],"f3cEDO":["Changer l\'image"],"fC6mXb":["Voter"],"fI-mNw":["Collections"],"fgLNSM":["S\'inscrire"],"gANddk":["Envoi…"],"gGx5tM":["Modification"],"gIQQwD":["Chargement échoué"],"gL9KKi":["Qui peut la voir"],"gLfZlz":["Ajouter à la collection"],"gjJ-sb":["Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."],"goWEhR":["Piste précédente"],"hBuUKa":["Changer le mot de passe…"],"hD7w09":["Vous avez tout lu, tout vu, tout bu."],"hJSliC":["<0>",["0"],"0> a publié <1>",["1"],"1>"],"hT8hgO":["Rétablir le titre suggéré"],"hYgDIe":["Créer"],"he3ygx":["Copier"],"hfk-mb":["Le dump n\'a pas pu être remis. Réessayez."],"hpCc_Z":["Fermer quand même"],"i7K_Te":["Qui suis-je ?"],"iDNBZe":["Notifications"],"iWpEwy":["Accueil"],"ipYn7W":["Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."],"isRobC":["Nouveau"],"jbernk":["Chargement du profil…"],"joEmfT":["Serveur inaccessible"],"jrZTZl":["Pas encore de recos. Soyez le premier !"],"k112DD":["Charger les messages plus anciens"],"k36iJ-":["Échec de l’enregistrement de la catégorie"],"k4b5_X":["modifié"],"kLttbL":["Inscription échouée"],"lUDifl":["Créées (",["0"],["1"],")"],"lUanmi":["Vous serez notifié lorsque quelqu\'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."],"lY5h1V":[["0","plural",{"one":["#"," reco"],"other":["#"," recos"]}]],"lcfvr_":["Supprimer ce commentaire ?"],"lpIMne":["Les mots de passe ne correspondent pas"],"mb_Wju":["Déposez un fichier ici, ou collez-en un"],"mt6O6E":["C\'est un mirage."],"nbm5sI":["Aucune reco ne correspond à « ",["q"]," »."],"nrjqON":["Vérification de l\'invitation…"],"nwtY4N":["Une erreur est survenue"],"nx4kaN":["Sauvegarde impossible"],"oTYvvN":[["0","plural",{"one":["et ","#"," reco plus ancienne"],"other":["et ","#"," recos plus anciennes"]}]],"ogtYkT":["Mot de passe mis à jour"],"pCpd9p":["<0>",["0"],"0> vous a mentionné dans <1>",["where"],"1>"],"pSheLH":["Aucun invité pour le moment."],"pd3FCq":["Ce fichier dépasse ",["limit"],"."],"pvnfJD":["Sombre"],"qIMfNQ":["Supprimer la collection"],"qbDAcy":["Recommander"],"qgx_78":["Suivez des collections publiques pour voir leurs recos ici."],"qsqt0w":["Lien d\'invitation"],"qvFa8r":["public"],"qvz_Pp":["Reco"],"rCbqPX":["Ce lien d\'invitation est manquant, expiré ou déjà utilisé."],"rDMzDU":["Écrivez un message…"],"rg9pXu":["Recherche échouée"],"rn6SBY":["Rétablir le son"],"rtpJqV":["Recos (",["0"],["1"],")"],"sEXJPi":["Lien ou fichier"],"sGeXL3":["Miniature"],"sQia9P":["Se connecter"],"sTiqbm":["invité par"],"sdP5Aa":["[supprimé]"],"sgX7B-":["Usage unique, valable 7 jours."],"shHs8T":["Saisissez une recherche."],"smeBfS":["Invitation invalide"],"teaneZ":["image"],"tfDRzk":["Enregistrer"],"tvmuQ0":["Thème de couleur"],"u1lDX2":["Récupération de l\'aperçu…"],"uD0qXQ":["Déposez un fichier ici"],"uISnsY":["Suppression impossible"],"uMGUnV":["Pas encore de collections."],"ub1EEL":["modifié ",["0"]],"uomLas":["Ajouter une catégorie"],"v-Wp--":["Ouvrir la source"],"vJBF1r":["Publication…"],"vLhLLO":["Notifications (",["unreadNotificationCount"]," non lues)"],"vQMkHu":["Retirer le vote"],"vuosjb":["Menu utilisateur"],"wckWOP":["Administration"],"wixIgH":["Vous avez déjà un compte ? <0>Se connecter0>"],"wtxFp4":["Fichier trop volumineux (max ",["max"],")."],"x6tjuK":["Onglet par défaut"],"xEWkgZ":["← Retour à toutes les recos"],"xPHtx0":["Lancer la recherche"],"xVuNgt":["+ Nouvelle collection"],"xc9O_u":["Supprimer la reco"],"y6sq5j":["Abonné"],"y7oaHj":["Titre de la collection"],"yA_6BX":["Tout voir →"],"yBBtRm":["Suivez des utilisateurs pour voir leurs recos ici."],"yQ2kGp":["Charger plus"],"y_0uwd":["Hier"],"yz7wBu":["Fermer"],"yzF66j":["Lien"],"z0ROB3":["Retirer le j\'aime"],"z1uNN0":["Aucun emoji trouvé."],"zVuxvN":["Actualisation…"],"zwBp5t":["Privé"]}',
),
};
diff --git a/src/locales/fr.po b/src/locales/fr.po
index 1b1c3da6..1ecef0a6 100644
--- a/src/locales/fr.po
+++ b/src/locales/fr.po
@@ -13,1587 +13,1623 @@ msgstr ""
"Last-Translator: \n"
"Language-Team: \n"
-#: src/components/comment/CommentNode.tsx:123
+#: src/components/comment/CommentNode.tsx:139
msgid "[deleted]"
msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount
-#: src/components/dump/DumpCard.tsx:116
-#: src/components/dump/JournalCard.tsx:120
+#: src/components/dump/DumpCard.tsx:139
+#: src/components/dump/JournalCard.tsx:133
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
#. placeholder {0}: playlist.dumpCount
-#: src/components/playlist/PlaylistCard.tsx:87
+#: src/components/playlist/PlaylistCard.tsx:108
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# reco} other {# recos}}"
#. placeholder {0}: rest.length
-#: src/components/dump/create/DuplicateNotice.tsx:34
+#: src/components/dump/create/DuplicateNotice.tsx:52
msgid "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus anciennes}}"
#. placeholder {0}: typingNames[0]
#. placeholder {1}: typingNames[1]
-#: src/components/chat/ChatModal.tsx:606
+#: src/components/chat/ChatModal.tsx:627
msgid "{0} and {1} are typing…"
msgstr "{0} et {1} sont en train d'écrire…"
#. placeholder {0}: typingNames[0]
-#: src/components/chat/ChatModal.tsx:604
+#: src/components/chat/ChatModal.tsx:625
msgid "{0} is typing…"
msgstr "{0} est en train d'écrire…"
#. placeholder {0}: rest.length
-#: src/components/feed/PresenceRow.tsx:66
+#: src/components/feed/PresenceRow.tsx:81
msgid "{0} more online: {restNames}"
msgstr "{0} de plus en ligne : {restNames}"
#. placeholder {0}: VALIDATION.USERNAME_MIN
#. placeholder {1}: VALIDATION.USERNAME_MAX
-#: src/pages/UserRegister.tsx:127
+#: src/pages/UserRegister.tsx:145
msgid "{0}–{1} characters: letters, numbers, or underscores"
msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
-#: src/pages/Search.tsx:190
+#: src/pages/Search.tsx:201
msgid "{label} ({count})"
msgstr "{label} ({count})"
-#: src/components/chat/ChatModal.tsx:735
+#: src/components/chat/ChatModal.tsx:756
msgid "{unseenCount} new messages"
msgstr "{unseenCount} nouveaux messages"
-#: src/components/comment/CommentThread.tsx:61
+#: src/components/comment/CommentThread.tsx:82
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
-#: src/components/dump/create/DumpCreateModal.tsx:766
-#: src/pages/PlaylistDetail.tsx:603
-#: src/pages/UserPublicProfile.tsx:816
+#: src/components/dump/create/DumpCreateModal.tsx:778
+#: src/pages/PlaylistDetail.tsx:630
+#: src/pages/UserPublicProfile.tsx:853
msgid "← Back"
msgstr "← Retour"
-#: src/pages/Dump.tsx:287
-#: src/pages/Dump.tsx:530
-#: src/pages/DumpEdit.tsx:193
+#: src/pages/Dump.tsx:314
+#: src/pages/Dump.tsx:554
+#: src/pages/DumpEdit.tsx:217
msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos"
-#: src/pages/UserDumps.tsx:66
-#: src/pages/UserPlaylists.tsx:364
-#: src/pages/UserUpvoted.tsx:144
+#: src/pages/UserDumps.tsx:76
+#: src/pages/UserPlaylists.tsx:380
+#: src/pages/UserUpvoted.tsx:169
msgid "← Back to profile"
msgstr "← Retour au profil"
-#: src/pages/UserPublicProfile.tsx:134
+#: src/pages/UserPublicProfile.tsx:142
msgid "+ Invite someone"
msgstr "+ Inviter quelqu'un"
-#: src/components/playlist/PlaylistMembershipPanel.tsx:90
+#: src/components/playlist/PlaylistMembershipPanel.tsx:107
msgid "+ New playlist"
msgstr "+ Nouvelle collection"
-#: src/pages/Dump.tsx:365
+#: src/pages/Dump.tsx:391
msgid "+ Playlist"
msgstr "+ Collection"
#. placeholder {0}: d.commenterUsername
#. placeholder {1}: d.dumpTitle
-#: src/pages/Notifications.tsx:198
+#: src/pages/Notifications.tsx:211
msgid "<0>{0}0> commented on <1>{1}1>"
msgstr "<0>{0}0> a commenté sur <1>{1}1>"
#. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle
-#: src/pages/Notifications.tsx:158
+#: src/pages/Notifications.tsx:171
msgid "<0>{0}0> followed your playlist <1>{1}1>"
msgstr "<0>{0}0> a suivi votre collection <1>{1}1>"
#. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle
-#: src/pages/Notifications.tsx:208
+#: src/pages/Notifications.tsx:221
msgid "<0>{0}0> liked your comment on <1>{1}1>"
msgstr "<0>{0}0> a aimé votre commentaire sur <1>{1}1>"
#. placeholder {0}: d.mentionerUsername
-#: src/pages/Notifications.tsx:224
+#: src/pages/Notifications.tsx:237
msgid "<0>{0}0> mentioned you in <1>{where}1>"
msgstr "<0>{0}0> vous a mentionné dans <1>{where}1>"
#. placeholder {0}: d.dumperUsername
#. placeholder {1}: d.dumpTitle
-#: src/pages/Notifications.tsx:168
+#: src/pages/Notifications.tsx:181
msgid "<0>{0}0> posted <1>{1}1>"
msgstr "<0>{0}0> a publié <1>{1}1>"
#. placeholder {0}: d.followerUsername
-#: src/pages/Notifications.tsx:149
+#: src/pages/Notifications.tsx:162
msgid "<0>{0}0> started following you"
msgstr "<0>{0}0> a commencé à vous suivre"
#. placeholder {0}: d.voterUsername
#. placeholder {1}: d.dumpTitle
-#: src/pages/Notifications.tsx:188
+#: src/pages/Notifications.tsx:201
msgid "<0>{0}0> upvoted <1>{1}1>"
msgstr "<0>{0}0> a voté pour <1>{1}1>"
#. placeholder {0}: d.dumpTitle
#. placeholder {1}: d.playlistTitle
-#: src/pages/Notifications.tsx:178
+#: src/pages/Notifications.tsx:191
msgid "<0>{0}0> was added to <1>{1}1>"
msgstr "<0>{0}0> a été ajouté à <1>{1}1>"
-#: src/components/chat/ChatModal.tsx:734
+#: src/components/chat/ChatModal.tsx:755
msgid "1 new message"
msgstr "1 nouveau message"
-#: src/pages/Notifications.tsx:219
+#: src/pages/Notifications.tsx:232
msgid "a comment"
msgstr "un commentaire"
-#: src/pages/Notifications.tsx:222
+#: src/pages/Notifications.tsx:235
msgid "a post"
msgstr "une publication"
-#: src/pages/UserPublicProfile.tsx:1224
+#: src/pages/UserPublicProfile.tsx:1261
msgid "Account"
msgstr "Compte"
-#: src/components/comment/CommentThread.tsx:81
+#: src/components/comment/CommentThread.tsx:102
msgid "Add a comment…"
msgstr "Ajouter un commentaire…"
-#: src/components/category/CategoryManager.tsx:95
-#: src/components/category/CategoryManager.tsx:96
+#: src/components/category/CategoryManager.tsx:104
+#: src/components/category/CategoryManager.tsx:105
msgid "Add category"
msgstr "Ajouter une catégorie"
-#: src/pages/UserPublicProfile.tsx:902
+#: src/pages/UserPublicProfile.tsx:939
msgid "Add email…"
msgstr "Ajouter un e-mail…"
-#: src/components/dump/create/DumpCreateModal.tsx:487
-#: src/components/playlist/AddToPlaylistModal.tsx:60
+#: src/components/shared/ImagePicker.tsx:63
+msgid "Add image"
+msgstr "Ajouter une image"
+
+#: src/components/dump/create/DumpCreateModal.tsx:500
+#: src/components/playlist/AddToPlaylistModal.tsx:72
msgid "Add to playlist"
msgstr "Ajouter à la collection"
-#: src/pages/UserPublicProfile.tsx:1694
+#: src/pages/UserPublicProfile.tsx:1726
msgid "Admin"
msgstr "Administrateur"
-#: src/components/category/CategorySwitcher.tsx:80
-#: src/components/category/CategorySwitcher.tsx:83
+#: src/components/category/CategorySwitcher.tsx:84
+#: src/components/category/CategorySwitcher.tsx:87
msgid "All"
msgstr "Tout"
#. placeholder {0}: first.username
#. placeholder {1}: relativeTime(first.createdAt)
-#: src/components/dump/create/DuplicateNotice.tsx:21
+#: src/components/dump/create/DuplicateNotice.tsx:39
msgid "Already dumped by {0} {1}"
msgstr "Déjà recommandé par {0} {1}"
-#: src/pages/UserRegister.tsx:157
+#: src/pages/UserRegister.tsx:175
msgid "Already have an account? <0>Log in0>"
msgstr "Vous avez déjà un compte ? <0>Se connecter0>"
-#: src/pages/UserPublicProfile.tsx:1243
+#: src/utils/shared/api.ts:34
+msgid "An unexpected error occurred."
+msgstr "Une erreur inattendue s'est produite."
+
+#: src/pages/UserPublicProfile.tsx:1280
msgid "Appearance"
msgstr "Apparence"
#. placeholder {0}: VALIDATION.PASSWORD_MIN
-#: src/components/profile/ChangePasswordModal.tsx:88
-#: src/pages/ResetPassword.tsx:110
-#: src/pages/UserRegister.tsx:146
+#: src/components/profile/ChangePasswordModal.tsx:102
+#: src/pages/ResetPassword.tsx:121
+#: src/pages/UserRegister.tsx:164
msgid "At least {0} characters"
msgstr "Au moins {0} caractères"
-#: src/components/form/RichTextField.tsx:56
+#: src/components/form/RichTextField.tsx:66
msgid "At most {maxLength} characters."
msgstr "{maxLength} caractères au maximum."
-#: src/pages/UserPublicProfile.tsx:1305
+#: src/pages/UserPublicProfile.tsx:1342
msgid "Auto"
msgstr "Auto"
-#: src/pages/ResetPassword.tsx:60
-#: src/pages/ResetPassword.tsx:133
+#: src/pages/ResetPassword.tsx:71
+#: src/pages/ResetPassword.tsx:144
msgid "Back to login"
msgstr "Retour à la connexion"
-#: src/contexts/ws/WSProvider.tsx:333
-#: src/contexts/ws/WSProvider.tsx:716
+#: src/contexts/ws/WSProvider.tsx:392
+#: src/contexts/ws/WSProvider.tsx:758
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."
-#: src/components/category/CategoryManager.tsx:212
-#: src/components/category/CategoryManager.tsx:213
-#: src/components/chat/ChatEditBox.tsx:70
-#: src/components/comment/CommentForm.tsx:105
-#: src/components/dump/create/DumpCreateModal.tsx:776
-#: src/components/form/FormActions.tsx:32
-#: src/components/shared/ConfirmModal.tsx:47
-#: src/pages/Dump.tsx:406
-#: src/pages/DumpEdit.tsx:508
-#: src/pages/PlaylistDetail.tsx:971
-#: src/pages/UserPublicProfile.tsx:1823
-#: src/pages/UserPublicProfile.tsx:1898
+#: src/components/category/CategoryManager.tsx:225
+#: src/components/category/CategoryManager.tsx:226
+#: src/components/chat/ChatEditBox.tsx:88
+#: src/components/comment/CommentForm.tsx:127
+#: src/components/dump/create/DumpCreateModal.tsx:788
+#: src/components/form/FormActions.tsx:42
+#: src/components/shared/ConfirmModal.tsx:63
+#: src/pages/Dump.tsx:432
+#: src/pages/DumpEdit.tsx:542
+#: src/pages/PlaylistDetail.tsx:1014
+#: src/pages/UserPublicProfile.tsx:1863
+#: src/pages/UserPublicProfile.tsx:1942
msgid "Cancel"
msgstr "Annuler"
-#: src/pages/PlaylistDetail.tsx:782
+#: src/pages/PlaylistDetail.tsx:808
msgid "Cancel removal"
msgstr "Annuler la suppression"
-#: src/components/chat/ChatModal.tsx:795
-#: src/components/chat/ChatModal.tsx:796
+#: src/components/chat/ChatModal.tsx:816
+#: src/components/chat/ChatModal.tsx:817
msgid "Cancel reply"
msgstr "Annuler la réponse"
-#: src/components/layout/AppHeader.tsx:130
+#: src/components/layout/AppHeader.tsx:162
msgid "Cancel search"
msgstr "Annuler la recherche"
-#: src/components/category/CategorySelect.tsx:22
-#: src/pages/UserPublicProfile.tsx:1420
+#: src/components/category/CategorySelect.tsx:33
+#: src/pages/UserPublicProfile.tsx:1457
msgid "Categories"
msgstr "Catégories"
-#: src/components/category/CategorySwitcher.tsx:103
+#: src/components/category/CategorySwitcher.tsx:107
msgid "Category"
msgstr "Catégorie"
-#: src/pages/UserPublicProfile.tsx:843
+#: src/pages/UserPublicProfile.tsx:880
msgid "Change avatar"
msgstr "Changer l'avatar"
-#: src/components/profile/ChangePasswordModal.tsx:53
-#: src/components/profile/ChangePasswordModal.tsx:107
+#: src/components/shared/ImagePicker.tsx:63
+msgid "Change image"
+msgstr "Changer l'image"
+
+#: src/components/profile/ChangePasswordModal.tsx:67
+#: src/components/profile/ChangePasswordModal.tsx:121
msgid "Change password"
msgstr "Changer le mot de passe"
-#: src/pages/UserPublicProfile.tsx:1236
+#: src/pages/UserPublicProfile.tsx:1273
msgid "Change password…"
msgstr "Changer le mot de passe…"
-#: src/components/editor/EmojiPickerPopup.tsx:85
+#: src/components/editor/EmojiPickerPopup.tsx:108
msgid "Change skin tone"
msgstr "Changer la couleur de peau"
-#: src/components/chat/ChatModal.tsx:695
-#: src/components/layout/ChatButton.tsx:17
-#: src/components/layout/ChatFab.tsx:49
-#: src/components/layout/ChatFab.tsx:50
+#: src/components/chat/ChatModal.tsx:716
+#: src/components/layout/ChatButton.tsx:26
+#: src/components/layout/ChatFab.tsx:53
+#: src/components/layout/ChatFab.tsx:54
msgid "Chat"
msgstr "Tribune"
-#: src/components/layout/ChatButton.tsx:16
-#: src/components/layout/ChatFab.tsx:48
+#: src/components/layout/ChatButton.tsx:25
+#: src/components/layout/ChatFab.tsx:52
msgid "Chat ({unreadChatCount} unread)"
msgstr "{unreadChatCount} messages non lus"
-#: src/pages/UserRegister.tsx:86
+#: src/pages/UserRegister.tsx:106
msgid "Checking invite…"
msgstr "Vérification de l'invitation…"
-#: src/components/profile/ChangePasswordModal.tsx:62
-#: src/components/shared/Modal.tsx:60
+#: src/components/profile/ChangePasswordModal.tsx:76
+#: src/components/shared/Modal.tsx:76
msgid "Close"
msgstr "Fermer"
-#: src/components/dump/create/DumpCreateModal.tsx:518
+#: src/components/dump/create/DumpCreateModal.tsx:531
msgid "Close anyway"
msgstr "Fermer quand même"
-#: src/components/editor/EmojiPickerPopup.tsx:90
+#: src/components/editor/EmojiPickerPopup.tsx:113
msgid "Close emoji picker"
msgstr "Fermer le sélecteur d'emoji"
-#: src/components/player/GlobalPlayer.tsx:242
+#: src/components/player/GlobalPlayer.tsx:262
msgid "Close player"
msgstr "Fermer le lecteur"
-#: src/components/dump/create/DumpCreateModal.tsx:508
+#: src/components/dump/create/DumpCreateModal.tsx:521
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."
-#: src/components/player/GlobalPlayer.tsx:234
+#: src/components/player/GlobalPlayer.tsx:254
msgid "Collapse player"
msgstr "Réduire le lecteur"
-#: src/pages/UserPublicProfile.tsx:1297
+#: src/pages/UserPublicProfile.tsx:1334
msgid "Color scheme"
msgstr "Thème de couleur"
-#: src/components/profile/ChangePasswordModal.tsx:95
-#: src/pages/ResetPassword.tsx:117
+#: src/components/profile/ChangePasswordModal.tsx:109
+#: src/pages/ResetPassword.tsx:128
msgid "Confirm new password"
msgstr "Confirmer le nouveau mot de passe"
-#: src/contexts/ws/WSProvider.tsx:709
+#: src/contexts/ws/WSProvider.tsx:751
msgid "Connection lost — this message may not have been sent"
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
-#: src/pages/UserPublicProfile.tsx:159
+#: src/pages/UserPublicProfile.tsx:167
msgid "Copied!"
msgstr "Copié !"
-#: src/pages/UserPublicProfile.tsx:159
+#: src/pages/UserPublicProfile.tsx:167
msgid "Copy"
msgstr "Copier"
-#: src/components/profile/ChangePasswordModal.tsx:104
+#: src/components/profile/ChangePasswordModal.tsx:118
msgid "Could not change password"
msgstr "Impossible de changer le mot de passe"
-#: src/pages/DumpEdit.tsx:496
-#: src/pages/UserPlaylists.tsx:396
+#: src/utils/shared/api.ts:32
+msgid "Could not connect to the server. Check your connection and try again."
+msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."
+
+#: src/pages/DumpEdit.tsx:530
+#: src/pages/UserPlaylists.tsx:412
msgid "Could not delete"
msgstr "Suppression impossible"
-#: src/components/social/UserListPopover.tsx:138
+#: src/components/social/UserListPopover.tsx:166
msgid "Could not load."
msgstr "Impossible de charger."
-#: src/pages/DumpEdit.tsx:456
+#: src/pages/DumpEdit.tsx:490
msgid "Could not save"
msgstr "Sauvegarde impossible"
-#: src/components/dump/create/DumpCreateModal.tsx:630
+#: src/components/dump/create/DumpCreateModal.tsx:643
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/chat/ChatModal.tsx:621
+#: src/components/chat/ChatModal.tsx:642
msgid "Couldn't load the chat."
msgstr "Impossible de charger le chat."
-#: src/components/playlist/PlaylistCreateForm.tsx:93
+#: src/components/social/FollowButton.tsx:112
+msgid "Couldn't update. Try again."
+msgstr "La mise à jour a échoué. Réessayez."
+
+#: src/components/playlist/PlaylistCreateForm.tsx:112
msgid "Create"
msgstr "Créer"
-#: src/components/playlist/PlaylistCreateForm.tsx:93
+#: src/components/playlist/PlaylistCreateForm.tsx:112
msgid "Create & Add"
msgstr "Créer et ajouter"
#. placeholder {0}: created.items.length
#. placeholder {1}: created.hasMore ? "+" : ""
-#: src/pages/UserPlaylists.tsx:402
+#: src/pages/UserPlaylists.tsx:418
msgid "Created ({0}{1})"
msgstr "Créées ({0}{1})"
-#: src/components/playlist/PlaylistCreateForm.tsx:92
+#: src/components/playlist/PlaylistCreateForm.tsx:111
msgid "Creating…"
msgstr "Création…"
-#: src/components/profile/ChangePasswordModal.tsx:73
+#: src/components/profile/ChangePasswordModal.tsx:87
msgid "Current password"
msgstr "Mot de passe actuel"
-#: src/pages/UserPublicProfile.tsx:1319
+#: src/pages/UserPublicProfile.tsx:1356
msgid "Dark"
msgstr "Sombre"
-#: src/pages/UserPublicProfile.tsx:1332
+#: src/pages/UserPublicProfile.tsx:1369
msgid "Default tab"
msgstr "Onglet par défaut"
-#: src/components/category/CategoryManager.tsx:223
-#: src/components/category/CategoryManager.tsx:224
-#: src/components/chat/ChatMessageItem.tsx:88
-#: src/components/chat/ChatMessageItem.tsx:89
-#: src/components/chat/ChatMessageItem.tsx:187
-#: src/components/comment/CommentNode.tsx:248
-#: src/components/comment/CommentNode.tsx:254
-#: src/components/shared/ConfirmModal.tsx:17
-#: src/pages/PlaylistDetail.tsx:967
+#: src/components/category/CategoryManager.tsx:236
+#: src/components/category/CategoryManager.tsx:237
+#: src/components/chat/ChatMessageItem.tsx:114
+#: src/components/chat/ChatMessageItem.tsx:115
+#: src/components/chat/ChatMessageItem.tsx:213
+#: src/components/comment/CommentNode.tsx:263
+#: src/components/comment/CommentNode.tsx:269
+#: src/components/shared/ConfirmModal.tsx:34
+#: src/pages/PlaylistDetail.tsx:1010
msgid "Delete"
msgstr "Supprimer"
-#: src/components/category/CategoryManager.tsx:240
+#: src/components/category/CategoryManager.tsx:253
msgid "Delete category"
msgstr "Supprimer la catégorie"
#. placeholder {0}: category.name
-#: src/components/category/CategoryManager.tsx:239
+#: src/components/category/CategoryManager.tsx:252
msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible."
-#: src/pages/DumpEdit.tsx:273
-#: src/pages/DumpEdit.tsx:504
+#: src/pages/DumpEdit.tsx:299
+#: src/pages/DumpEdit.tsx:538
msgid "Delete dump"
msgstr "Supprimer la reco"
#. placeholder {0}: res.status
-#: src/pages/DumpEdit.tsx:155
+#: src/pages/DumpEdit.tsx:178
msgid "Delete failed ({0})"
msgstr "Échec de la suppression ({0})"
-#: src/components/playlist/PlaylistCard.tsx:110
-#: src/pages/PlaylistDetail.tsx:795
-#: src/pages/UserPlaylists.tsx:479
+#: src/components/playlist/PlaylistCard.tsx:132
+#: src/pages/PlaylistDetail.tsx:821
+#: src/pages/UserPlaylists.tsx:495
msgid "Delete playlist"
msgstr "Supprimer la collection"
-#: src/components/comment/CommentNode.tsx:253
+#: src/components/comment/CommentNode.tsx:268
msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?"
-#: src/pages/DumpEdit.tsx:272
+#: src/pages/DumpEdit.tsx:298
msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible."
-#: src/components/chat/ChatMessageItem.tsx:186
+#: src/components/chat/ChatMessageItem.tsx:212
msgid "Delete this message?"
msgstr "Supprimer ce message ?"
-#: src/pages/PlaylistDetail.tsx:794
-#: src/pages/UserPlaylists.tsx:478
+#: src/pages/PlaylistDetail.tsx:820
+#: src/pages/UserPlaylists.tsx:494
msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible."
-#: src/components/chat/ReplyRef.tsx:31
+#: src/components/chat/ReplyRef.tsx:49
msgid "deleted message"
msgstr "message supprimé"
-#: src/components/playlist/PlaylistCreateForm.tsx:85
-#: src/pages/PlaylistDetail.tsx:928
+#: src/components/playlist/PlaylistCreateForm.tsx:104
+#: src/pages/PlaylistDetail.tsx:971
msgid "Description (optional)"
msgstr "Description (facultatif)"
-#: src/components/chat/PendingItem.tsx:84
+#: src/components/chat/PendingItem.tsx:100
msgid "Discard"
msgstr "Abandonner"
-#: src/components/chat/ChatModal.tsx:699
+#: src/components/chat/ChatModal.tsx:720
msgid "Disconnected — reconnecting…"
msgstr "Déconnecté — reconnexion…"
-#: src/components/chat/ChatModal.tsx:771
-#: src/components/chat/ChatModal.tsx:772
+#: src/components/chat/ChatModal.tsx:792
+#: src/components/chat/ChatModal.tsx:793
msgid "Dismiss"
msgstr "Fermer"
-#: src/components/dump/create/DumpCreateModal.tsx:569
+#: src/components/dump/create/DumpCreateModal.tsx:582
msgid "Done"
msgstr "Terminé"
#. placeholder {0}: dump.fileName
-#: src/components/dump/FilePreview.tsx:334
+#: src/components/dump/FilePreview.tsx:358
msgid "Download {0}"
msgstr "Télécharger {0}"
-#: src/components/form/FileDropZone.tsx:29
+#: src/components/form/FileDropZone.tsx:55
msgid "Drop a file here"
msgstr "Déposez un fichier ici"
-#: src/components/dump/create/DumpCreateModal.tsx:677
+#: src/components/dump/create/DumpCreateModal.tsx:689
msgid "Drop a file here, or paste one"
msgstr "Déposez un fichier ici, ou collez-en un"
-#: src/pages/DumpEdit.tsx:448
+#: src/pages/DumpEdit.tsx:481
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
-#: src/components/layout/AppHeader.tsx:170
+#: src/components/layout/AppHeader.tsx:201
msgid "Dump"
msgstr "Reco"
-#: src/components/dump/create/DumpCreateModal.tsx:789
+#: src/components/dump/create/DumpCreateModal.tsx:801
msgid "Dump it"
msgstr "Recommander"
-#: src/components/dump/create/DumpCreateModal.tsx:546
+#: src/components/dump/create/DumpCreateModal.tsx:559
msgid "Dumped!"
msgstr "Recommandé !"
-#: src/pages/Search.tsx:186
-#: src/pages/UserDumps.tsx:80
-#: src/pages/UserPublicProfile.tsx:989
+#: src/pages/Search.tsx:197
+#: src/pages/UserDumps.tsx:90
+#: src/pages/UserPublicProfile.tsx:1026
msgid "Dumps"
msgstr "Recos"
#. placeholder {0}: dumps.items.length
#. placeholder {1}: dumps.hasMore ? "+" : ""
-#: src/pages/UserPublicProfile.tsx:1009
+#: src/pages/UserPublicProfile.tsx:1046
msgid "Dumps ({0}{1})"
msgstr "Recos ({0}{1})"
-#: src/pages/Notifications.tsx:434
+#: src/pages/Notifications.tsx:472
msgid "Earlier"
msgstr "Plus tôt"
-#: src/components/chat/ChatMessageItem.tsx:77
-#: src/components/chat/ChatMessageItem.tsx:78
-#: src/components/chat/PendingItem.tsx:77
-#: src/components/comment/CommentNode.tsx:239
-#: src/pages/Dump.tsx:526
-#: src/pages/PlaylistDetail.tsx:658
+#: src/components/chat/ChatMessageItem.tsx:103
+#: src/components/chat/ChatMessageItem.tsx:104
+#: src/components/chat/PendingItem.tsx:93
+#: src/components/comment/CommentNode.tsx:254
+#: src/pages/Dump.tsx:550
+#: src/pages/PlaylistDetail.tsx:684
msgid "Edit"
msgstr "Modifier"
#. placeholder {0}: category.name
-#: src/components/category/CategoryManager.tsx:75
+#: src/components/category/CategoryManager.tsx:84
msgid "Edit {0}"
msgstr "Modifier {0}"
-#: src/pages/UserPublicProfile.tsx:953
+#: src/pages/UserPublicProfile.tsx:990
msgid "Edit description"
msgstr "Modifier la description"
-#: src/pages/UserPublicProfile.tsx:900
+#: src/pages/UserPublicProfile.tsx:937
msgid "Edit email"
msgstr "Modifier l'e-mail"
-#: src/components/dump/create/TitleField.tsx:55
-#: src/pages/Dump.tsx:422
+#: src/components/dump/create/TitleField.tsx:69
+#: src/pages/Dump.tsx:448
msgid "Edit title"
msgstr "Modifier le titre"
-#: src/components/chat/ChatMessageItem.tsx:101
+#: src/components/chat/ChatMessageItem.tsx:127
msgid "edited"
msgstr "modifié"
#. placeholder {0}: relativeTime(comment.updatedAt)
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
-#: src/components/chat/ChatMessageItem.tsx:102
-#: src/components/comment/CommentNode.tsx:188
-#: src/pages/Dump.tsx:465
-#: src/pages/PlaylistDetail.tsx:697
+#: src/components/chat/ChatMessageItem.tsx:128
+#: src/components/comment/CommentNode.tsx:203
+#: src/pages/Dump.tsx:491
+#: src/pages/PlaylistDetail.tsx:723
msgid "edited {0}"
msgstr "modifié {0}"
#. placeholder {0}: comment.updatedAt.toLocaleString()
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
-#: src/components/chat/ChatMessageItem.tsx:98
-#: src/components/comment/CommentNode.tsx:186
-#: src/pages/Dump.tsx:463
-#: src/pages/PlaylistDetail.tsx:694
+#: src/components/chat/ChatMessageItem.tsx:124
+#: src/components/comment/CommentNode.tsx:201
+#: src/pages/Dump.tsx:489
+#: src/pages/PlaylistDetail.tsx:720
msgid "Edited {0}"
msgstr "Modifié le {0}"
-#: src/pages/DumpEdit.tsx:218
+#: src/pages/DumpEdit.tsx:242
msgid "Editing"
msgstr "Modification"
#. placeholder {0}: state.dump.title
-#: src/pages/DumpEdit.tsx:55
+#: src/pages/DumpEdit.tsx:72
msgid "Editing {0}"
msgstr "Édition de {0}"
-#: src/pages/UserRegister.tsx:134
+#: src/pages/UserRegister.tsx:152
msgid "Email address"
msgstr "Adresse e-mail"
-#: src/pages/Search.tsx:219
+#: src/pages/Search.tsx:230
msgid "Enter a query to search."
msgstr "Saisissez une recherche."
-#: src/components/player/GlobalPlayer.tsx:234
+#: src/components/player/GlobalPlayer.tsx:254
msgid "Expand player"
msgstr "Agrandir le lecteur"
-#: src/components/category/CategoryManager.tsx:233
+#: src/components/category/CategoryManager.tsx:246
msgid "Failed to create category"
msgstr "Échec de la création de la catégorie"
-#: src/components/playlist/PlaylistCreateForm.tsx:90
+#: src/components/playlist/PlaylistCreateForm.tsx:109
msgid "Failed to create playlist"
msgstr "Impossible de créer la collection"
-#: src/components/category/CategoryManager.tsx:156
+#: src/components/category/CategoryManager.tsx:169
msgid "Failed to delete"
msgstr "Suppression échouée"
-#: src/pages/UserPublicProfile.tsx:104
-#: src/pages/UserPublicProfile.tsx:107
-#: src/pages/UserPublicProfile.tsx:168
+#: src/pages/UserPublicProfile.tsx:111
+#: src/pages/UserPublicProfile.tsx:114
+#: src/pages/UserPublicProfile.tsx:176
msgid "Failed to generate invite"
msgstr "Impossible de générer une invitation"
-#: src/components/feed/DumpFeed.tsx:59
-#: src/components/feed/FollowedFeed.tsx:92
-#: src/components/feed/JournalFeed.tsx:33
-#: src/pages/Notifications.tsx:408
-#: src/pages/UserPublicProfile.tsx:1111
-#: src/pages/UserPublicProfile.tsx:1157
-#: src/pages/UserPublicProfile.tsx:1202
+#: src/components/feed/DumpFeed.tsx:87
+#: src/components/feed/FollowedFeed.tsx:105
+#: src/components/feed/JournalFeed.tsx:45
+#: src/pages/Notifications.tsx:446
+#: src/pages/UserPublicProfile.tsx:1148
+#: src/pages/UserPublicProfile.tsx:1194
+#: src/pages/UserPublicProfile.tsx:1239
msgid "Failed to load"
msgstr "Chargement échoué"
-#: src/components/dump/create/DumpCreateModal.tsx:578
+#: src/components/dump/create/DumpCreateModal.tsx:591
msgid "Failed to post"
msgstr "Publication échouée"
-#: src/components/comment/CommentThread.tsx:80
+#: src/components/comment/CommentThread.tsx:101
msgid "Failed to post comment"
msgstr "Impossible de publier le commentaire"
-#: src/components/comment/CommentNode.tsx:274
+#: src/components/comment/CommentNode.tsx:289
msgid "Failed to post reply"
msgstr "Impossible de publier la réponse"
-#: src/pages/PlaylistDetail.tsx:959
-#: src/pages/UserPublicProfile.tsx:1826
-#: src/pages/UserPublicProfile.tsx:1900
+#: src/pages/PlaylistDetail.tsx:1002
+#: src/pages/UserPublicProfile.tsx:1866
+#: src/pages/UserPublicProfile.tsx:1944
msgid "Failed to save"
msgstr "Enregistrement échoué"
-#: src/components/category/CategoryManager.tsx:234
+#: src/components/category/CategoryManager.tsx:247
msgid "Failed to save category"
msgstr "Échec de l’enregistrement de la catégorie"
-#: src/components/comment/CommentNode.tsx:201
+#: src/components/comment/CommentNode.tsx:216
msgid "Failed to save edit"
msgstr "Impossible d'enregistrer la modification"
-#: src/pages/UserPublicProfile.tsx:911
+#: src/pages/UserPublicProfile.tsx:948
msgid "Failed to update avatar"
msgstr "Impossible de mettre à jour l'avatar"
-#: src/pages/UserPublicProfile.tsx:1764
+#: src/pages/UserPublicProfile.tsx:1800
msgid "Failed to update role"
msgstr "Erreur lors de la mise à jour du rôle"
-#: src/pages/UserPublicProfile.tsx:1327
+#: src/pages/UserPublicProfile.tsx:1364
msgid "Feeds"
msgstr "Flux"
-#: src/components/dump/create/DumpCreateModal.tsx:617
+#: src/components/dump/create/DumpCreateModal.tsx:630
msgid "Fetching preview…"
msgstr "Récupération de l'aperçu…"
-#: src/components/dump/create/DumpCreateModal.tsx:404
-msgid "File too large (max 50 MB)."
-msgstr "Fichier trop volumineux (max 50 Mo)."
+#: src/components/dump/create/DumpCreateModal.tsx:420
+msgid "File too large (max {max})."
+msgstr "Fichier trop volumineux (max {max})."
-#: src/components/social/FollowButton.tsx:37
-#: src/components/social/FollowButton.tsx:64
+#: src/components/social/FollowButton.tsx:62
+#: src/components/social/FollowButton.tsx:102
msgid "Follow"
msgstr "Suivre"
-#: src/components/social/FollowButton.tsx:35
+#: src/components/social/FollowButton.tsx:60
msgid "Follow {targetUsername}"
msgstr "Suivre {targetUsername}"
-#: src/components/social/FollowButton.tsx:62
+#: src/components/social/FollowButton.tsx:100
msgid "Follow playlist"
msgstr "Suivre la collection"
-#: src/components/feed/FollowedFeed.tsx:378
+#: src/components/feed/FollowedFeed.tsx:388
msgid "Follow some public playlists to see their dumps here."
msgstr "Suivez des collections publiques pour voir leurs recos ici."
-#: src/components/feed/FollowedFeed.tsx:364
+#: src/components/feed/FollowedFeed.tsx:375
msgid "Follow some users to see their dumps here."
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
-#: src/components/feed/FeedTabBar.tsx:40
-#: src/pages/UserPublicProfile.tsx:991
-#: src/pages/UserPublicProfile.tsx:1361
+#: src/components/feed/FeedTabBar.tsx:48
+#: src/pages/UserPublicProfile.tsx:1028
+#: src/pages/UserPublicProfile.tsx:1398
msgid "Followed"
msgstr "Suivi"
#. placeholder {0}: followed.items.length
#. placeholder {1}: followed.hasMore ? "+" : ""
-#: src/pages/UserPlaylists.tsx:444
+#: src/pages/UserPlaylists.tsx:460
msgid "Followed ({0}{1})"
msgstr "Suivies ({0}{1})"
-#: src/pages/UserPublicProfile.tsx:1146
+#: src/pages/UserPublicProfile.tsx:1183
msgid "Followed playlists"
msgstr "Collections suivies"
-#: src/components/social/FollowButton.tsx:37
-#: src/components/social/FollowButton.tsx:64
-#: src/pages/UserPublicProfile.tsx:1100
+#: src/components/social/FollowButton.tsx:62
+#: src/components/social/FollowButton.tsx:102
+#: src/pages/UserPublicProfile.tsx:1137
msgid "Following"
msgstr "Abonné"
-#: src/pages/UserLogin.tsx:87
+#: src/pages/UserLogin.tsx:97
msgid "Forgot password?"
msgstr "Mot de passe oublié ?"
-#: src/components/feed/FollowedFeed.tsx:347
+#: src/components/feed/FollowedFeed.tsx:359
msgid "From people"
msgstr "De personnes"
-#: src/components/feed/FollowedFeed.tsx:348
+#: src/components/feed/FollowedFeed.tsx:360
msgid "From playlists"
msgstr "De collections"
-#: src/components/player/MediaPlayer.tsx:353
+#: src/components/player/MediaPlayer.tsx:373
msgid "Fullscreen"
msgstr "Plein écran"
-#: src/pages/NotFound.tsx:16
+#: src/pages/NotFound.tsx:24
msgid "Go home"
msgstr "Accueil"
-#: src/pages/ResetPassword.tsx:82
+#: src/pages/ResetPassword.tsx:93
msgid "Go to login"
msgstr "Aller à la connexion"
-#: src/components/feed/FeedTabBar.tsx:36
-#: src/pages/UserPublicProfile.tsx:1340
+#: src/components/feed/FeedTabBar.tsx:44
+#: src/pages/UserPublicProfile.tsx:1377
msgid "Hot"
msgstr "Tendances"
-#: src/pages/UserLogin.tsx:122
+#: src/pages/UserLogin.tsx:136
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
-#: src/utils/chat/message.ts:24
+#: src/utils/chat/message.ts:43
msgid "image"
msgstr "image"
-#: src/pages/Dump.tsx:560
+#: src/pages/Dump.tsx:582
msgid "In collections"
msgstr "Dans les collections"
-#: src/components/editor/TextEditor.tsx:503
-#: src/components/editor/TextEditor.tsx:505
+#: src/components/editor/TextEditor.tsx:544
+#: src/components/editor/TextEditor.tsx:546
msgid "Insert emoji"
msgstr "Insérer un emoji"
-#: src/pages/UserRegister.tsx:97
+#: src/pages/UserRegister.tsx:117
msgid "Invalid invite"
msgstr "Invitation invalide"
-#: src/pages/ResetPassword.tsx:50
+#: src/pages/ResetPassword.tsx:61
msgid "Invalid link"
msgstr "Lien invalide"
-#: src/pages/UserPublicProfile.tsx:142
-#: src/pages/UserPublicProfile.tsx:149
+#: src/pages/UserPublicProfile.tsx:150
+#: src/pages/UserPublicProfile.tsx:157
msgid "Invite link"
msgstr "Lien d'invitation"
-#: src/pages/UserPublicProfile.tsx:865
+#: src/pages/UserPublicProfile.tsx:902
msgid "invited by"
msgstr "invité par"
-#: src/pages/UserPublicProfile.tsx:992
-#: src/pages/UserPublicProfile.tsx:1191
+#: src/pages/UserPublicProfile.tsx:1029
+#: src/pages/UserPublicProfile.tsx:1228
msgid "Invitees"
msgstr "Invités"
-#: src/components/feed/FeedTabBar.tsx:38
-#: src/pages/UserPublicProfile.tsx:1354
+#: src/components/feed/FeedTabBar.tsx:46
+#: src/pages/UserPublicProfile.tsx:1391
msgid "Journal"
msgstr "Journal"
-#: src/components/dump/create/DumpCreateModal.tsx:515
+#: src/components/dump/create/DumpCreateModal.tsx:528
msgid "Keep editing"
msgstr "Continuer l'édition"
-#: src/pages/UserPublicProfile.tsx:1312
+#: src/pages/UserPublicProfile.tsx:1349
msgid "Light"
msgstr "Clair"
-#: src/components/social/LikeButton.tsx:31
+#: src/components/social/LikeButton.tsx:54
msgid "Like"
msgstr "Aimer"
-#: src/components/dump/create/DumpCreateModal.tsx:592
+#: src/components/dump/create/DumpCreateModal.tsx:605
msgid "Link"
msgstr "Lien"
-#: src/components/dump/create/StepIndicator.tsx:12
+#: src/components/dump/create/StepIndicator.tsx:19
msgid "Link or file"
msgstr "Lien ou fichier"
-#: src/contexts/ws/WSProvider.tsx:715
+#: src/contexts/ws/WSProvider.tsx:757
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
-#: src/components/layout/AppHeader.tsx:205
+#: src/components/layout/AppHeader.tsx:236
msgid "Live updates unavailable."
msgstr "Mises à jour en direct indisponibles."
-#: src/components/social/UserListPopover.tsx:234
-#: src/pages/Notifications.tsx:481
-#: src/pages/UserPublicProfile.tsx:1137
+#: src/components/social/UserListPopover.tsx:262
+#: src/pages/Notifications.tsx:519
+#: src/pages/UserPublicProfile.tsx:1174
msgid "Load more"
msgstr "Charger plus"
-#: src/components/chat/ChatModal.tsx:720
+#: src/components/chat/ChatModal.tsx:741
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
-#: src/pages/Dump.tsx:263
-#: src/pages/DumpEdit.tsx:169
+#: src/pages/Dump.tsx:290
+#: src/pages/DumpEdit.tsx:193
msgid "Loading dump…"
msgstr "Chargement de la reco…"
-#: src/components/chat/ChatModal.tsx:613
+#: src/components/chat/ChatModal.tsx:634
msgid "Loading messages…"
msgstr "Chargement des messages…"
-#: src/components/feed/DumpFeed.tsx:87
-#: src/components/feed/FollowedFeed.tsx:120
-#: src/components/feed/JournalFeed.tsx:75
-#: src/pages/Search.tsx:256
-#: src/pages/UserDumps.tsx:121
-#: src/pages/UserPlaylists.tsx:431
-#: src/pages/UserPlaylists.tsx:466
-#: src/pages/UserUpvoted.tsx:197
+#: src/components/feed/DumpFeed.tsx:115
+#: src/components/feed/FollowedFeed.tsx:131
+#: src/components/feed/JournalFeed.tsx:86
+#: src/pages/Search.tsx:267
+#: src/pages/UserDumps.tsx:131
+#: src/pages/UserPlaylists.tsx:447
+#: src/pages/UserPlaylists.tsx:482
+#: src/pages/UserUpvoted.tsx:222
msgid "Loading more…"
msgstr "Chargement…"
-#: src/pages/PlaylistDetail.tsx:587
+#: src/pages/PlaylistDetail.tsx:614
msgid "Loading playlist…"
msgstr "Chargement de la collection…"
-#: src/pages/UserPublicProfile.tsx:799
+#: src/pages/UserPublicProfile.tsx:836
msgid "Loading profile…"
msgstr "Chargement du profil…"
-#: src/components/category/CategoryManager.tsx:51
-#: src/components/chat/ChatModal.tsx:719
-#: src/components/editor/EmojiPickerPopup.tsx:102
-#: src/components/feed/DumpFeed.tsx:55
-#: src/components/feed/FollowedFeed.tsx:87
-#: src/components/feed/JournalFeed.tsx:29
-#: src/components/playlist/PlaylistMembershipPanel.tsx:28
-#: src/components/social/UserListPopover.tsx:195
-#: src/components/social/UserListPopover.tsx:233
-#: src/pages/Notifications.tsx:404
-#: src/pages/Notifications.tsx:480
-#: src/pages/UserDumps.tsx:54
-#: src/pages/UserPlaylists.tsx:352
-#: src/pages/UserPublicProfile.tsx:1105
-#: src/pages/UserPublicProfile.tsx:1136
-#: src/pages/UserPublicProfile.tsx:1151
-#: src/pages/UserPublicProfile.tsx:1196
-#: src/pages/UserUpvoted.tsx:132
+#: src/components/category/CategoryManager.tsx:60
+#: src/components/chat/ChatModal.tsx:740
+#: src/components/editor/EmojiPickerPopup.tsx:125
+#: src/components/feed/DumpFeed.tsx:83
+#: src/components/feed/FollowedFeed.tsx:100
+#: src/components/feed/JournalFeed.tsx:41
+#: src/components/playlist/PlaylistMembershipPanel.tsx:45
+#: src/components/social/UserListPopover.tsx:223
+#: src/components/social/UserListPopover.tsx:261
+#: src/pages/Notifications.tsx:442
+#: src/pages/Notifications.tsx:518
+#: src/pages/UserDumps.tsx:64
+#: src/pages/UserPlaylists.tsx:368
+#: src/pages/UserPublicProfile.tsx:1142
+#: src/pages/UserPublicProfile.tsx:1173
+#: src/pages/UserPublicProfile.tsx:1188
+#: src/pages/UserPublicProfile.tsx:1233
+#: src/pages/UserUpvoted.tsx:157
msgid "Loading…"
msgstr "Chargement…"
-#: src/components/layout/AppHeader.tsx:178
-#: src/pages/UserLogin.tsx:31
-#: src/pages/UserLogin.tsx:57
-#: src/pages/UserLogin.tsx:76
+#: src/components/layout/AppHeader.tsx:209
+#: src/pages/UserLogin.tsx:41
+#: src/pages/UserLogin.tsx:67
+#: src/pages/UserLogin.tsx:86
msgid "Log in"
msgstr "Se connecter"
-#: src/components/social/LikeButton.tsx:32
+#: src/components/social/LikeButton.tsx:55
msgid "Log in to like"
msgstr "Se connecter pour aimer"
-#: src/components/social/VoteButton.tsx:29
+#: src/components/social/VoteButton.tsx:53
msgid "Log in to vote"
msgstr "Se connecter pour voter"
-#: src/pages/UserPublicProfile.tsx:820
-#: src/pages/UserPublicProfile.tsx:924
+#: src/pages/UserPublicProfile.tsx:857
+#: src/pages/UserPublicProfile.tsx:961
msgid "Log out"
msgstr "Se déconnecter"
-#: src/pages/UserLogin.tsx:75
+#: src/pages/UserLogin.tsx:85
msgid "Logging in…"
msgstr "Connexion…"
-#: src/pages/UserLogin.tsx:62
+#: src/pages/UserLogin.tsx:72
msgid "Login failed"
msgstr "Connexion échouée"
-#: src/pages/UserPublicProfile.tsx:997
+#: src/pages/UserPublicProfile.tsx:1034
msgid "Manage"
msgstr "Administration"
-#: src/components/form/FileDropZone.tsx:140
-msgid "Max 50 MB"
-msgstr "Max 50 Mo"
+#: src/components/form/FileDropZone.tsx:169
+msgid "Max {limit}"
+msgstr "Max {limit}"
-#: src/pages/UserPublicProfile.tsx:1693
+#: src/pages/UserPublicProfile.tsx:1725
msgid "Moderator"
msgstr "Modérateur"
-#: src/components/player/MediaPlayer.tsx:326
+#: src/components/player/MediaPlayer.tsx:346
msgid "Mute"
msgstr "Couper le son"
-#: src/components/category/CategoryManager.tsx:179
-#: src/components/category/CategoryManager.tsx:180
+#: src/components/category/CategoryManager.tsx:192
+#: src/components/category/CategoryManager.tsx:193
msgid "Name"
msgstr "Nom"
-#: src/pages/Notifications.tsx:397
+#: src/pages/Notifications.tsx:435
msgid "new"
msgstr "nouveau"
-#: src/components/chat/ChatModal.tsx:648
-#: src/components/feed/FeedTabBar.tsx:37
-#: src/pages/UserPublicProfile.tsx:1347
+#: src/components/chat/ChatModal.tsx:669
+#: src/components/feed/FeedTabBar.tsx:45
+#: src/pages/UserPublicProfile.tsx:1384
msgid "New"
msgstr "Nouveau"
-#: src/components/dump/create/DumpCreateModal.tsx:487
+#: src/components/dump/create/DumpCreateModal.tsx:500
+#: src/components/layout/DumpFab.tsx:66
#: src/components/layout/DumpFab.tsx:67
-#: src/components/layout/DumpFab.tsx:68
-#: src/pages/UserDumps.tsx:88
-#: src/pages/UserPublicProfile.tsx:1466
+#: src/pages/UserDumps.tsx:98
+#: src/pages/UserPublicProfile.tsx:1505
msgid "New dump"
msgstr "Nouvelle reco"
-#: src/components/profile/ChangePasswordModal.tsx:81
-#: src/pages/ResetPassword.tsx:102
+#: src/components/profile/ChangePasswordModal.tsx:95
+#: src/pages/ResetPassword.tsx:113
msgid "New password"
msgstr "Nouveau mot de passe"
-#: src/components/playlist/NewPlaylistButton.tsx:33
-#: src/components/playlist/NewPlaylistButton.tsx:40
+#: src/components/playlist/NewPlaylistButton.tsx:49
+#: src/components/playlist/NewPlaylistButton.tsx:56
msgid "New playlist"
msgstr "Nouvelle collection"
-#: src/components/dump/create/DumpCreateModal.tsx:788
+#: src/components/dump/create/DumpCreateModal.tsx:800
msgid "Next →"
msgstr "Suivant →"
-#: src/components/player/GlobalPlayer.tsx:224
+#: src/components/player/GlobalPlayer.tsx:244
msgid "Next track"
msgstr "Piste suivante"
-#: src/pages/PlaylistDetail.tsx:717
+#: src/pages/PlaylistDetail.tsx:743
msgid "No dumps in this playlist yet."
msgstr "Aucune reco dans cette collection pour l'instant."
-#: src/pages/Search.tsx:236
+#: src/pages/Search.tsx:247
msgid "No dumps match \"{q}\"."
msgstr "Aucune reco ne correspond à « {q} »."
-#: src/components/feed/DumpFeed.tsx:63
-#: src/components/feed/JournalFeed.tsx:37
+#: src/components/feed/DumpFeed.tsx:91
+#: src/components/feed/JournalFeed.tsx:49
msgid "No dumps yet. Be the first!"
msgstr "Pas encore de recos. Soyez le premier !"
-#: src/components/editor/EmojiPickerPopup.tsx:105
+#: src/components/editor/EmojiPickerPopup.tsx:128
msgid "No emoji found."
msgstr "Aucun emoji trouvé."
-#: src/pages/UserPlaylists.tsx:453
-#: src/pages/UserPublicProfile.tsx:1164
+#: src/pages/UserPlaylists.tsx:469
+#: src/pages/UserPublicProfile.tsx:1201
msgid "No followed playlists yet."
msgstr "Pas encore de collections suivies."
-#: src/pages/UserPublicProfile.tsx:1209
+#: src/pages/UserPublicProfile.tsx:1246
msgid "No invitees yet."
msgstr "Aucun invité pour le moment."
-#: src/components/chat/ChatModal.tsx:632
+#: src/components/chat/ChatModal.tsx:653
msgid "No messages yet. Say hello!"
msgstr "Aucun message pour l'instant. Dites bonjour !"
-#: src/components/social/UserListPopover.tsx:203
+#: src/components/social/UserListPopover.tsx:231
msgid "No one yet."
msgstr "Personne pour le moment."
-#: src/pages/Search.tsx:295
+#: src/pages/Search.tsx:306
msgid "No playlists match \"{q}\"."
msgstr "Aucune collection ne correspond à « {q} »."
-#: src/components/playlist/PlaylistMembershipPanel.tsx:34
-#: src/pages/UserPlaylists.tsx:411
-#: src/pages/UserPublicProfile.tsx:1071
+#: src/components/playlist/PlaylistMembershipPanel.tsx:51
+#: src/pages/UserPlaylists.tsx:427
+#: src/pages/UserPublicProfile.tsx:1108
msgid "No playlists yet."
msgstr "Pas encore de collections."
-#: src/contexts/ws/WSProvider.tsx:949
+#: src/contexts/ws/WSProvider.tsx:926
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é"
-#: src/pages/Search.tsx:269
+#: src/pages/Search.tsx:280
msgid "No users match \"{q}\"."
msgstr "Aucun utilisateur ne correspond à « {q} »."
-#: src/pages/UserPublicProfile.tsx:1118
+#: src/pages/UserPublicProfile.tsx:1155
msgid "Not following anyone yet."
msgstr "Aucun abonnement pour le moment."
-#: src/pages/Notifications.tsx:415
-#: src/pages/UserDumps.tsx:99
-#: src/pages/UserPublicProfile.tsx:1477
-#: src/pages/UserPublicProfile.tsx:1605
-#: src/pages/UserUpvoted.tsx:166
+#: src/pages/Notifications.tsx:453
+#: src/pages/UserDumps.tsx:109
+#: src/pages/UserPublicProfile.tsx:1516
+#: src/pages/UserPublicProfile.tsx:1639
+#: src/pages/UserUpvoted.tsx:191
msgid "Nothing here yet."
msgstr "Rien ici pour l'instant."
-#: src/components/layout/NotificationBell.tsx:42
-#: src/pages/Notifications.tsx:267
-#: src/pages/Notifications.tsx:393
-#: src/pages/UserPublicProfile.tsx:1369
+#: src/components/layout/NotificationBell.tsx:54
+#: src/pages/Notifications.tsx:294
+#: src/pages/Notifications.tsx:431
+#: src/pages/UserPublicProfile.tsx:1406
msgid "Notifications"
msgstr "Notifications"
-#: src/components/layout/NotificationBell.tsx:41
+#: src/components/layout/NotificationBell.tsx:53
msgid "Notifications ({unreadNotificationCount} unread)"
msgstr "Notifications ({unreadNotificationCount} non lues)"
-#: src/pages/UserPublicProfile.tsx:1393
+#: src/pages/UserPublicProfile.tsx:1430
msgid "Off"
msgstr "Désactivés"
-#: src/pages/UserPublicProfile.tsx:1386
+#: src/pages/UserPublicProfile.tsx:1423
msgid "On"
msgstr "Activés"
-#: src/components/editor/TextEditor.tsx:317
+#: src/components/editor/TextEditor.tsx:359
msgid "Only images can be dropped here."
msgstr "Seules les images peuvent être déposées ici."
+#: src/pages/DumpEdit.tsx:103
+msgid "Only its poster or a moderator can edit this dump."
+msgstr "Seuls son auteur et les modérateurs peuvent modifier ce dump."
+
#. placeholder {0}: richContent.siteName
-#: src/components/dump/RichContentCard.tsx:101
+#: src/components/dump/RichContentCard.tsx:122
msgid "Open on {0}"
msgstr "Ouvrir sur {0}"
-#: src/components/dump/RichContentCard.tsx:102
+#: src/components/dump/RichContentCard.tsx:123
msgid "Open source"
msgstr "Ouvrir la source"
-#: src/components/form/FileDropZone.tsx:134
+#: src/components/form/FileDropZone.tsx:163
msgid "or <0>browse files0>"
msgstr "ou <0>parcourir les fichiers0>"
-#: src/pages/NotFound.tsx:8
+#: src/pages/NotFound.tsx:16
msgid "Page not found"
msgstr "Page introuvable"
-#: src/pages/UserLogin.tsx:72
-#: src/pages/UserPublicProfile.tsx:1229
+#: src/pages/UserLogin.tsx:82
+#: src/pages/UserPublicProfile.tsx:1266
msgid "Password"
msgstr "Mot de passe"
#. placeholder {0}: VALIDATION.PASSWORD_MIN
-#: src/pages/UserRegister.tsx:140
+#: src/pages/UserRegister.tsx:158
msgid "Password (min. {0} characters)"
msgstr "Mot de passe (min. {0} caractères)"
-#: src/components/profile/ChangePasswordModal.tsx:58
+#: src/components/profile/ChangePasswordModal.tsx:72
msgid "Password changed successfully."
msgstr "Mot de passe modifié avec succès."
-#: src/pages/ResetPassword.tsx:72
+#: src/pages/ResetPassword.tsx:83
msgid "Password updated"
msgstr "Mot de passe mis à jour"
-#: src/components/profile/ChangePasswordModal.tsx:101
-#: src/pages/ResetPassword.tsx:122
+#: src/components/profile/ChangePasswordModal.tsx:115
+#: src/pages/ResetPassword.tsx:133
msgid "Passwords do not match"
msgstr "Les mots de passe ne correspondent pas"
-#: src/components/dump/create/DumpCreateModal.tsx:610
+#: src/components/dump/create/DumpCreateModal.tsx:623
msgid "Paste a link…"
msgstr "Collez un lien…"
-#: src/components/dump/FilePreview.tsx:119
-#: src/components/dump/FilePreview.tsx:286
-#: src/components/dump/RichContentCard.tsx:83
-#: src/components/player/MediaPlayer.tsx:310
+#: src/components/dump/FilePreview.tsx:134
+#: src/components/dump/FilePreview.tsx:310
+#: src/components/dump/RichContentCard.tsx:103
+#: src/components/player/MediaPlayer.tsx:330
msgid "Pause"
msgstr "Pause"
-#: src/components/dump/create/DumpCreateModal.tsx:527
+#: src/components/dump/create/DumpCreateModal.tsx:540
msgid "Picked up where you left off."
msgstr "Reprise de votre brouillon."
-#: src/components/dump/FilePreview.tsx:119
-#: src/components/dump/FilePreview.tsx:230
-#: src/components/dump/FilePreview.tsx:251
-#: src/components/dump/FilePreview.tsx:286
-#: src/components/dump/RichContentCard.tsx:83
-#: src/components/dump/RichContentCard.tsx:129
-#: src/components/player/MediaPlayer.tsx:310
+#: src/components/dump/FilePreview.tsx:134
+#: src/components/dump/FilePreview.tsx:253
+#: src/components/dump/FilePreview.tsx:275
+#: src/components/dump/FilePreview.tsx:310
+#: src/components/dump/RichContentCard.tsx:103
+#: src/components/dump/RichContentCard.tsx:150
+#: src/components/player/MediaPlayer.tsx:330
msgid "Play"
msgstr "Lecture"
-#: src/pages/PlaylistDetail.tsx:912
+#: src/pages/PlaylistDetail.tsx:955
msgid "Playlist title"
msgstr "Titre de la collection"
-#: src/components/dump/create/StepIndicator.tsx:14
-#: src/components/layout/AppHeader.tsx:151
-#: src/components/layout/UserMenu.tsx:62
-#: src/pages/Search.tsx:189
-#: src/pages/UserPlaylists.tsx:378
-#: src/pages/UserPublicProfile.tsx:990
+#: src/components/dump/create/StepIndicator.tsx:21
+#: src/components/layout/AppHeader.tsx:182
+#: src/components/layout/UserMenu.tsx:78
+#: src/pages/Search.tsx:200
+#: src/pages/UserPlaylists.tsx:394
+#: src/pages/UserPublicProfile.tsx:1027
msgid "Playlists"
msgstr "Collections"
#. placeholder {0}: playlists.items.length
#. placeholder {1}: playlists.hasMore ? "+" : ""
-#: src/pages/UserPublicProfile.tsx:1040
+#: src/pages/UserPublicProfile.tsx:1077
msgid "Playlists ({0}{1})"
msgstr "Collections ({0}{1})"
-#: src/components/comment/CommentThread.tsx:78
+#: src/components/comment/CommentThread.tsx:99
msgid "Post comment"
msgstr "Publier le commentaire"
-#: src/components/comment/CommentNode.tsx:272
+#: src/components/comment/CommentNode.tsx:287
msgid "Post reply"
msgstr "Publier la réponse"
-#: src/components/comment/CommentNode.tsx:273
-#: src/components/comment/CommentThread.tsx:79
-#: src/components/dump/create/DumpCreateModal.tsx:782
+#: src/components/comment/CommentNode.tsx:288
+#: src/components/comment/CommentThread.tsx:100
+#: src/components/dump/create/DumpCreateModal.tsx:794
msgid "Posting…"
msgstr "Publication…"
-#: src/components/player/GlobalPlayer.tsx:208
+#: src/components/player/GlobalPlayer.tsx:228
msgid "Previous track"
msgstr "Piste précédente"
-#: src/components/dump/DumpCard.tsx:125
-#: src/components/dump/JournalCard.tsx:129
-#: src/components/playlist/PlaylistCard.tsx:74
-#: src/components/playlist/PlaylistMembershipPanel.tsx:64
-#: src/pages/Dump.tsx:471
-#: src/pages/PlaylistDetail.tsx:677
+#: src/components/dump/DumpCard.tsx:148
+#: src/components/dump/JournalCard.tsx:142
+#: src/components/playlist/PlaylistCard.tsx:95
+#: src/components/playlist/PlaylistMembershipPanel.tsx:81
+#: src/pages/Dump.tsx:497
+#: src/pages/PlaylistDetail.tsx:703
msgid "private"
msgstr "privé"
-#: src/components/form/SegmentedField.tsx:81
-#: src/pages/PlaylistDetail.tsx:953
+#: src/components/form/SegmentedField.tsx:100
+#: src/pages/PlaylistDetail.tsx:996
msgid "Private"
msgstr "Privé"
-#: src/components/playlist/PlaylistCard.tsx:73
-#: src/pages/PlaylistDetail.tsx:676
+#: src/components/playlist/PlaylistCard.tsx:94
+#: src/pages/PlaylistDetail.tsx:702
msgid "public"
msgstr "public"
-#: src/components/form/SegmentedField.tsx:80
-#: src/pages/PlaylistDetail.tsx:946
+#: src/components/form/SegmentedField.tsx:99
+#: src/pages/PlaylistDetail.tsx:989
msgid "Public"
msgstr "Public"
-#: src/pages/DumpEdit.tsx:251
+#: src/pages/DumpEdit.tsx:275
msgid "Refresh metadata"
msgstr "Actualiser les métadonnées"
-#: src/components/player/GlobalPlayer.tsx:304
+#: src/components/player/GlobalPlayer.tsx:324
msgid "Refreshing stream…"
msgstr "Actualisation du flux…"
-#: src/pages/DumpEdit.tsx:250
+#: src/pages/DumpEdit.tsx:274
msgid "Refreshing…"
msgstr "Actualisation…"
-#: src/pages/UserRegister.tsx:34
-#: src/pages/UserRegister.tsx:109
-#: src/pages/UserRegister.tsx:151
+#: src/pages/UserRegister.tsx:45
+#: src/pages/UserRegister.tsx:129
+#: src/pages/UserRegister.tsx:169
msgid "Register"
msgstr "S'inscrire"
-#: src/pages/UserRegister.tsx:150
+#: src/pages/UserRegister.tsx:168
msgid "Registering…"
msgstr "Inscription…"
-#: src/pages/UserRegister.tsx:114
+#: src/pages/UserRegister.tsx:134
msgid "Registration failed"
msgstr "Inscription échouée"
-#: src/pages/Dump.tsx:538
+#: src/pages/Dump.tsx:561
msgid "Related"
msgstr "Connexe"
-#: src/components/form/FileDropZone.tsx:110
+#: src/components/form/FileDropZone.tsx:139
msgid "Remove file"
msgstr "Supprimer le fichier"
-#: src/pages/PlaylistDetail.tsx:772
+#: src/pages/PlaylistDetail.tsx:798
msgid "Remove from playlist"
msgstr "Retirer de la collection"
-#: src/components/social/LikeButton.tsx:31
+#: src/components/social/LikeButton.tsx:54
msgid "Remove like"
msgstr "Retirer le j'aime"
-#: src/components/social/VoteButton.tsx:28
+#: src/components/social/VoteButton.tsx:52
msgid "Remove vote"
msgstr "Retirer le vote"
-#: src/pages/DumpEdit.tsx:440
+#: src/pages/DumpEdit.tsx:473
msgid "Replace file"
msgstr "Remplacer le fichier"
-#: src/components/chat/ChatMessageItem.tsx:67
-#: src/components/chat/ChatMessageItem.tsx:68
-#: src/components/comment/CommentNode.tsx:227
+#: src/components/chat/ChatMessageItem.tsx:93
+#: src/components/chat/ChatMessageItem.tsx:94
+#: src/components/comment/CommentNode.tsx:242
msgid "Reply"
msgstr "Répondre"
-#: src/components/chat/ChatModal.tsx:783
+#: src/components/chat/ChatModal.tsx:804
msgid "Replying to"
msgstr "En réponse à"
-#: src/pages/UserLogin.tsx:134
+#: src/pages/UserLogin.tsx:148
msgid "Request failed"
msgstr "Échec de la demande"
-#: src/pages/ResetPassword.tsx:98
+#: src/pages/ResetPassword.tsx:109
msgid "Reset failed"
msgstr "Échec de la réinitialisation"
-#: src/pages/ResetPassword.tsx:23
+#: src/pages/ResetPassword.tsx:34
msgid "Reset password"
msgstr "Réinitialiser le mot de passe"
-#: src/pages/DumpEdit.tsx:416
-#: src/pages/DumpEdit.tsx:470
+#: src/pages/DumpEdit.tsx:449
+#: src/pages/DumpEdit.tsx:504
msgid "Reset to default"
msgstr "Réinitialiser par défaut"
-#: src/components/dump/create/TitleField.tsx:66
-#: src/components/dump/create/TitleField.tsx:67
+#: src/components/dump/create/TitleField.tsx:80
+#: src/components/dump/create/TitleField.tsx:81
msgid "Reset to the suggested title"
msgstr "Rétablir le titre suggéré"
-#: src/components/chat/PendingItem.tsx:70
-#: src/pages/Dump.tsx:280
-#: src/pages/DumpEdit.tsx:186
+#: src/components/chat/PendingItem.tsx:86
+#: src/pages/Dump.tsx:307
+#: src/pages/DumpEdit.tsx:210
msgid "Retry"
msgstr "Réessayer"
-#: src/pages/UserPublicProfile.tsx:1748
+#: src/pages/UserPublicProfile.tsx:1784
msgid "Role"
msgstr "Rôle"
-#: src/components/category/CategoryManager.tsx:203
-#: src/components/chat/ChatEditBox.tsx:67
-#: src/components/comment/CommentNode.tsx:199
-#: src/pages/Dump.tsx:398
-#: src/pages/DumpEdit.tsx:511
-#: src/pages/PlaylistDetail.tsx:978
-#: src/pages/UserPublicProfile.tsx:1815
-#: src/pages/UserPublicProfile.tsx:1890
+#: src/components/category/CategoryManager.tsx:216
+#: src/components/chat/ChatEditBox.tsx:85
+#: src/components/comment/CommentNode.tsx:214
+#: src/pages/Dump.tsx:424
+#: src/pages/DumpEdit.tsx:545
+#: src/pages/PlaylistDetail.tsx:1021
+#: src/pages/UserPublicProfile.tsx:1855
+#: src/pages/UserPublicProfile.tsx:1934
msgid "Save"
msgstr "Enregistrer"
-#: src/components/comment/CommentNode.tsx:200
-#: src/components/profile/ChangePasswordModal.tsx:106
-#: src/pages/Dump.tsx:397
-#: src/pages/PlaylistDetail.tsx:974
-#: src/pages/ResetPassword.tsx:125
-#: src/pages/UserPublicProfile.tsx:1812
-#: src/pages/UserPublicProfile.tsx:1887
+#: src/components/comment/CommentNode.tsx:215
+#: src/components/profile/ChangePasswordModal.tsx:120
+#: src/pages/Dump.tsx:423
+#: src/pages/PlaylistDetail.tsx:1017
+#: src/pages/ResetPassword.tsx:136
+#: src/pages/UserPublicProfile.tsx:1852
+#: src/pages/UserPublicProfile.tsx:1931
msgid "Saving…"
msgstr "Enregistrement…"
-#: src/components/layout/AppHeader.tsx:130
-#: src/components/layout/SearchBar.tsx:73
-#: src/pages/Search.tsx:49
+#: src/components/layout/AppHeader.tsx:162
+#: src/components/layout/SearchBar.tsx:82
+#: src/pages/Search.tsx:63
msgid "Search"
msgstr "Rechercher"
-#: src/components/layout/SearchBar.tsx:69
+#: src/components/layout/SearchBar.tsx:78
msgid "Search dumps, users, playlists…"
msgstr "Rechercher des recos, utilisateurs, collections…"
-#: src/components/editor/EmojiPickerPopup.tsx:81
+#: src/components/editor/EmojiPickerPopup.tsx:104
msgid "Search emoji"
msgstr "Rechercher un emoji"
-#: src/components/editor/EmojiPickerPopup.tsx:80
+#: src/components/editor/EmojiPickerPopup.tsx:103
msgid "Search emoji…"
msgstr "Rechercher un emoji…"
-#: src/pages/Search.tsx:230
+#: src/pages/Search.tsx:241
msgid "Search failed"
msgstr "Recherche échouée"
-#: src/pages/Search.tsx:225
+#: src/pages/Search.tsx:236
msgid "Searching…"
msgstr "Recherche…"
-#: src/components/player/SeekBar.tsx:34
+#: src/components/player/SeekBar.tsx:50
msgid "Seek"
msgstr "Position"
-#: src/components/chat/ChatModal.tsx:823
+#: src/components/chat/ChatModal.tsx:844
msgid "Send"
msgstr "Envoyer"
-#: src/pages/UserLogin.tsx:143
+#: src/pages/UserLogin.tsx:157
msgid "Send reset link"
msgstr "Envoyer le lien de réinitialisation"
-#: src/components/chat/PendingItem.tsx:88
-#: src/pages/UserLogin.tsx:142
+#: src/components/chat/PendingItem.tsx:104
+#: src/pages/UserLogin.tsx:156
msgid "Sending…"
msgstr "Envoi…"
-#: src/components/layout/AppHeader.tsx:167
+#: src/components/layout/AppHeader.tsx:198
msgid "Server unreachable"
msgstr "Serveur inaccessible"
-#: src/pages/ResetPassword.tsx:93
-#: src/pages/ResetPassword.tsx:126
+#: src/pages/ResetPassword.tsx:104
+#: src/pages/ResetPassword.tsx:137
msgid "Set new password"
msgstr "Définir un nouveau mot de passe"
-#: src/pages/UserPublicProfile.tsx:994
+#: src/pages/UserPublicProfile.tsx:1031
msgid "Settings"
msgstr "Paramètres"
-#: src/components/chat/ChatModal.tsx:607
+#: src/components/chat/ChatModal.tsx:628
msgid "Several people are typing…"
msgstr "Plusieurs personnes écrivent…"
-#: src/pages/UserPublicProfile.tsx:162
+#: src/pages/UserPublicProfile.tsx:170
msgid "Single use, valid for 7 days."
msgstr "Usage unique, valable 7 jours."
-#: src/components/category/CategoryManager.tsx:192
+#: src/components/category/CategoryManager.tsx:205
msgid "slug"
msgstr "identifiant"
-#: src/components/editor/TextEditor.tsx:276
+#: src/components/editor/TextEditor.tsx:317
msgid "Some images couldn't be uploaded."
msgstr "Certaines images n'ont pas pu être envoyées."
-#: src/components/form/FormError.tsx:16
-#: src/components/layout/PageError.tsx:13
-#: src/contexts/ws/WSProvider.tsx:669
-#: src/pages/PlaylistDetail.tsx:711
+#: src/components/form/FormError.tsx:31
+#: src/components/layout/PageError.tsx:29
+#: src/contexts/ws/WSProvider.tsx:726
+#: src/pages/PlaylistDetail.tsx:737
msgid "Something went wrong"
msgstr "Une erreur est survenue"
-#: src/pages/UserPublicProfile.tsx:1374
+#: src/pages/UserPublicProfile.tsx:1411
msgid "Sounds"
msgstr "Sons"
-#: src/components/dump/create/DumpCreateModal.tsx:534
+#: src/components/dump/create/DumpCreateModal.tsx:547
msgid "Start fresh"
msgstr "Repartir de zéro"
-#: src/pages/UserPublicProfile.tsx:1248
+#: src/pages/UserPublicProfile.tsx:1285
msgid "Style"
msgstr "Style"
-#: src/components/layout/SearchBar.tsx:79
+#: src/components/layout/SearchBar.tsx:88
msgid "Submit search"
msgstr "Lancer la recherche"
-#: src/pages/Notifications.tsx:221
+#: src/components/form/FileField.tsx:60
+msgid "That file is over {limit}."
+msgstr "Ce fichier dépasse {limit}."
+
+#: src/pages/Notifications.tsx:234
msgid "the chat"
msgstr "la tribune"
-#: src/pages/PlaylistDetail.tsx:501
+#: src/pages/PlaylistDetail.tsx:563
+msgid "The dump couldn't be put back. Try again."
+msgstr "Le dump n'a pas pu être remis. Réessayez."
+
+#: src/pages/PlaylistDetail.tsx:511
msgid "The dump couldn't be removed. Try again."
msgstr "Le dump n'a pas pu être retiré. Réessayez."
-#: src/components/editor/TextEditor.tsx:277
+#: src/components/editor/TextEditor.tsx:318
msgid "The image couldn't be uploaded."
msgstr "L'image n'a pas pu être envoyée."
-#: src/components/comment/CommentNode.tsx:265
+#: src/components/comment/CommentNode.tsx:280
msgid "This comment couldn't be deleted. Try again."
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
-#: src/pages/UserRegister.tsx:98
+#: src/pages/UserRegister.tsx:118
msgid "This invite link is missing, expired, or already used."
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
-#: src/pages/UserLogin.tsx:94
+#: src/pages/UserLogin.tsx:104
msgid "This is a mirage."
msgstr "C'est un mirage."
-#: src/pages/NotFound.tsx:13
+#: src/pages/NotFound.tsx:21
msgid "This page does not exist."
msgstr "Rien à voir, circulez."
-#: src/pages/ResetPassword.tsx:53
+#: src/pages/ResetPassword.tsx:64
msgid "This reset link is missing or malformed."
msgstr "Ce lien de réinitialisation est absent ou malformé."
-#: src/pages/DumpEdit.tsx:400
+#: src/pages/DumpEdit.tsx:433
msgid "Thumbnail"
msgstr "Miniature"
-#: src/components/dump/create/TitleField.tsx:32
-#: src/components/playlist/PlaylistCreateForm.tsx:77
-#: src/pages/DumpEdit.tsx:460
+#: src/components/dump/create/TitleField.tsx:45
+#: src/components/playlist/PlaylistCreateForm.tsx:96
+#: src/pages/DumpEdit.tsx:494
msgid "Title"
msgstr "Titre"
-#: src/components/dump/create/DumpCreateModal.tsx:402
+#: src/components/dump/create/DumpCreateModal.tsx:417
msgid "Title is required."
msgstr "Un titre est requis."
-#: src/pages/Notifications.tsx:431
-#: src/utils/chat/time.ts:16
+#: src/pages/Notifications.tsx:469
+#: src/utils/chat/time.ts:24
msgid "Today"
msgstr "Aujourd'hui"
#. placeholder {0}: i + 1
-#: src/components/player/GlobalPlayer.tsx:318
+#: src/components/player/GlobalPlayer.tsx:338
msgid "Track {0}"
msgstr "Piste {0}"
-#: src/components/chat/ChatModal.tsx:624
+#: src/components/chat/ChatModal.tsx:645
msgid "Try again"
msgstr "Réessayer"
-#: src/components/chat/ChatModal.tsx:808
+#: src/components/chat/ChatModal.tsx:829
msgid "Type a message…"
msgstr "Écrivez un message…"
-#: src/pages/PlaylistDetail.tsx:784
+#: src/pages/PlaylistDetail.tsx:810
msgid "Undo"
msgstr "Annuler"
-#: src/components/social/FollowButton.tsx:34
+#: src/utils/shared/api.ts:21
+msgid "Unexpected response format"
+msgstr "Format de réponse inattendu"
+
+#: src/components/social/FollowButton.tsx:59
msgid "Unfollow {targetUsername}"
msgstr "Ne plus suivre {targetUsername}"
-#: src/components/social/FollowButton.tsx:62
+#: src/components/social/FollowButton.tsx:100
msgid "Unfollow playlist"
msgstr "Ne plus suivre la collection"
-#: src/components/player/MediaPlayer.tsx:326
+#: src/components/player/MediaPlayer.tsx:346
msgid "Unmute"
msgstr "Rétablir le son"
-#: src/pages/UserPublicProfile.tsx:770
+#: src/pages/UserPublicProfile.tsx:807
msgid "Upload failed"
msgstr "Envoi échoué"
#. placeholder {0}: Math.round((uploadProgress ?? 0) * 100)
-#: src/components/dump/create/DumpCreateModal.tsx:470
+#: src/components/dump/create/DumpCreateModal.tsx:484
msgid "Uploading {0}%"
msgstr "Envoi {0} %"
-#: src/components/editor/TextEditor.tsx:525
+#: src/components/editor/TextEditor.tsx:566
msgid "Uploading image…"
msgstr "Envoi de l'image…"
-#: src/components/dump/create/DumpCreateModal.tsx:785
+#: src/components/dump/create/DumpCreateModal.tsx:797
msgid "Uploading…"
msgstr "Envoi…"
-#: src/components/social/VoteButton.tsx:28
+#: src/components/social/VoteButton.tsx:52
msgid "Upvote"
msgstr "Voter"
-#: src/pages/UserUpvoted.tsx:161
+#: src/pages/UserUpvoted.tsx:186
msgid "Upvoted"
msgstr "Voté"
#. placeholder {0}: votes.items.length
#. placeholder {1}: votes.hasMore ? "+" : ""
-#: src/pages/UserPublicProfile.tsx:1020
+#: src/pages/UserPublicProfile.tsx:1057
msgid "Upvoted ({0}{1})"
msgstr "Votés ({0}{1})"
-#: src/pages/DumpEdit.tsx:432
+#: src/pages/DumpEdit.tsx:465
msgid "URL"
msgstr "URL"
-#: src/components/dump/create/DumpCreateModal.tsx:386
+#: src/components/dump/create/DumpCreateModal.tsx:401
msgid "URL is required."
msgstr "L'URL est obligatoire."
-#: src/components/category/CategoryManager.tsx:193
+#: src/components/category/CategoryManager.tsx:206
msgid "URL slug"
msgstr "Identifiant d’URL"
-#: src/pages/UserPublicProfile.tsx:1692
+#: src/pages/UserPublicProfile.tsx:1724
msgid "User"
msgstr "Utilisateur"
-#: src/pages/UserPublicProfile.tsx:1405
+#: src/pages/UserPublicProfile.tsx:1442
msgid "User management"
msgstr "Gestion utilisateur"
-#: src/components/layout/UserMenu.tsx:37
+#: src/components/layout/UserMenu.tsx:53
msgid "User menu"
msgstr "Menu utilisateur"
-#: src/pages/UserLogin.tsx:65
-#: src/pages/UserRegister.tsx:117
+#: src/pages/UserLogin.tsx:75
+#: src/pages/UserRegister.tsx:137
msgid "Username"
msgstr "Nom d'utilisateur"
-#: src/pages/Search.tsx:188
+#: src/pages/Search.tsx:199
msgid "Users"
msgstr "Utilisateurs"
-#: src/pages/UserPublicProfile.tsx:1090
-#: src/pages/UserPublicProfile.tsx:1179
-#: src/pages/UserPublicProfile.tsx:1498
-#: src/pages/UserPublicProfile.tsx:1635
+#: src/pages/UserPublicProfile.tsx:1127
+#: src/pages/UserPublicProfile.tsx:1216
+#: src/pages/UserPublicProfile.tsx:1537
+#: src/pages/UserPublicProfile.tsx:1669
msgid "View all →"
msgstr "Tout voir →"
-#: src/components/dump/create/DumpCreateModal.tsx:548
+#: src/components/dump/create/DumpCreateModal.tsx:561
msgid "View dump →"
msgstr "Voir la reco →"
-#: src/components/player/MediaPlayer.tsx:343
+#: src/components/player/MediaPlayer.tsx:363
msgid "Volume"
msgstr "Volume"
-#: src/components/dump/create/DumpCreateModal.tsx:736
-#: src/pages/DumpEdit.tsx:481
+#: src/components/dump/create/DumpCreateModal.tsx:748
+#: src/pages/DumpEdit.tsx:515
msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?"
-#: src/pages/UserPublicProfile.tsx:974
-#: src/pages/UserPublicProfile.tsx:1879
+#: src/pages/UserPublicProfile.tsx:1011
+#: src/pages/UserPublicProfile.tsx:1923
msgid "Who am I?"
msgstr "Qui suis-je ?"
-#: src/components/dump/create/DumpCreateModal.tsx:751
+#: src/components/dump/create/DumpCreateModal.tsx:763
msgid "Who can see it"
msgstr "Qui peut la voir"
-#: src/components/dump/create/StepIndicator.tsx:13
+#: src/components/dump/create/StepIndicator.tsx:20
msgid "Why & where"
msgstr "Pourquoi & où"
-#: src/components/dump/create/DumpCreateModal.tsx:735
-#: src/pages/DumpEdit.tsx:480
+#: src/components/dump/create/DumpCreateModal.tsx:747
+#: src/pages/DumpEdit.tsx:514
msgid "Why?"
msgstr "Pourquoi ?"
-#: src/components/comment/CommentNode.tsx:276
+#: src/components/comment/CommentNode.tsx:291
msgid "Write a reply…"
msgstr "Écrire une réponse…"
-#: src/pages/Notifications.tsx:433
-#: src/utils/chat/time.ts:19
+#: src/pages/Notifications.tsx:471
+#: src/utils/chat/time.ts:27
msgid "Yesterday"
msgstr "Hier"
-#: src/components/dump/create/DuplicateNotice.tsx:42
+#: src/components/dump/create/DuplicateNotice.tsx:60
msgid "You can post it anyway."
msgstr "Vous pouvez la publier quand même."
-#: src/pages/Notifications.tsx:418
+#: src/pages/Notifications.tsx:456
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/ws/WSProvider.tsx:930
+#: src/contexts/ws/WSProvider.tsx:907
msgid "You're offline — not sent"
msgstr "Hors ligne — message non envoyé"
-#: src/components/feed/DumpFeed.tsx:92
-#: src/components/feed/FollowedFeed.tsx:125
-#: src/components/feed/JournalFeed.tsx:80
-#: src/pages/Search.tsx:261
-#: src/pages/UserDumps.tsx:126
-#: src/pages/UserPlaylists.tsx:436
-#: src/pages/UserPlaylists.tsx:471
-#: src/pages/UserUpvoted.tsx:202
+#: src/components/feed/DumpFeed.tsx:120
+#: src/components/feed/FollowedFeed.tsx:136
+#: src/components/feed/JournalFeed.tsx:91
+#: src/pages/Search.tsx:272
+#: src/pages/UserDumps.tsx:136
+#: src/pages/UserPlaylists.tsx:452
+#: src/pages/UserPlaylists.tsx:487
+#: src/pages/UserUpvoted.tsx:227
msgid "You've reached the end."
msgstr "Vous avez tout lu, tout vu, tout bu."
-#: src/pages/UserLogin.tsx:138
+#: src/pages/UserLogin.tsx:152
msgid "Your email address"
msgstr "Votre adresse e-mail"
-#: src/pages/ResetPassword.tsx:75
+#: src/pages/ResetPassword.tsx:86
msgid "Your password has been changed. You can now log in."
msgstr "Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."
diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx
index f2d22700..50cb34ae 100644
--- a/src/pages/Dump.tsx
+++ b/src/pages/Dump.tsx
@@ -65,7 +65,22 @@ export function Dump(): React.JSX.Element {
const { selectedDump } = useParams();
const location = useLocation();
const navigate = useNavigate();
- const preloaded = (location.state as { dump?: Dump } | null)?.dump ?? null;
+ // Taken once: it stays in the history entry, where a reload or Back would
+ // show that snapshot forever, so it is cleared from there below.
+ const [handedOver] = useState(
+ () => (location.state as { dump?: Dump } | null)?.dump ?? null,
+ );
+ const preloaded = handedOver &&
+ (handedOver.id === selectedDump || handedOver.slug === selectedDump)
+ ? handedOver
+ : null;
+ useEffect(() => {
+ if (!(location.state as { dump?: Dump } | null)?.dump) return;
+ navigate(`${location.pathname}${location.search}${location.hash}`, {
+ replace: true,
+ state: null,
+ });
+ }, [location, navigate]);
const [dumpState, setDumpState] = useState(
preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" },
@@ -91,7 +106,7 @@ export function Dump(): React.JSX.Element {
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState(null);
- const { user, token, mediaToken, authFetch } = useAuth();
+ const { user, mediaToken, authFetch } = useAuth();
const { byId } = useCategories();
const {
voteCounts,
@@ -578,7 +593,6 @@ export function Dump(): React.JSX.Element {
dumpId={dump.id}
comments={comments}
currentUser={user}
- token={token}
onCommentCreated={(c) =>
setComments((prev) =>
prev.some((x) => x.id === c.id) ? prev : [...prev, c]
diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx
index 90c68a06..c1cfd000 100644
--- a/src/pages/DumpEdit.tsx
+++ b/src/pages/DumpEdit.tsx
@@ -10,7 +10,10 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
-import { VALIDATION } from "../../shared/validation.ts";
+import {
+ DUMP_MAX_FILE_SIZE_BYTES,
+ VALIDATION,
+} from "../../shared/validation.ts";
import type { Dump, UpdateDumpRequest } from "../../shared/dump.ts";
import type { Wire } from "../../shared/api.ts";
import { deserializeDump } from "../utils/shared/deserialize.ts";
@@ -20,6 +23,7 @@ import {
parseAPIResponse,
} from "../utils/shared/api.ts";
import { useAuth, useRequiredAuth } from "../hooks/auth/useAuth.ts";
+import { can } from "../utils/auth/permissions.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { formatBytes } from "../utils/shared/format.ts";
import {
@@ -50,17 +54,16 @@ type DumpEditState =
| { status: "loaded"; dump: Dump };
/**
- * Renders `/dumps/:selectedDump/edit`, for a signed-in user. The page itself
- * doesn't check that the user may edit the dump: `Dump` links here only for
- * its poster and moderators, and the API refuses anyone else's changes. The
- * dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the preview refresh
+ * Renders `/dumps/:selectedDump/edit`, for the dump's poster and moderators;
+ * anyone else signed in gets an error rather than a form the API would refuse
+ * to save. The dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the preview refresh
* and the deletion apply at once, and the form's other fields on "Save", which
* goes back to the dump's page. A deletion goes to the index.
*/
export function DumpEdit(): React.JSX.Element {
const { selectedDump } = useParams();
const navigate = useNavigate();
- const { authFetch, token, mediaToken } = useRequiredAuth();
+ const { authFetch, token, mediaToken, user } = useRequiredAuth();
const [state, setState] = useState({ status: "loading" });
@@ -92,10 +95,16 @@ export function DumpEdit(): React.JSX.Element {
const apiResponse = parseAPIResponse>(await res.json());
if (apiResponse.success) {
- setState({
- status: "loaded",
- dump: deserializeDump(apiResponse.data),
- });
+ const dump = deserializeDump(apiResponse.data);
+ // The same rule as the API's.
+ if (dump.userId !== user.id && !can(user, "dump:moderate")) {
+ setState({
+ status: "error",
+ error: t`Only its poster or a moderator can edit this dump.`,
+ });
+ return;
+ }
+ setState({ status: "loaded", dump });
} else {
setState({ status: "error", error: apiResponse.error.message });
}
@@ -103,7 +112,7 @@ export function DumpEdit(): React.JSX.Element {
setState({ status: "error", error: friendlyFetchError(err) });
}
})();
- }, [selectedDump, token]);
+ }, [selectedDump, token, user]);
const handleRefreshMetadata = async () => {
if (state.status !== "loaded" || state.dump.kind !== "url") return;
@@ -173,8 +182,7 @@ export function DumpEdit(): React.JSX.Element {
setDeleteError(friendlyFetchError(err));
return;
}
- // Meant for Index, to hide the dump before `dump_deleted` arrives, but the
- // redirect from `/` doesn't pass the state on.
+ // For Index, to hide the dump before `dump_deleted` arrives.
navigate("/", { state: { deletedDumpId: state.dump.id } });
};
@@ -471,6 +479,7 @@ function DumpEditForm({
name="newFile"
hint={t`Drop a replacement here`}
+ maxBytes={DUMP_MAX_FILE_SIZE_BYTES}
showLimit={false}
/>
diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx
index c669a8cf..2001dea6 100644
--- a/src/pages/Index.tsx
+++ b/src/pages/Index.tsx
@@ -31,7 +31,7 @@ import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts";
import type { Dump } from "../../shared/dump.ts";
import type { PaginatedData, Wire } from "../../shared/api.ts";
-import { friendlyFetchError } from "../utils/shared/api.ts";
+import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
import { useScrollSave } from "../hooks/feed/useScrollSave.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
@@ -127,7 +127,7 @@ export function Index(
);
const { cached, saveState } = useFeedCache(
- `feed:index:${categorySlug ?? "all"}:${user?.id ?? "guest"}`,
+ `feed:index:${categorySlug ?? "all"}`,
hydrateDump,
);
const [dumpsState, setDumpsState] = useState(() =>
@@ -141,7 +141,6 @@ export function Index(
}
: { status: "loading" }
);
- const mainFetchDone = useRef(false);
// The Web Share Target: Android's share sheet opens `/?share_url=…`, which
// the redirect from `/` keeps. The query is dropped from the URL at once, so
@@ -156,8 +155,7 @@ export function Index(
}, [shareUrl, location.pathname]);
useEffect(() => {
- if (mainFetchDone.current || cached) return;
- mainFetchDone.current = true;
+ if (cached) return;
const controller = new AbortController();
(async () => {
try {
@@ -165,9 +163,9 @@ export function Index(
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const body = await res.json();
- const { items, hasMore } = body.data as PaginatedData>;
+ const { items, hasMore } = await expectOk<
+ PaginatedData>
+ >(res);
setDumpsState({
status: "loaded",
dumps: items.map(deserializeDump),
@@ -180,10 +178,7 @@ export function Index(
setDumpsState({ status: "error", error: friendlyFetchError(err) });
}
})();
- return () => {
- mainFetchDone.current = false;
- controller.abort();
- };
+ return () => controller.abort();
}, [cached, token, feedUrl]);
const setDumpsItems = useCallback(
@@ -207,9 +202,8 @@ export function Index(
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
- .then((r) => r.json())
- .then((body) => {
- const { items } = body.data as PaginatedData>;
+ .then((res) => expectOk>>(res))
+ .then(({ items }) => {
const fresh = items.map(deserializeDump);
setDumpsState((s) => {
if (s.status !== "loaded") return s;
@@ -235,9 +229,8 @@ export function Index(
fetch(feedUrl(nextPage), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
- .then((r) => r.json())
- .then((body) => {
- const { items, hasMore } = body.data as PaginatedData>;
+ .then((res) => expectOk>>(res))
+ .then(({ items, hasMore }) => {
setDumpsState((s) =>
s.status === "loaded"
? {
@@ -300,8 +293,8 @@ export function Index(
);
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
- // Dumps deleted since loading are dropped. `justDeletedId` never matches,
- // though: DumpEdit navigates to `/`, whose redirect drops the state.
+ // Dumps deleted since loading are dropped, and the one the edit page just
+ // deleted before its `dump_deleted` arrives.
const combined = useMemo(
() =>
[...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps]
@@ -358,7 +351,6 @@ export function Index(
user={user}
castVote={castVote}
removeVote={removeVote}
- deletedDumpIds={deletedDumpIds}
/>
)}
diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx
index ed263920..1780a332 100644
--- a/src/pages/Notifications.tsx
+++ b/src/pages/Notifications.tsx
@@ -6,7 +6,7 @@
* @module
*/
-import { useEffect, useState } from "react";
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -299,6 +299,15 @@ export function Notifications(): React.JSX.Element {
arrived: [],
});
+ // The latest notification the read-all requests have covered, so that one
+ // arriving while the page is open is marked read once, and the one the bell
+ // already had isn't marked again after the first load.
+ const lastNotificationRef = useRef(lastNotification);
+ const markedReadRef = useRef(lastNotification);
+ useLayoutEffect(() => {
+ lastNotificationRef.current = lastNotification;
+ });
+
useEffect(() => {
let cancelled = false;
(async () => {
@@ -330,6 +339,7 @@ export function Notifications(): React.JSX.Element {
}
// If this fails, the list is still worth showing, and they stay unread
// for next time.
+ markedReadRef.current = lastNotificationRef.current;
try {
const res = await authFetch(`${API_URL}/api/notifications/read-all`, {
method: "POST",
@@ -369,6 +379,8 @@ export function Notifications(): React.JSX.Element {
const loaded = state.status === "loaded";
useEffect(() => {
if (!lastNotification || !loaded) return;
+ if (markedReadRef.current === lastNotification) return;
+ markedReadRef.current = lastNotification;
authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" })
.then((res) => {
if (res.ok) clearUnreadNotifications();
@@ -385,9 +397,8 @@ export function Notifications(): React.JSX.Element {
authFetch(
`${API_URL}/api/notifications?page=${nextPage}&limit=${NOTIFICATIONS_PAGE_SIZE}`,
)
- .then((r) => r.json())
- .then((body) => {
- const data = body.data as PaginatedData>;
+ .then((res) => expectOk>>(res))
+ .then((data) => {
setState((s) =>
s.status === "loaded"
? {
diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx
index 10614cc8..59c4dabe 100644
--- a/src/pages/PlaylistDetail.tsx
+++ b/src/pages/PlaylistDetail.tsx
@@ -125,9 +125,12 @@ export function PlaylistDetail(): React.JSX.Element {
// For the socket effects, which must not re-run when it changes.
const activeDumpIdsRef = useRef(activeDumpIds);
- // Every dump seen in the playlist, so that one hidden when it turned private
- // comes back when it turns public.
+ // Every dump of the playlist seen so far, so that one hidden when it turned
+ // private comes back when it turns public. A dump removed from the playlist
+ // leaves it.
const knownDumpIdsRef = useRef>(new Set());
+ // The removals still in flight, by dump, which an undo must wait for.
+ const removalsRef = useRef>>(new Map());
// The server's last order, where such a dump comes back.
const dumpOrderRef = useRef([]);
@@ -272,6 +275,9 @@ export function PlaylistDetail(): React.JSX.Element {
for (const id of prev) {
if (!newIds.has(id)) {
+ // Shown until now and gone from the list: removed from the
+ // playlist, not hidden (that comes as `dump_deleted`).
+ knownDumpIdsRef.current.delete(id);
setActiveDumpIds((s) => {
const n = new Set(s);
n.delete(id);
@@ -480,17 +486,31 @@ export function PlaylistDetail(): React.JSX.Element {
};
const handleRemoveDump = (dumpId: string) => {
- if (!playlistId) return;
+ if (!playlistId || state.status !== "loaded") return;
+ const index = state.playlist.dumps.findIndex((d) => d.id === dumpId);
+ const dump = state.playlist.dumps[index];
+ knownDumpIdsRef.current.delete(dumpId);
// Removed on the server at once; the undo adds it back.
- authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
- method: "DELETE",
- }).then((res) => {
+ const removal = authFetch(
+ `${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`,
+ { method: "DELETE" },
+ ).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}).catch(() => {
cancels.current.get(dumpId)?.();
+ knownDumpIdsRef.current.add(dumpId);
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
+ // The fade may have taken it out of the list already.
+ setState((s) => {
+ if (s.status !== "loaded" || !dump) return s;
+ if (s.playlist.dumps.some((d) => d.id === dumpId)) return s;
+ const dumps = [...s.playlist.dumps];
+ dumps.splice(Math.min(index, dumps.length), 0, dump);
+ return { ...s, playlist: { ...s.playlist, dumps } };
+ });
setActionError(t`The dump couldn't be removed. Try again.`);
- });
+ }).finally(() => removalsRef.current.delete(dumpId));
+ removalsRef.current.set(dumpId, removal);
setActiveDumpIds((prev) => {
const n = new Set(prev);
n.delete(dumpId);
@@ -510,23 +530,41 @@ export function PlaylistDetail(): React.JSX.Element {
.filter((d) => restoredIds.has(d.id))
.map((d) => d.id);
pendingUndoOrderRef.current = desiredOrder;
+ knownDumpIdsRef.current.add(dumpId);
+ // Added back only once the removal has landed, or the two could cross.
// The server adds the dump first, so the order is restored after.
- authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
- method: "POST",
- })
+ (removalsRef.current.get(dumpId) ?? Promise.resolve())
.then(() =>
- authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
+ authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
+ method: "POST",
+ })
+ )
+ .then((res) => {
+ // 409: the removal failed, so it never left.
+ if (!res.ok && res.status !== 409) {
+ throw new Error(`HTTP ${res.status}`);
+ }
+ return authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ dumpIds: desiredOrder } satisfies ReorderPlaylistRequest,
),
- })
- )
+ });
+ })
+ .catch(() => {
+ knownDumpIdsRef.current.delete(dumpId);
+ setActiveDumpIds((prev) => {
+ const n = new Set(prev);
+ n.delete(dumpId);
+ return n;
+ });
+ startFade(dumpId);
+ setActionError(t`The dump couldn't be put back. Try again.`);
+ })
.finally(() => {
pendingUndoOrderRef.current = null;
- })
- .catch(() => {});
+ });
};
const openEdit = () => setEditOpen(true);
@@ -817,6 +855,12 @@ function PlaylistEditForm(
) {
const { authFetch, mediaToken } = useAuth();
const [imagePreview, setImagePreview] = useState(null);
+ // Each preview holds the file in memory until its URL is revoked: when the
+ // next one replaces it, or when the form closes.
+ useEffect(() => {
+ if (!imagePreview) return;
+ return () => URL.revokeObjectURL(imagePreview);
+ }, [imagePreview]);
const form = useApiForm({
defaultValues: {
title: playlist.title,
diff --git a/src/pages/UserDumps.tsx b/src/pages/UserDumps.tsx
index fa52112b..2d8a46cc 100644
--- a/src/pages/UserDumps.tsx
+++ b/src/pages/UserDumps.tsx
@@ -51,13 +51,10 @@ export function UserDumps(): React.JSX.Element {
dumpItems,
setItems,
lastDumpEvent,
- (d) => d.isPrivate,
(d) => !d.isPrivate && d.userId === profileUserId,
);
useDumpListSync(setItems, {
- ownerId: profileUserId ?? undefined,
isOwner: isOwnProfile,
- skipReinsert: true,
});
if (state.status === "loading") {
diff --git a/src/pages/UserPlaylists.tsx b/src/pages/UserPlaylists.tsx
index fe90e08f..fd51af9c 100644
--- a/src/pages/UserPlaylists.tsx
+++ b/src/pages/UserPlaylists.tsx
@@ -120,13 +120,11 @@ export function UserPlaylists(): React.JSX.Element {
createdItems,
setCreated,
lastPlaylistItem,
- (p) => !p.isPublic,
(p) => p.isPublic && p.userId === profileUserId,
);
usePlaylistListSync(setCreated, {
isOwner: isOwnProfile,
ownerId: profileUserId ?? undefined,
- skipReinsert: true,
});
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
@@ -234,9 +232,8 @@ export function UserPlaylists(): React.JSX.Element {
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
- .then((r) => r.json())
- .then((body) => {
- const { items, hasMore } = body.data as PaginatedData>;
+ .then((res) => expectOk>>(res))
+ .then(({ items, hasMore }) => {
setState((s) =>
s.status === "loaded"
? {
@@ -274,9 +271,8 @@ export function UserPlaylists(): React.JSX.Element {
fetch(
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
)
- .then((r) => r.json())
- .then((body) => {
- const { items, hasMore } = body.data as PaginatedData>;
+ .then((res) => expectOk>>(res))
+ .then(({ items, hasMore }) => {
setState((s) =>
s.status === "loaded"
? {
diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx
index 44f6bd2a..8b25046b 100644
--- a/src/pages/UserPublicProfile.tsx
+++ b/src/pages/UserPublicProfile.tsx
@@ -337,13 +337,10 @@ export function UserPublicProfile(): React.JSX.Element {
dumpItems,
setDumps,
lastDumpEvent,
- (d) => d.isPrivate,
(d) => !d.isPrivate && d.userId === profileUserId,
);
useDumpListSync(setDumps, {
- ownerId: profileUserId ?? undefined,
isOwner: isOwnProfile,
- skipReinsert: true,
});
// A withdrawn vote leaves its dump in the list, for UpvotedDumpList to fade
@@ -362,10 +359,9 @@ export function UserPublicProfile(): React.JSX.Element {
voteItems,
setVotes,
lastDumpEvent,
- (d) => d.isPrivate,
(d) => !d.isPrivate && !withdrawnVoteIdsRef.current.has(d.id),
);
- useDumpListSync(setVotes, { skipReinsert: true });
+ useDumpListSync(setVotes);
const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
setState((s) =>
@@ -382,13 +378,11 @@ export function UserPublicProfile(): React.JSX.Element {
playlistItems,
setPlaylists,
lastPlaylistItem,
- (p) => !p.isPublic,
(p) => p.isPublic && p.userId === profileUserId,
);
usePlaylistListSync(setPlaylists, {
isOwner: isOwnProfile,
ownerId: profileUserId ?? undefined,
- skipReinsert: true,
});
useEffect(() => {
@@ -591,7 +585,10 @@ export function UserPublicProfile(): React.JSX.Element {
withdrawnVoteIdsRef.current.add(dumpId);
} else {
withdrawnVoteIdsRef.current.delete(dumpId);
- fetch(`${API_URL}/api/dumps/${dumpId}`)
+ // Signed in, or a private dump the viewer owns can't be fetched.
+ fetch(`${API_URL}/api/dumps/${dumpId}`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ })
.then((r) => r.json())
.then((body) => {
if (!body.success) return;
@@ -613,7 +610,7 @@ export function UserPublicProfile(): React.JSX.Element {
})
.catch(() => {});
}
- }, [lastVoteEvent, profileUserId]);
+ }, [lastVoteEvent, profileUserId, token]);
// Saves the lists and the scroll position, so that Back restores both.
useEffect(() => {
@@ -654,6 +651,16 @@ export function UserPublicProfile(): React.JSX.Element {
);
}, [playlistFeed, savePlaylists]);
+ // The two lazy tabs' fetches aren't aborted when the user switches tabs:
+ // they finish in the background. Only another profile or leaving the page
+ // aborts them, since their state is reset then anyway.
+ const followedFetchRef = useRef(null);
+ const inviteesFetchRef = useRef(null);
+ useEffect(() => () => {
+ followedFetchRef.current?.abort();
+ inviteesFetchRef.current?.abort();
+ }, [username]);
+
useEffect(() => {
if (
tab !== "followed" || followedState !== null || state.status !== "loaded"
@@ -663,6 +670,7 @@ export function UserPublicProfile(): React.JSX.Element {
// Deferred, since the lint forbids a setState in an effect's body.
queueMicrotask(() => setFollowedState({ status: "loading" }));
const controller = new AbortController();
+ followedFetchRef.current = controller;
Promise.all([
fetch(
`${API_URL}/api/users/${username}/followed-users?page=1&limit=${DEFAULT_PAGE_SIZE}`,
@@ -697,9 +705,8 @@ export function UserPublicProfile(): React.JSX.Element {
if (err.name === "AbortError") return;
setFollowedState({ status: "error", error: friendlyFetchError(err) });
});
- return () => controller.abort();
- // followedState only guards the first fetch. As a dependency, its change
- // to "loading" would abort that fetch.
+ // followedState only guards the first fetch; as a dependency, its change
+ // to "loading" would run this again.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, state.status, username]);
@@ -712,6 +719,7 @@ export function UserPublicProfile(): React.JSX.Element {
}
queueMicrotask(() => setInviteTreeState({ status: "loading" }));
const controller = new AbortController();
+ inviteesFetchRef.current = controller;
fetch(
`${API_URL}/api/users/${username}/invitees`,
{ signal: controller.signal },
@@ -727,7 +735,6 @@ export function UserPublicProfile(): React.JSX.Element {
if (err.name === "AbortError") return;
setInviteTreeState({ status: "error", error: friendlyFetchError(err) });
});
- return () => controller.abort();
// inviteTreeState only guards the first fetch, as followedState above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, state.status, username]);
diff --git a/src/pages/UserRegister.tsx b/src/pages/UserRegister.tsx
index 13e9654b..d7275b06 100644
--- a/src/pages/UserRegister.tsx
+++ b/src/pages/UserRegister.tsx
@@ -66,11 +66,18 @@ export function UserRegister(): React.JSX.Element {
useEffect(() => {
if (!token) return;
- fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`)
+ // Aborted for a newer token, whose answer alone may land.
+ const controller = new AbortController();
+ fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`, {
+ signal: controller.signal,
+ })
.then((r) => {
setTokenState(r.ok ? { status: "valid" } : { status: "invalid" });
})
- .catch(() => setTokenState({ status: "invalid" }));
+ .catch((err) => {
+ if (err.name !== "AbortError") setTokenState({ status: "invalid" });
+ });
+ return () => controller.abort();
}, [token]);
const onSubmit = form.submit(async ({ username, email, password }) => {
diff --git a/src/pages/UserUpvoted.tsx b/src/pages/UserUpvoted.tsx
index ea2ea381..10d49c1f 100644
--- a/src/pages/UserUpvoted.tsx
+++ b/src/pages/UserUpvoted.tsx
@@ -32,7 +32,7 @@ import { PageError } from "../components/layout/PageError.tsx";
*/
export function UserUpvoted(): React.JSX.Element {
const { username } = useParams();
- const { user: me } = useAuth();
+ const { user: me, token } = useAuth();
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
useDocumentTitle(username ?? null);
@@ -124,7 +124,10 @@ export function UserUpvoted(): React.JSX.Element {
} else {
setVotedIds((prev) => new Set([...prev, dumpId]));
cancelFading(dumpId);
- fetch(`${API_URL}/api/dumps/${dumpId}`)
+ // Signed in, or a private dump the viewer owns can't be fetched.
+ fetch(`${API_URL}/api/dumps/${dumpId}`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ })
.then((r) => r.json())
.then((body) => {
if (!body.success) return;
@@ -138,7 +141,14 @@ export function UserUpvoted(): React.JSX.Element {
})
.catch(() => {});
}
- }, [lastVoteEvent, profileUserId, startFading, cancelFading, setState]);
+ }, [
+ lastVoteEvent,
+ profileUserId,
+ startFading,
+ cancelFading,
+ setState,
+ token,
+ ]);
if (state.status === "loading") {
return (
diff --git a/src/test/components/comment/CommentThread.test.tsx b/src/test/components/comment/CommentThread.test.tsx
index 622ad80a..b40c30e3 100644
--- a/src/test/components/comment/CommentThread.test.tsx
+++ b/src/test/components/comment/CommentThread.test.tsx
@@ -21,7 +21,6 @@ function setup(initial: Comment[], session: Session | null = me, ws = {}) {
dumpId="d1"
comments={comments}
currentUser={session?.user ?? null}
- token={session?.token ?? null}
onCommentCreated={(c) => setComments((prev) => [...prev, c])}
onCommentDeleted={(id) =>
setComments((prev) =>
@@ -67,7 +66,10 @@ describe("CommentThread", () => {
.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reply" })).not
.toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Like" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Like" })).toHaveAttribute(
+ "aria-disabled",
+ "true",
+ );
});
test("posting a comment", async () => {
diff --git a/src/test/components/dump/FilePreview.test.tsx b/src/test/components/dump/FilePreview.test.tsx
index 46ecf887..c1332006 100644
--- a/src/test/components/dump/FilePreview.test.tsx
+++ b/src/test/components/dump/FilePreview.test.tsx
@@ -46,7 +46,7 @@ describe("FilePreview", () => {
renderWithContexts( );
const link = screen.getByRole("link", { name: /Download thing/ });
expect(link).toHaveAttribute("download", "thing");
- expect(link).toHaveTextContent("(2.0 KB)");
+ expect(link).toHaveTextContent("(2 kB)");
});
test("compact audio plays in the global player", async () => {
diff --git a/src/test/components/layout/ChatButton.test.tsx b/src/test/components/layout/ChatButton.test.tsx
index d454d72e..c7270e40 100644
--- a/src/test/components/layout/ChatButton.test.tsx
+++ b/src/test/components/layout/ChatButton.test.tsx
@@ -11,7 +11,7 @@ describe("ChatButton", () => {
const user = userEvent.setup();
renderWithContexts(
{} }}
+ value={{ openChat }}
>
,
diff --git a/src/test/components/layout/ChatFab.test.tsx b/src/test/components/layout/ChatFab.test.tsx
index 684fd156..33739c6a 100644
--- a/src/test/components/layout/ChatFab.test.tsx
+++ b/src/test/components/layout/ChatFab.test.tsx
@@ -8,7 +8,7 @@ import { renderWithContexts } from "../../render.tsx";
function withChat(ui: React.ReactNode, openChat = vi.fn()) {
return (
{} }}
+ value={{ openChat }}
>
{ui}
diff --git a/src/test/components/layout/NotificationBell.test.tsx b/src/test/components/layout/NotificationBell.test.tsx
index c410abfc..dcb4a584 100644
--- a/src/test/components/layout/NotificationBell.test.tsx
+++ b/src/test/components/layout/NotificationBell.test.tsx
@@ -40,6 +40,15 @@ describe("NotificationBell", () => {
expect(bell()).toHaveClass("notification-bell--ringing");
act(() => vi.advanceTimersByTime(700));
expect(bell()).not.toHaveClass("notification-bell--ringing");
+ // Another in the middle of a ring doesn't leave the bell rung.
+ rerender(withLast(makeNotification({ id: "n3" })));
+ act(() => vi.advanceTimersByTime(100));
+ rerender(withLast(makeNotification({ id: "n4" })));
+ act(() => vi.advanceTimersByTime(700));
+ expect(bell()).not.toHaveClass("notification-bell--ringing");
+ rerender(withLast(makeNotification({ id: "n5" })));
+ act(() => vi.advanceTimersByTime(10));
+ expect(bell()).toHaveClass("notification-bell--ringing");
} finally {
vi.useRealTimers();
}
diff --git a/src/test/components/shared/ThumbnailPlaceholder.test.tsx b/src/test/components/shared/ThumbnailPlaceholder.test.tsx
index 14a31568..6073e497 100644
--- a/src/test/components/shared/ThumbnailPlaceholder.test.tsx
+++ b/src/test/components/shared/ThumbnailPlaceholder.test.tsx
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import { ThumbnailPlaceholder } from "../../../components/shared/ThumbnailPlaceholder.tsx";
-import { hueFromString } from "../../../utils/shared/thumbnailTint.ts";
+import { tintFor } from "../../../utils/shared/thumbnailTint.ts";
import { renderWithContexts } from "../../render.tsx";
describe("ThumbnailPlaceholder", () => {
@@ -22,7 +22,7 @@ describe("ThumbnailPlaceholder", () => {
/>,
);
expect(box().style.getPropertyValue("--thumb-tint")).toBe(
- hueFromString("site.test"),
+ tintFor({ seed: "site.test" }),
);
});
diff --git a/src/test/components/social/UserListPopover.test.tsx b/src/test/components/social/UserListPopover.test.tsx
index 51b239ed..c45eb2b3 100644
--- a/src/test/components/social/UserListPopover.test.tsx
+++ b/src/test/components/social/UserListPopover.test.tsx
@@ -77,7 +77,7 @@ describe("UserListPopover", () => {
await screen.findByText("b");
});
- test("closes on Escape, an outside press and a scroll — not on the anchor", async () => {
+ test("closes on Escape, an outside press and a scroll — not on the anchor or its own scroll", async () => {
route("GET /api/x/voters", () => page([]));
const onClose = vi.fn();
const user = userEvent.setup();
@@ -85,6 +85,9 @@ describe("UserListPopover", () => {
await screen.findByText("No one yet.");
await user.click(screen.getByText("anchor"));
await user.click(screen.getByText("No one yet."));
+ act(() => {
+ screen.getByText("No one yet.").dispatchEvent(new Event("scroll"));
+ });
expect(onClose).not.toHaveBeenCalled();
await user.keyboard("{Escape}");
await user.click(screen.getByText("outside"));
diff --git a/src/test/components/social/VoteButton.test.tsx b/src/test/components/social/VoteButton.test.tsx
index 7aa4b75b..a0e7d583 100644
--- a/src/test/components/social/VoteButton.test.tsx
+++ b/src/test/components/social/VoteButton.test.tsx
@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
-import { screen } from "@testing-library/react";
+import { fireEvent, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { VoteButton } from "../../../components/social/VoteButton.tsx";
import { route } from "../../fetch.ts";
@@ -35,20 +35,31 @@ describe("VoteButton", () => {
expect(onRemove).toHaveBeenCalledWith("d", 3);
});
- test("disabled for guests, with a hint", () => {
+ test("disabled for guests, with a hint, but its count still opens", async () => {
+ const onCast = vi.fn();
renderWithContexts(
{}}
+ onCast={onCast}
onRemove={() => {}}
/>,
);
const btn = screen.getByRole("button", { name: "Upvote" });
- expect(btn).toBeDisabled();
+ expect(btn).toHaveAttribute("aria-disabled", "true");
expect(btn).toHaveAttribute("title", "Log in to vote");
+ fireEvent.click(btn);
+ expect(onCast).not.toHaveBeenCalled();
+ // The count still opens who voted.
+ route(
+ "GET /api/dumps/:id/voters",
+ () => page([makeUser({ username: "fan" })]),
+ );
+ fireEvent.click(screen.getByText("1"));
+ expect(await screen.findByRole("menuitem", { name: /fan/ }))
+ .toBeInTheDocument();
});
test("the count opens who voted, without voting", async () => {
diff --git a/src/test/contexts/category/CategoriesProvider.test.tsx b/src/test/contexts/category/CategoriesProvider.test.tsx
index f4aa49ca..0e7ce6b1 100644
--- a/src/test/contexts/category/CategoriesProvider.test.tsx
+++ b/src/test/contexts/category/CategoriesProvider.test.tsx
@@ -1,4 +1,4 @@
-import { describe, expect, test } from "vitest";
+import { describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { CategoriesProvider } from "../../../contexts/category/CategoriesProvider.tsx";
@@ -30,10 +30,22 @@ describe("CategoriesProvider", () => {
expect(result.current.categories).toHaveLength(2);
});
- test("a failure still ends loading", async () => {
- route("GET /api/categories", () => fail("x", 500));
- const { result } = renderHook(useCategories, { wrapper });
- await waitFor(() => expect(result.current.isLoaded).toBe(true));
- expect(result.current.categories).toEqual([]);
+ test("a failure isn't taken for an empty list, and is tried again", async () => {
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ try {
+ route("GET /api/categories", () => fail("x", 500));
+ const { result } = renderHook(useCategories, { wrapper });
+ await act(async () => {});
+ expect(result.current.isLoaded).toBe(false);
+ route("GET /api/categories", () => wire([makeCategory({ id: "a" })]));
+ await act(async () => {
+ vi.advanceTimersByTime(10_000);
+ });
+ await act(async () => {});
+ expect(result.current.isLoaded).toBe(true);
+ expect(result.current.categories).toHaveLength(1);
+ } finally {
+ vi.useRealTimers();
+ }
});
});
diff --git a/src/test/contexts/chat/ChatProvider.test.tsx b/src/test/contexts/chat/ChatProvider.test.tsx
index 236f7dd9..5288f695 100644
--- a/src/test/contexts/chat/ChatProvider.test.tsx
+++ b/src/test/contexts/chat/ChatProvider.test.tsx
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
-import { act, renderHook, screen } from "@testing-library/react";
+import { act, fireEvent, renderHook, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
import { ChatProvider } from "../../../contexts/chat/ChatProvider.tsx";
@@ -20,26 +20,20 @@ describe("ChatProvider", () => {
),
});
act(() => guest.result.current.openChat());
- expect(guest.result.current.isChatOpen).toBe(false);
+ expect(screen.queryByRole("dialog")).toBeNull();
});
test("?chat=open deep-links into it, and closing strips the param", async () => {
const session = makeSession();
route("GET /api/chat", () => ({ items: [], hasMore: false }));
- let chat!: ReturnType;
- function Probe() {
- chat = useChat();
- return null;
- }
- renderApp( , { session, route: "/somewhere?chat=open&x=1" });
- expect(chat.isChatOpen).toBe(true);
+ renderApp(<>>, { session, route: "/somewhere?chat=open&x=1" });
await screen.findByRole("dialog", { name: "Chat" });
- act(() => chat.closeChat());
- expect(chat.isChatOpen).toBe(false);
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(screen.queryByRole("dialog")).toBeNull();
expect(currentPath()).toBe("/somewhere?x=1");
});
- test("openChat / closeChat", () => {
+ test("openChat opens it, and its close button closes it", async () => {
const session = makeSession();
route("GET /api/chat", () => ({ items: [], hasMore: false }));
let chat!: ReturnType;
@@ -49,8 +43,8 @@ describe("ChatProvider", () => {
}
renderApp( , { session });
act(() => chat.openChat());
- expect(chat.isChatOpen).toBe(true);
- act(() => chat.closeChat());
- expect(chat.isChatOpen).toBe(false);
+ await screen.findByRole("dialog", { name: "Chat" });
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(screen.queryByRole("dialog")).toBeNull();
});
});
diff --git a/src/test/contexts/social/FollowProvider.test.tsx b/src/test/contexts/social/FollowProvider.test.tsx
index e8da0127..a272899f 100644
--- a/src/test/contexts/social/FollowProvider.test.tsx
+++ b/src/test/contexts/social/FollowProvider.test.tsx
@@ -1,4 +1,4 @@
-import { describe, expect, test } from "vitest";
+import { describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
import { FollowProvider } from "../../../contexts/social/FollowProvider.tsx";
@@ -48,11 +48,18 @@ describe("FollowProvider", () => {
await waitFor(() => expect(result.current.isLoaded).toBe(true));
route("POST /api/follows/users/:id", () => ({}));
- await act(() => result.current.followUser("u2"));
+ let ok: boolean | undefined;
+ await act(async () => {
+ ok = await result.current.followUser("u2");
+ });
+ expect(ok).toBe(true);
expect(result.current.followedUserIds.has("u2")).toBe(true);
route("DELETE /api/follows/users/:id", () => fail("no", 500));
- await act(() => result.current.unfollowUser("u2"));
+ await act(async () => {
+ ok = await result.current.unfollowUser("u2");
+ });
+ expect(ok).toBe(false);
expect(result.current.followedUserIds.has("u2")).toBe(true);
route("POST /api/follows/playlists/:id", () => new TypeError("offline"));
@@ -68,4 +75,35 @@ describe("FollowProvider", () => {
"/api/follows/playlists/p2",
);
});
+
+ test("a failed load keeps the buttons disabled, and is tried again", async () => {
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ try {
+ const session = makeSession();
+ storeSession(session);
+ route("GET /api/users/me", () => wire(session.user));
+ route("GET /api/follows/status", () => fail("down", 500));
+ const { result } = renderHook(useFollows, {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+ await act(async () => {});
+ expect(result.current.isLoaded).toBe(false);
+ route("GET /api/follows/status", () => ({
+ followedUserIds: ["u1"],
+ followedPlaylistIds: [],
+ }));
+ await act(async () => {
+ vi.advanceTimersByTime(10_000);
+ });
+ await act(async () => {});
+ expect(result.current.isLoaded).toBe(true);
+ expect(result.current.followedUserIds.has("u1")).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
});
diff --git a/src/test/contexts/ws/WSProvider.test.tsx b/src/test/contexts/ws/WSProvider.test.tsx
index 24466fa3..12cf3278 100644
--- a/src/test/contexts/ws/WSProvider.test.tsx
+++ b/src/test/contexts/ws/WSProvider.test.tsx
@@ -275,6 +275,43 @@ describe("votes", () => {
act(() => vi.advanceTimersByTime(10_000));
expect(ws().myVotes.has("d1")).toBe(true);
});
+
+ test("a welcome says a vote never landed, and its count goes back", () => {
+ const { socket, ws } = setup();
+ socket.welcome();
+ act(() => ws().castVote("d1", 4));
+ expect(ws().voteCounts.d1).toBe(5);
+ socket.welcome({ myVotes: [] });
+ expect(ws().myVotes.has("d1")).toBe(false);
+ expect(ws().voteCounts.d1).toBe(4);
+ });
+
+ test("clicks before an ack share one rollback, and the last ack decides", () => {
+ const { socket, ws } = setup();
+ socket.welcome();
+ act(() => ws().castVote("d1", 4));
+ act(() => ws().removeVote("d1"));
+ act(() => ws().castVote("d1"));
+ expect(ws().voteCounts.d1).toBe(5);
+ // The first ack doesn't flip the button back while others are in flight.
+ socket.receive({
+ type: "vote_ack",
+ dumpId: "d1",
+ action: "cast",
+ voteCount: 5,
+ });
+ socket.receive({
+ type: "vote_ack",
+ dumpId: "d1",
+ action: "remove",
+ voteCount: 4,
+ });
+ expect(ws().myVotes.has("d1")).toBe(true);
+ // The last one is lost: the timer restores the state before the clicks.
+ act(() => vi.advanceTimersByTime(5_000));
+ expect(ws().myVotes.has("d1")).toBe(false);
+ expect(ws().voteCounts.d1).toBe(4);
+ });
});
describe("comment likes", () => {
@@ -325,7 +362,6 @@ describe("comment likes", () => {
});
expect(ws().myLikes.has("c2")).toBe(true);
expect(ws().likeCounts.c2).toBe(3);
- expect(ws().lastLikeEvent?.commentId).toBe("c2");
});
});
diff --git a/src/test/hooks/dump/useDumpListSync.test.tsx b/src/test/hooks/dump/useDumpListSync.test.tsx
index 9b33e8f0..bf3abe54 100644
--- a/src/test/hooks/dump/useDumpListSync.test.tsx
+++ b/src/test/hooks/dump/useDumpListSync.test.tsx
@@ -72,27 +72,10 @@ describe("useDumpListSync", () => {
expect(owner.hook.result.current.items[0].isPrivate).toBe(true);
});
- test("re-inserts only the owner's dumps, and only when asked", () => {
- const mine = makeDump({ id: "m", userId: "me" });
- const theirs = makeDump({ id: "t", userId: "them" });
-
- const none = setup([]);
- none.emit({ lastDumpEvent: mine });
- expect(none.hook.result.current.items).toEqual([]);
-
- const owned = setup([], { ownerId: "me" });
- owned.emit({ lastDumpEvent: theirs });
- expect(owned.hook.result.current.items).toEqual([]);
- owned.emit({ lastDumpEvent: mine });
- expect(ids(owned.hook.result.current.items)).toEqual(["m"]);
-
- const skip = setup([], { ownerId: "me", skipReinsert: true });
- skip.emit({ lastDumpEvent: mine });
- expect(skip.hook.result.current.items).toEqual([]);
-
- const privateOne = setup([], { ownerId: "me" });
- privateOne.emit({ lastDumpEvent: { ...mine, isPrivate: true } });
- expect(privateOne.hook.result.current.items).toEqual([]);
+ test("never adds a dump the list doesn't hold", () => {
+ const { hook, emit } = setup([]);
+ emit({ lastDumpEvent: makeDump({ id: "m", userId: "me" }) });
+ expect(hook.result.current.items).toEqual([]);
});
test("comment events move the count, never below zero; edits don't", () => {
diff --git a/src/test/hooks/feed/usePositionAwareSync.test.tsx b/src/test/hooks/feed/usePositionAwareSync.test.tsx
index efa329b2..6c207056 100644
--- a/src/test/hooks/feed/usePositionAwareSync.test.tsx
+++ b/src/test/hooks/feed/usePositionAwareSync.test.tsx
@@ -10,6 +10,7 @@ import type { Dump } from "../../../../shared/dump.ts";
import type { Playlist } from "../../../../shared/playlist.ts";
import { useDumpListSync } from "../../../hooks/dump/useDumpListSync.ts";
import { usePositionAwareSync } from "../../../hooks/feed/usePositionAwareSync.ts";
+import { useWS } from "../../../hooks/ws/useWS.ts";
import { makeDump, makePlaylist } from "../../fixtures.ts";
/** A WS context whose value a test can push new events into. */
@@ -42,14 +43,8 @@ describe("usePositionAwareSync", () => {
const both = renderHook(() => {
const { items, setItems } = useList(initial);
const [event, setEvent] = useState(null);
- usePositionAwareSync(
- items,
- setItems,
- event,
- (d) => d.isPrivate,
- (d) => !d.isPrivate,
- );
- useDumpListSync(setItems, { ownerId: "me", skipReinsert: true });
+ usePositionAwareSync(items, setItems, event, (d) => !d.isPrivate);
+ useDumpListSync(setItems);
return { items, setEvent };
}, { wrapper: ws.Wrapper });
@@ -63,30 +58,40 @@ describe("usePositionAwareSync", () => {
expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]);
});
+ test("a dump that goes private for others, as a deletion, comes back where it was", () => {
+ const ws = liveWS();
+ const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" }));
+ const both = renderHook(() => {
+ const { items, setItems } = useList(initial);
+ const { lastDumpEvent } = useWS();
+ usePositionAwareSync(items, setItems, lastDumpEvent, (d) => !d.isPrivate);
+ useDumpListSync(setItems);
+ return { items };
+ }, { wrapper: ws.Wrapper });
+ ws.emit({ deletedDumpIds: new Set(["b"]) });
+ expect(ids(both.result.current.items)).toEqual(["a", "c"]);
+ ws.emit({ deletedDumpIds: new Set(), lastDumpEvent: initial[1] });
+ expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]);
+ });
+
test("a position is only used once, and clamps to the list end", () => {
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
const hook = renderHook(() => {
const { items, setItems } = useList(initial);
const [event, setEvent] = useState(null);
- usePositionAwareSync(
- items,
- setItems,
- event,
- (p) => !p.isPublic,
- (p) => p.isPublic,
- );
+ usePositionAwareSync(items, setItems, event, (p) => p.isPublic);
return { items, setItems, setEvent };
});
const c = initial[2];
- act(() => hook.result.current.setEvent({ ...c, isPublic: false }));
+ // It leaves, whatever takes it out, then the list shrinks further.
+ act(() => hook.result.current.setItems((prev) => prev.slice(0, 2)));
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
act(() => hook.result.current.setEvent({ ...c, isPublic: true }));
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
- // Nothing saved any more: a second public event is left alone.
- act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
+ // Used once: the same event again adds nothing.
act(() =>
hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })
);
- expect(ids(hook.result.current.items)).toEqual(["a"]);
+ expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
});
});
diff --git a/src/test/hooks/playlist/usePlaylistListSync.test.tsx b/src/test/hooks/playlist/usePlaylistListSync.test.tsx
index 6d813256..575a6ffb 100644
--- a/src/test/hooks/playlist/usePlaylistListSync.test.tsx
+++ b/src/test/hooks/playlist/usePlaylistListSync.test.tsx
@@ -87,7 +87,7 @@ describe("usePlaylistListSync", () => {
expect(followed.hook.result.current.items).toEqual([]);
});
- test("updated: in place, removed when private, re-added when public", () => {
+ test("updated: in place, removed when private, never re-added by itself", () => {
const p = makePlaylist({ id: "p", title: "one" });
const { hook, emit } = setup([p]);
emit({
@@ -113,18 +113,8 @@ describe("usePlaylistListSync", () => {
playlist: { ...p, title: "back" },
},
});
- expect(hook.result.current.items[0].title).toBe("back");
- });
-
- test("updated and not in the list: skipReinsert and noNewEntries hold it back", () => {
- const p = makePlaylist({ id: "p" });
- for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
- const { hook, emit } = setup([], opts);
- emit({
- lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p },
- });
- expect(hook.result.current.items).toEqual([]);
- }
+ // usePositionAwareSync puts it back, where it was.
+ expect(hook.result.current.items).toEqual([]);
});
test("deleted and deletedPlaylistIds both remove", () => {
diff --git a/src/test/pages/Dump.test.tsx b/src/test/pages/Dump.test.tsx
index 816497ee..65ac12db 100644
--- a/src/test/pages/Dump.test.tsx
+++ b/src/test/pages/Dump.test.tsx
@@ -328,6 +328,16 @@ describe("DumpEdit page", () => {
},
});
+ test("someone else's dump gets no form", async () => {
+ openEdit(urlDump(), other);
+ expect(
+ await screen.findByText(
+ "Only its poster or a moderator can edit this dump.",
+ ),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ });
+
test("saves title, note, categories and visibility", async () => {
route(
"PUT /api/dumps/:id",
@@ -466,6 +476,33 @@ describe("DumpEdit page", () => {
expect(calls("PUT /api/dumps/:id")).toHaveLength(0);
});
+ test("a replacement over the limit is refused before any upload", async () => {
+ const fileDump = makeDump({
+ id: "f1",
+ kind: "file",
+ title: "Song",
+ userId: "owner",
+ fileName: "song.mp3",
+ fileMime: "audio/mpeg",
+ fileSize: 10,
+ url: undefined,
+ });
+ const user = openEdit(fileDump);
+ await screen.findByText("song.mp3");
+ const big = new File([new Uint8Array(3)], "big.mp3", {
+ type: "audio/mpeg",
+ });
+ Object.defineProperty(big, "size", { value: 60 * 1024 * 1024 });
+ await user.upload(
+ document.querySelector('.fdz input[type="file"]')!,
+ big,
+ );
+ expect(await screen.findByText("That file is over 50 MB."))
+ .toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(calls("PUT /api/dumps/:id/file")).toHaveLength(0);
+ });
+
test("deleting asks, then goes home", async () => {
route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 }));
const user = openEdit(urlDump());
diff --git a/src/test/pages/Index.test.tsx b/src/test/pages/Index.test.tsx
index 173d2bf4..723d9b7f 100644
--- a/src/test/pages/Index.test.tsx
+++ b/src/test/pages/Index.test.tsx
@@ -157,7 +157,10 @@ describe("Index feeds", () => {
route("GET /api/dumps/", () => page([older]));
setup("/~/new");
await screen.findByText("Old but loved");
- expect(screen.getByRole("button", { name: "Upvote" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Upvote" })).toHaveAttribute(
+ "aria-disabled",
+ "true",
+ );
});
test("a reconnect backfills what was missed", async () => {
diff --git a/src/test/pages/Notifications.test.tsx b/src/test/pages/Notifications.test.tsx
index 09ffdd21..f4594c4b 100644
--- a/src/test/pages/Notifications.test.tsx
+++ b/src/test/pages/Notifications.test.tsx
@@ -250,6 +250,12 @@ describe("Notifications", () => {
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent("bob upvoted Pic");
expect(items[0]).toHaveClass("notification-item--unread");
+ // The read-all after the load covered them: none is sent again.
+ await waitFor(() =>
+ expect(calls("POST /api/notifications/read-all")).toHaveLength(1)
+ );
+ await new Promise((r) => setTimeout(r, 50));
+ expect(calls("POST /api/notifications/read-all")).toHaveLength(1);
});
test("load more", async () => {
diff --git a/src/test/pages/UserPublicProfile.test.tsx b/src/test/pages/UserPublicProfile.test.tsx
index 2783c725..4f1b2f43 100644
--- a/src/test/pages/UserPublicProfile.test.tsx
+++ b/src/test/pages/UserPublicProfile.test.tsx
@@ -3,7 +3,7 @@ import { screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { UserPublicProfile } from "../../pages/UserPublicProfile.tsx";
import type { PublicUser } from "../../../shared/user.ts";
-import { calls, fail, route } from "../fetch.ts";
+import { calls, deferred, fail, route } from "../fetch.ts";
import {
makeCategory,
makeDump,
@@ -127,6 +127,19 @@ describe("UserPublicProfile", () => {
.toBeInTheDocument();
});
+ test("followed: leaving the tab while it loads still loads it", async () => {
+ const users = deferred();
+ route("GET /api/users/:u/followed-users", () => users.promise);
+ route("GET /api/users/:u/followed-playlists", () => page([]));
+ const { user } = open({ url: "/users/ann/~/followed" });
+ await screen.findByRole("heading", { name: "ann" });
+ await user.click(screen.getByRole("button", { name: "Playlists" }));
+ users.resolve(page([makeUser({ username: "bob" })]));
+ await user.click(screen.getByRole("button", { name: "Followed" }));
+ expect(await screen.findByRole("link", { name: /@bob/ }))
+ .toBeInTheDocument();
+ });
+
test("invitees as a tree", async () => {
route("GET /api/users/:u/invitees", () =>
wire([
diff --git a/src/test/utils/shared/charCount.test.ts b/src/test/utils/shared/charCount.test.ts
index 239cdca5..a4014367 100644
--- a/src/test/utils/shared/charCount.test.ts
+++ b/src/test/utils/shared/charCount.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
-import { charCountClass, overLimit } from "../../../utils/shared/charCount.ts";
+import { charCountClass } from "../../../utils/shared/charCount.ts";
describe("character limits", () => {
test("charCountClass warns from 85% and flags the limit", () => {
@@ -7,8 +7,4 @@ describe("character limits", () => {
expect(charCountClass(85, 100)).toBe(" text-editor-count--warn");
expect(charCountClass(100, 100)).toBe(" text-editor-count--danger");
});
- test("overLimit is strictly past it", () => {
- expect(overLimit("abc", 3)).toBe(false);
- expect(overLimit("abcd", 3)).toBe(true);
- });
});
diff --git a/src/test/utils/shared/format.test.ts b/src/test/utils/shared/format.test.ts
index adb88396..ba57377b 100644
--- a/src/test/utils/shared/format.test.ts
+++ b/src/test/utils/shared/format.test.ts
@@ -5,10 +5,10 @@ import {
} from "../../../utils/shared/format.ts";
describe("formatBytes", () => {
- test("B, KB and MB", () => {
- expect(formatBytes(512)).toBe("512 B");
- expect(formatBytes(1536)).toBe("1.5 KB");
- expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
+ test("bytes, kilobytes and megabytes, in the app's language", () => {
+ expect(formatBytes(512)).toBe("512B");
+ expect(formatBytes(1536)).toBe("1.5 kB");
+ expect(formatBytes(5 * 1024 * 1024)).toBe("5 MB");
});
});
diff --git a/src/test/utils/shared/sound.test.ts b/src/test/utils/shared/sound.test.ts
index e1be94bd..b0f8b18b 100644
--- a/src/test/utils/shared/sound.test.ts
+++ b/src/test/utils/shared/sound.test.ts
@@ -38,6 +38,11 @@ async function load() {
return await import("../../../utils/shared/sound.ts");
}
+// playChime claims the event asynchronously, under a lock shared by tabs.
+const settle = async () => {
+ for (let i = 0; i < 3; i++) await Promise.resolve();
+};
+
const notes = () =>
FakeAudioContext.instances.reduce((n, c) => n + c.oscillators, 0);
@@ -71,8 +76,10 @@ describe("playChime", () => {
test("a notification plays two notes, a chat message one", async () => {
const s = await load();
s.playChime("notify", "n1");
+ await settle();
expect(notes()).toBe(2);
s.playChime("chat", "c1");
+ await settle();
expect(notes()).toBe(3);
});
@@ -80,13 +87,16 @@ describe("playChime", () => {
const s = await load();
s.writeSoundsEnabled(false);
s.playChime("notify", "n1");
+ await settle();
expect(notes()).toBe(0);
});
test("one chime per event across tabs", async () => {
const s = await load();
s.playChime("notify", "same");
+ await settle();
s.playChime("notify", "same");
+ await settle();
expect(notes()).toBe(2);
expect(localStorage.getItem("sounds-last-claim")).toBe("notify:same");
});
@@ -94,19 +104,24 @@ describe("playChime", () => {
test("chat blips are throttled", async () => {
const s = await load();
s.playChime("chat", "a");
+ await settle();
s.playChime("chat", "b");
+ await settle();
expect(notes()).toBe(1);
vi.advanceTimersByTime(4_001);
s.playChime("chat", "c");
+ await settle();
expect(notes()).toBe(2);
});
test("a suspended context stays quiet until a gesture unlocks it", async () => {
const s = await load();
s.playChime("notify", "warmup"); // creates the context
+ await settle();
const ctx = FakeAudioContext.instances[0];
ctx.state = "suspended";
s.playChime("notify", "n2");
+ await settle();
expect(ctx.oscillators).toBe(2);
globalThis.dispatchEvent(new Event("keydown"));
expect(ctx.resume).toHaveBeenCalled();
diff --git a/src/test/utils/shared/thumbnailTint.test.ts b/src/test/utils/shared/thumbnailTint.test.ts
index fbd532bc..eba34db9 100644
--- a/src/test/utils/shared/thumbnailTint.test.ts
+++ b/src/test/utils/shared/thumbnailTint.test.ts
@@ -1,37 +1,33 @@
import { describe, expect, test } from "vitest";
-import {
- hueFromString,
- initialsFor,
- isSafeHex,
- tintFor,
-} from "../../../utils/shared/thumbnailTint.ts";
+import { initialsFor, tintFor } from "../../../utils/shared/thumbnailTint.ts";
+
+const hue = (tint: string) => Number(/hsl\((\d+)/.exec(tint)?.[1]);
describe("thumbnail tint", () => {
- test("isSafeHex accepts only canonical #rrggbb", () => {
- expect(isSafeHex("#a1b2c3")).toBe(true);
- expect(isSafeHex("#A1B2C3")).toBe(false);
- expect(isSafeHex("#abc")).toBe(false);
- expect(isSafeHex("red")).toBe(false);
- expect(isSafeHex("#000000;background:url(x)")).toBe(false);
- expect(isSafeHex(undefined)).toBe(false);
+ test("tintFor takes only a canonical #rrggbb page colour", () => {
+ const url = "https://a.b";
+ expect(tintFor({ accentColor: "#a1b2c3", url })).toBe("#a1b2c3");
+ for (
+ const unsafe of [
+ "#A1B2C3",
+ "#abc",
+ "red",
+ "#000000;background:url(x)",
+ undefined,
+ ]
+ ) {
+ expect(tintFor({ accentColor: unsafe, url })).toMatch(/^hsl\(/);
+ }
});
- test("hueFromString is stable and in range", () => {
- expect(hueFromString("example.com")).toBe(hueFromString("example.com"));
- const hue = Number(/hsl\((\d+)/.exec(hueFromString("x"))![1]);
- expect(hue).toBeGreaterThanOrEqual(0);
- expect(hue).toBeLessThan(360);
- });
-
- test("tintFor prefers the page colour, then the hostname sans www", () => {
- expect(tintFor({ accentColor: "#112233", url: "https://a.b" })).toBe(
- "#112233",
- );
- expect(tintFor({ accentColor: "evil", url: "https://www.a.b/x" })).toBe(
- hueFromString("a.b"),
- );
- expect(tintFor({ seed: "file.mp3" })).toBe(hueFromString("file.mp3"));
- expect(tintFor({ url: "not a url" })).toBe(hueFromString("not a url"));
+ test("tintFor falls back to a stable hue of the seed or the hostname sans www", () => {
+ const fromHost = tintFor({ url: "https://www.a.b/x" });
+ expect(tintFor({ url: "https://a.b/y" })).toBe(fromHost);
+ expect(tintFor({ seed: "a.b" })).toBe(fromHost);
+ expect(tintFor({ seed: "file.mp3" })).toBe(tintFor({ seed: "file.mp3" }));
+ expect(tintFor({ url: "not a url" })).toBe(tintFor({ seed: "not a url" }));
+ expect(hue(fromHost)).toBeGreaterThanOrEqual(0);
+ expect(hue(fromHost)).toBeLessThan(360);
});
test("initialsFor takes the first letter or digit", () => {
diff --git a/src/test/utils/shared/visited.test.ts b/src/test/utils/shared/visited.test.ts
index d8957193..1ebb992f 100644
--- a/src/test/utils/shared/visited.test.ts
+++ b/src/test/utils/shared/visited.test.ts
@@ -24,6 +24,19 @@ describe("visited", () => {
expect(isDumpVisited("a")).toBe(true);
});
+ test("the old list format still counts, and week-old visits are dropped", () => {
+ const day = 24 * 3600 * 1000;
+ localStorage.setItem("visited_dumps", JSON.stringify(["old"]));
+ expect(isDumpVisited("old")).toBe(true);
+ localStorage.setItem(
+ "visited_dumps",
+ JSON.stringify({ stale: Date.now() - 8 * day, fresh: Date.now() }),
+ );
+ markDumpVisited("new");
+ expect(Object.keys(JSON.parse(localStorage.getItem("visited_dumps")!)))
+ .toEqual(["fresh", "new"]);
+ });
+
test("isRecent is a seven-day window", () => {
const day = 24 * 3600 * 1000;
expect(isRecent(new Date(Date.now() - 6 * day))).toBe(true);
diff --git a/src/themes/brutalist.css b/src/themes/brutalist.css
index fb9cc8bc..adcc135d 100644
--- a/src/themes/brutalist.css
+++ b/src/themes/brutalist.css
@@ -127,7 +127,7 @@
[data-style="brutalist"] .modal-close-btn:hover,
[data-style="brutalist"] .playlist-remove-btn:hover,
[data-style="brutalist"] .playlist-card-delete-btn:hover,
-[data-style="brutalist"] .vote-btn:hover:not(:disabled),
+[data-style="brutalist"] .vote-btn:hover:not([aria-disabled="true"]),
[data-style="brutalist"] .emoji-picker-float [frimousse-emoji]:hover,
[data-style="brutalist"] .emoji-picker-close-btn:hover,
[data-style="brutalist"] .text-editor-emoji-btn:hover:not(:disabled),
diff --git a/src/themes/kanagawa.css b/src/themes/kanagawa.css
index 3e2a0f0b..a688a622 100644
--- a/src/themes/kanagawa.css
+++ b/src/themes/kanagawa.css
@@ -171,7 +171,7 @@
--journal-title-hover-shadow: inset 2px 0 0 var(--kngw-accent-on-photo);
}
-[data-style="kanagawa"] .journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not(:disabled) {
+[data-style="kanagawa"] .journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not([aria-disabled="true"]) {
color: var(--kngw-accent-on-photo);
border-color: var(--kngw-accent-on-photo);
}
diff --git a/src/utils/shared/api.ts b/src/utils/shared/api.ts
index 062cb7b5..33ea0308 100644
--- a/src/utils/shared/api.ts
+++ b/src/utils/shared/api.ts
@@ -5,6 +5,7 @@
* @module
*/
+import { t } from "@lingui/core/macro";
import type { APIResponse } from "../../../shared/api.ts";
/**
@@ -17,19 +18,20 @@ export function parseAPIResponse(raw: unknown): APIResponse {
if (raw !== null && typeof raw === "object" && "success" in raw) {
return raw as APIResponse;
}
- throw new Error("Unexpected response format");
+ throw new Error(t`Unexpected response format`);
}
/**
* Returns the message to show for `err`, an error caught around a request. A
* `TypeError` means the request never reached the server (no network, CORS),
- * and gets a message saying so. The messages are not translated.
+ * and gets a message saying so. The server's own messages come through as
+ * they are.
*/
export function friendlyFetchError(err: unknown): string {
if (err instanceof TypeError) {
- return "Could not connect to the server. Check your connection and try again.";
+ return t`Could not connect to the server. Check your connection and try again.`;
}
- return err instanceof Error ? err.message : "An unexpected error occurred.";
+ return err instanceof Error ? err.message : t`An unexpected error occurred.`;
}
/**
diff --git a/src/utils/shared/charCount.ts b/src/utils/shared/charCount.ts
index 3731b406..5ea0e664 100644
--- a/src/utils/shared/charCount.ts
+++ b/src/utils/shared/charCount.ts
@@ -15,11 +15,3 @@ export function charCountClass(len: number, max: number): string {
if (len >= max * 0.85) return " text-editor-count--warn";
return "";
}
-
-/**
- * Checks whether `value` is longer than `max`, which the server would refuse.
- * Only the tests call it.
- */
-export function overLimit(value: string, max: number): boolean {
- return value.length > max;
-}
diff --git a/src/utils/shared/format.ts b/src/utils/shared/format.ts
index d9bdd818..de372f7b 100644
--- a/src/utils/shared/format.ts
+++ b/src/utils/shared/format.ts
@@ -5,14 +5,26 @@
* @module
*/
+import { i18n } from "@lingui/core";
+
/**
- * Returns `bytes` as a size in B, KB or MB, where a KB is 1024 bytes. The units
- * are not translated.
+ * Returns `bytes` as a size in bytes, kilobytes or megabytes, where a
+ * kilobyte is 1024 bytes, in the app's language ("1.5 kB", "1,5 ko").
+ * `Intl` names the units, so they follow the language without a catalog.
*/
export function formatBytes(bytes: number): string {
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ const [value, unit] = bytes < 1024
+ ? [bytes, "byte"]
+ : bytes < 1024 * 1024
+ ? [bytes / 1024, "kilobyte"]
+ : [bytes / (1024 * 1024), "megabyte"];
+ return new Intl.NumberFormat(i18n.locale, {
+ style: "unit",
+ unit,
+ // English's short form of a byte is "byte", even for 512.
+ unitDisplay: unit === "byte" ? "narrow" : "short",
+ maximumFractionDigits: 1,
+ }).format(value);
}
/**
diff --git a/src/utils/shared/relativeTime.ts b/src/utils/shared/relativeTime.ts
index 88c9ef0d..35ad0d46 100644
--- a/src/utils/shared/relativeTime.ts
+++ b/src/utils/shared/relativeTime.ts
@@ -10,12 +10,13 @@ import { i18n } from "@lingui/core";
/**
* Returns how long ago `date` was, as "3 minutes ago", in the current locale.
* It is computed at render, so the text only moves on when the caller
- * re-renders. A month counts 30 days and a year 365.
+ * re-renders. A month counts 30 days and a year 365. Every date shown is of
+ * something that happened, so one in the future, which only a clock ahead of
+ * this one can give, reads as "now".
*/
export function relativeTime(date: Date): string {
const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
- const diff = date.getTime() - Date.now();
- const abs = Math.abs(diff) / 1000;
+ const abs = Math.max(0, Date.now() - date.getTime()) / 1000;
if (abs < 60) return rtf.format(-Math.round(abs), "second");
if (abs < 3600) return rtf.format(-Math.round(abs / 60), "minute");
diff --git a/src/utils/shared/sound.ts b/src/utils/shared/sound.ts
index dd5f115f..eddbd46b 100644
--- a/src/utils/shared/sound.ts
+++ b/src/utils/shared/sound.ts
@@ -75,11 +75,8 @@ function unlock() {
globalThis.addEventListener("pointerdown", unlock);
globalThis.addEventListener("keydown", unlock);
-/**
- * Claims `eventId` for this tab, and returns whether no other tab had. Two tabs
- * checking at the same instant can both win.
- */
-function claim(eventId: string): boolean {
+/** Checks `CLAIM_KEY` and sets it to `eventId`, in one step or not at all. */
+function checkAndSet(eventId: string): boolean {
try {
if (localStorage.getItem(CLAIM_KEY) === eventId) return false;
localStorage.setItem(CLAIM_KEY, eventId);
@@ -89,6 +86,17 @@ function claim(eventId: string): boolean {
return true;
}
+/**
+ * Claims `eventId` for this tab, and resolves to whether no other tab had. The
+ * check and the write run under a lock shared by the site's tabs, so two tabs
+ * can't both win; a browser without Web Locks goes without it.
+ */
+function claim(eventId: string): Promise {
+ const locks = globalThis.navigator?.locks;
+ if (!locks) return Promise.resolve(checkAndSet(eventId));
+ return locks.request(CLAIM_KEY, () => checkAndSet(eventId));
+}
+
/**
* Plays one note of `freq` Hz on `ac`, from `start` for `duration` seconds,
* peaking at gain `peak`.
@@ -141,8 +149,9 @@ export function playChime(kind: ChimeKind, eventId: string): void {
if (now - lastChatAt < CHAT_THROTTLE) return;
lastChatAt = now;
}
- if (!claim(`${kind}:${eventId}`)) return;
- render(kind);
+ void claim(`${kind}:${eventId}`).then((won) => {
+ if (won) render(kind);
+ });
}
/**
diff --git a/src/utils/shared/thumbnailTint.ts b/src/utils/shared/thumbnailTint.ts
index 05f3e579..242198b8 100644
--- a/src/utils/shared/thumbnailTint.ts
+++ b/src/utils/shared/thumbnailTint.ts
@@ -12,7 +12,7 @@
* property is substituted verbatim wherever it is used, unparsed. So the form
* is checked here before it reaches a style.
*/
-export function isSafeHex(value: string | undefined): value is string {
+function isSafeHex(value: string | undefined): value is string {
return !!value && /^#[0-9a-f]{6}$/.test(value);
}
@@ -33,7 +33,7 @@ function hashString(seed: string): number {
* Returns a colour of a hue drawn from `seed`, so that the same site always
* gets the same tint.
*/
-export function hueFromString(seed: string): string {
+function hueFromString(seed: string): string {
return `hsl(${hashString(seed) % 360} 55% 55%)`;
}
diff --git a/src/utils/shared/visited.ts b/src/utils/shared/visited.ts
index e7695b97..6a87f292 100644
--- a/src/utils/shared/visited.ts
+++ b/src/utils/shared/visited.ts
@@ -10,32 +10,65 @@ const PLAYLIST_KEY = "visited_playlists";
/** The age past which an item shows no unread dot, visited or not. */
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
-function loadSet(key: string): Set {
- try {
- const raw = localStorage.getItem(key);
- return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
- } catch {
- return new Set();
- }
-}
+// Each key's visits, id to epoch ms, with the stored string they were parsed
+// from: reading the string is cheap, and it is parsed again only when it
+// changed (another tab, or cleared storage).
+const cache = new Map<
+ string,
+ { raw: string | null; visits: Map }
+>();
-function saveSet(key: string, set: Set): void {
+function load(key: string): Map {
+ let raw: string | null;
try {
- localStorage.setItem(key, JSON.stringify([...set]));
- } catch { /* With storage full or blocked, the visit is forgotten. */ }
+ raw = localStorage.getItem(key);
+ } catch {
+ // Blocked storage: whatever this page recorded.
+ return cache.get(key)?.visits ?? new Map();
+ }
+ const cached = cache.get(key);
+ if (cached && cached.raw === raw) return cached.visits;
+ let visits = new Map();
+ try {
+ const parsed = JSON.parse(raw ?? "{}") as Record | string[];
+ // A list from before visits had times counts as visited now.
+ visits = Array.isArray(parsed)
+ ? new Map(parsed.map((id) => [id, Date.now()]))
+ : new Map(Object.entries(parsed));
+ } catch {
+ // Unreadable: nothing visited.
+ }
+ cache.set(key, { raw, visits });
+ return visits;
}
/**
- * Checks whether dump `id` was opened in this browser. It parses the whole
- * stored list on every call.
+ * Records `id` as visited under `key`, and drops the visits older than 7 days:
+ * an item was at most that old when visited, so past then it shows no dot
+ * anyway, and the list stays small.
*/
+function record(key: string, id: string): void {
+ const visits = load(key);
+ const now = Date.now();
+ visits.set(id, now);
+ for (const [visitedId, at] of visits) {
+ if (now - at > MAX_AGE_MS) visits.delete(visitedId);
+ }
+ const raw = JSON.stringify(Object.fromEntries(visits));
+ cache.set(key, { raw, visits });
+ try {
+ localStorage.setItem(key, raw);
+ } catch { /* With storage full or blocked, the visit lasts until reload. */ }
+}
+
+/** Checks whether dump `id` was opened in this browser. */
export function isDumpVisited(id: string): boolean {
- return loadSet(DUMP_KEY).has(id);
+ return load(DUMP_KEY).has(id);
}
/** Checks whether playlist `id` was opened in this browser. */
export function isPlaylistVisited(id: string): boolean {
- return loadSet(PLAYLIST_KEY).has(id);
+ return load(PLAYLIST_KEY).has(id);
}
/**
@@ -43,16 +76,12 @@ export function isPlaylistVisited(id: string): boolean {
* keeps its dot until it renders again.
*/
export function markDumpVisited(id: string): void {
- const set = loadSet(DUMP_KEY);
- set.add(id);
- saveSet(DUMP_KEY, set);
+ record(DUMP_KEY, id);
}
/** Records playlist `id` as opened, as {@link markDumpVisited} does a dump. */
export function markPlaylistVisited(id: string): void {
- const set = loadSet(PLAYLIST_KEY);
- set.add(id);
- saveSet(PLAYLIST_KEY, set);
+ record(PLAYLIST_KEY, id);
}
/**