diff --git a/api/routes/comments.ts b/api/routes/comments.ts index e21f876b..ee358ffe 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -1,7 +1,7 @@ /** - * Serves the comments on dumps. The comments of a private dump 404 for anyone - * but its poster, and changes to them aren't broadcast. Comments are otherwise - * broadcast over the WebSocket as they are posted, edited and deleted. + * Serves the comments on dumps. Comments are broadcast over the WebSocket as + * they are posted, edited and deleted, to everyone, or to its poster alone for + * a private dump, whose comments 404 for anyone else. * * - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment * on a dump, oldest first. @@ -76,7 +76,7 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => { body.body, body.parentId ?? undefined, ); - if (!dump.isPrivate) broadcastCommentCreated(comment); + broadcastCommentCreated(comment, dump); const responseBody: APIResponse = { success: true, data: comment }; ctx.response.status = 201; ctx.response.body = responseBody; @@ -89,13 +89,13 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => { if (!isUpdateCommentRequest(body)) { throw new APIException(400, "Invalid comment data"); } - const { comment, isPrivate } = updateComment( + const { comment, dump } = updateComment( ctx.params.commentId, body.body, userId, canModerate, ); - if (!isPrivate) broadcastCommentUpdated(comment); + broadcastCommentUpdated(comment, dump); const responseBody: APIResponse = { success: true, data: comment }; ctx.response.body = responseBody; }); @@ -123,12 +123,12 @@ router.get("/comments/:commentId/likers", async (ctx) => { router.delete("/comments/:commentId", authMiddleware, (ctx) => { const userId = ctx.state.user.userId; const canModerate = can(ctx.state.user, "comment:moderate"); - const { dumpId, isPrivate } = deleteComment( + const dump = deleteComment( ctx.params.commentId, userId, canModerate, ); - if (!isPrivate) broadcastCommentDeleted(ctx.params.commentId, dumpId); + broadcastCommentDeleted(ctx.params.commentId, dump); const responseBody: APIResponse = { success: true, data: null }; ctx.response.body = responseBody; }); diff --git a/api/services/category-service.ts b/api/services/category-service.ts index 0ff6ffd9..0aa8ac26 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -28,6 +28,7 @@ import { type Page, pageOf } from "../lib/pagination.ts"; import { isReservedSlug } from "../lib/reserved-slugs.ts"; import { validateSlug } from "../lib/slugify.ts"; import { VALIDATION } from "../../shared/validation.ts"; +import { broadcastCategoriesChanged } from "./ws-service.ts"; import { parseRows } from "../model/rows.ts"; /** @@ -176,7 +177,8 @@ export function listCategories(): Category[] { /** * Creates a category, for `POST /api/categories`. The name is stored trimmed, - * and the position defaults to 0. + * and the position defaults to 0. Every client is told to fetch the + * categories again, as after the two writes below. * * @throws {APIException} 400 when the slug or name is invalid, 409 when * either is taken within its kind. @@ -202,6 +204,7 @@ export function createCategory(req: CreateCategoryRequest): Category { } catch (err) { uniqueConflict(err, req.slug, name); } + broadcastCategoriesChanged(); return { id, @@ -249,6 +252,7 @@ export function updateCategory( } catch (err) { uniqueConflict(err, newSlug, newName); } + broadcastCategoriesChanged(); return { ...existing, @@ -270,6 +274,7 @@ export function deleteCategory(id: string): void { if (result.changes === 0) { throw new APIException(404, "Category not found"); } + broadcastCategoriesChanged(); } /** diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts index d42e8279..2e96a9ef 100644 --- a/api/services/comment-service.ts +++ b/api/services/comment-service.ts @@ -114,12 +114,44 @@ export function createComment( return comment; } +/** + * The dump a comment is on, as {@link updateComment} and {@link deleteComment} + * return it: what the route needs to choose the broadcast's audience. + */ +export interface CommentDump { + id: string; + isPrivate: boolean; + /** The dump's poster, the only one who sees a private dump's comments. */ + userId: string; +} + +/** + * Returns the dump comment `commentId` is on. + * + * @throws {APIException} 404 when there is no such comment. + */ +function commentDump(commentId: string): CommentDump { + const row = db.prepare( + `SELECT d.id, d.is_private, d.user_id FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`, + ).get(commentId) as + | { id: string; is_private: number; user_id: string } + | undefined; + if (!row) { + throw new APIException(404, "Comment not found"); + } + return { + id: row.id, + isPrivate: Boolean(row.is_private), + userId: row.user_id, + }; +} + /** * Replaces a comment's body, for `PATCH /api/comments/:commentId`. * `canModerate` lets someone other than the author do it. It notifies, in the * author's name, the users it mentions for the first time, unless the dump is * private, and relinks the attachments and backlinks. The route broadcasts the - * comment unless `isPrivate`. + * comment to those who may see its `dump`. * * @throws {APIException} 404 when there is no such comment, 400 when it is * deleted, 403 when the user is neither its author nor a moderator. @@ -129,13 +161,8 @@ export function updateComment( body: string, requestingUserId: string, canModerate: boolean, -): { comment: Comment; dumpId: string; isPrivate: boolean } { - const row = db.prepare( - `SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`, - ).get(commentId) as { dump_id: string; is_private: number } | undefined; - if (!row) { - throw new APIException(404, "Comment not found"); - } +): { comment: Comment; dump: CommentDump } { + const dump = commentDump(commentId); const existing = fetchComment(commentId); if (existing.deleted) { throw new APIException(400, "Cannot edit a deleted comment"); @@ -149,9 +176,9 @@ export function updateComment( now, commentId, ); - if (!row.is_private) { + if (!dump.isPrivate) { const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get( - row.dump_id, + dump.id, ) as { title: string } | undefined; // A moderator's edit keeps the author's words, so the author mentions. notifyMentions( @@ -160,23 +187,19 @@ export function updateComment( "comment", commentId, dumpRow?.title ?? "", - row.dump_id, + dump.id, ); } linkAttachments(body, "comment", commentId); - relinkBacklinks("comment", commentId, row.dump_id, body); - return { - comment: fetchComment(commentId), - dumpId: row.dump_id, - isPrivate: Boolean(row.is_private), - }; + relinkBacklinks("comment", commentId, dump.id, body); + return { comment: fetchComment(commentId), dump }; } /** * Deletes a comment, for `DELETE /api/comments/:commentId`. `canModerate` lets * someone other than the author do it. The body is emptied and the backlinks * dropped, but the row stays so its replies keep their place. The route - * broadcasts the deletion unless `isPrivate`. + * broadcasts the deletion to those who may see the returned dump. * * @throws {APIException} 404 when there is no such comment, 403 when the user * is neither its author nor a moderator. @@ -185,13 +208,8 @@ export function deleteComment( commentId: string, requestingUserId: string, canModerate: boolean, -): { dumpId: string; isPrivate: boolean } { - const row = db.prepare( - `SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`, - ).get(commentId) as { dump_id: string; is_private: number } | undefined; - if (!row) { - throw new APIException(404, "Comment not found"); - } +): CommentDump { + const dump = commentDump(commentId); const comment = fetchComment(commentId); if (comment.userId !== requestingUserId && !canModerate) { throw new APIException(403, "Not authorized to delete this comment"); @@ -199,6 +217,6 @@ export function deleteComment( db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run( commentId, ); - relinkBacklinks("comment", commentId, row.dump_id, ""); - return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) }; + relinkBacklinks("comment", commentId, dump.id, ""); + return dump; } diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index ac6a004f..098f3be8 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -33,6 +33,11 @@ import { } from "./rich-content-service.ts"; import { isValidHttpUrl } from "../remote/fetch.ts"; import { broadcastDumpChange } from "./ws-service.ts"; +import { + broadcastHoldersDumps, + type PlaylistHolder, + playlistsHolding, +} from "./playlist-service.ts"; import { notifyMentions, notifyPlaylistFollowersNowPublic, @@ -86,6 +91,23 @@ function assertFileDumpText(title?: string, comment?: string): void { } } +/** + * Tells clients how a dump changed, for every write of this service: + * `broadcastDumpChange` for the dump itself, and, when it was deleted or + * changed visibility, the dumps of each playlist holding it, which moved for + * their viewers. A deletion passes the `holders` it read before the cascade + * took the dump out of them. + */ +function announceDumpChange( + before: Dump | null, + after: Dump | null, + holders?: PlaylistHolder[], +): void { + broadcastDumpChange(before, after); + if (!before || (after && after.isPrivate === before.isPrivate)) return; + broadcastHoldersDumps(holders ?? playlistsHolding(before.id)); +} + /** * Notifies the followers of a dump that went public, `before` being private * and `after` public: the poster's, as if it were just posted, and those of @@ -149,7 +171,7 @@ export async function createUrlDump( }); const dump: Dump = { ...fetchDump(dumpId), categoryIds }; - broadcastDumpChange(null, dump); + announceDumpChange(null, dump); if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, title); if (request.comment) { // A private dump's mentions are notified if it goes public. @@ -225,7 +247,7 @@ export async function createFileDump( } const dump: Dump = { ...fetchDump(dumpId), categoryIds: dedupedCategoryIds }; - broadcastDumpChange(null, dump); + announceDumpChange(null, dump); if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, finalTitle); if (comment) { // A private dump's mentions are notified if it goes public. @@ -486,7 +508,7 @@ export async function updateDump( } else { attachCategoryIds("dump", [updatedDump]); } - broadcastDumpChange(dump, updatedDump); + announceDumpChange(dump, updatedDump); notifyIfWentPublic(dump, updatedDump); if (updatedDump.comment) { if (!updatedDump.isPrivate) { @@ -558,7 +580,7 @@ export async function updateDump( attachCategoryIds("dump", [updatedDump]); } - broadcastDumpChange(dump, updatedDump); + announceDumpChange(dump, updatedDump); notifyIfWentPublic(dump, updatedDump); if (updatedDump.comment) { if (!updatedDump.isPrivate) { @@ -687,7 +709,7 @@ export async function replaceFileDump( isPrivate: finalPrivate, updatedAt: now, }; - broadcastDumpChange(dump, updatedDump); + announceDumpChange(dump, updatedDump); notifyIfWentPublic(dump, updatedDump); return updatedDump; } @@ -799,7 +821,7 @@ export async function refreshDumpMetadata(dumpId: string): Promise { `UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`, ).run(row.title, row.rich_content, row.id); - broadcastDumpChange(dump, updatedDump); + announceDumpChange(dump, updatedDump); return updatedDump; } @@ -826,7 +848,7 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump { dump.id, ); const updatedDump: Dump = { ...dump, thumbnailMime: mime }; - broadcastDumpChange(dump, updatedDump); + announceDumpChange(dump, updatedDump); return updatedDump; } @@ -842,18 +864,20 @@ export async function removeDumpThumbnail(dumpId: string): Promise { .run(dump.id); await Deno.remove(customThumbnailPath(dump.id)).catch(() => {}); const updatedDump: Dump = { ...dump, thumbnailMime: undefined }; - broadcastDumpChange(dump, updatedDump); + announceDumpChange(dump, updatedDump); return updatedDump; } /** * Deletes a dump with its files and thumbnails, for - * `DELETE /api/dumps/:dumpId`, and broadcasts `dump_deleted`. + * `DELETE /api/dumps/:dumpId`, and broadcasts `dump_deleted` and the new + * dumps of the playlists that held it. * * @throws {APIException} 404 when there is no such dump. */ export async function deleteDump(dumpId: string): Promise { const dump = fetchDump(dumpId); + const holders = playlistsHolding(dumpId); const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId); @@ -869,5 +893,5 @@ export async function deleteDump(dumpId: string): Promise { await Deno.remove(customThumbnailPath(dump.id)).catch(() => {}); } - broadcastDumpChange(dump, null); + announceDumpChange(dump, null, holders); } diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index e4224f8c..3b5caec8 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -167,12 +167,14 @@ export function getUnreadCount(userId: string): number { /** * Marks every notification of `userId` read, when they open the - * `/notifications` page. + * `/notifications` page, and tells all their sockets, so that the bell clears + * in their other tabs and devices too. */ export function markAllRead(userId: string): void { db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run( userId, ); + sendToUser(userId, { type: "notifications_read" }); } /** diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index e670c693..8f66e73d 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -25,10 +25,8 @@ import { import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { - broadcastPlaylistCreated, - broadcastPlaylistDeleted, + broadcastPlaylistChange, broadcastPlaylistDumpsUpdated, - broadcastPlaylistUpdated, } from "./ws-service.ts"; import { notifyMentions, @@ -138,7 +136,7 @@ export function createPlaylist( } linkAttachments(req.description, "playlist", id); } - broadcastPlaylistCreated(playlist); + broadcastPlaylistChange(null, playlist); return playlist; } @@ -276,7 +274,7 @@ export function listPlaylistsByUser( * playlist is public, it notifies, in the owner's name, the users the * description mentions. When it goes public, its followers are notified of * its public dumps they weren't yet told of. It links the description's - * attachments and broadcasts `playlist_updated`. + * attachments and tells clients of the change (`broadcastPlaylistChange`). * * @throws {APIException} 404 when there is no such playlist, 403 when the user * is neither its owner nor a moderator, 400 when the slug isn't valid or a @@ -349,7 +347,7 @@ export function updatePlaylist( } linkAttachments(newDescription, "playlist", playlist.id); } - broadcastPlaylistUpdated(updated, playlist.isPublic); + broadcastPlaylistChange(playlist, updated); // Its followers weren't told of the dumps added while it was private. if (!playlist.isPublic && newIsPublic) { notifyPlaylistFollowersNowPublic({ playlistId: playlist.id }); @@ -376,7 +374,7 @@ export function deletePlaylist( } db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id); - broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic); + broadcastPlaylistChange(playlist, null); } /** @@ -424,7 +422,7 @@ export function setPlaylistImage( `UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`, ).run(imageMime, new Date().toISOString(), playlist.id); const updated = getPlaylistById(playlist.id); - broadcastPlaylistUpdated(updated, playlist.isPublic); + broadcastPlaylistChange(playlist, updated); return updated; } @@ -676,3 +674,37 @@ function getCurrentMembers(playlistId: string): PlaylistMember[] { userId: r.user_id, })); } + +/** + * A playlist holding a dump, as {@link playlistsHolding} returns it: what + * `broadcastPlaylistDumpsUpdated` needs to choose its audience. + */ +export type PlaylistHolder = Pick; + +/** + * Returns the playlists holding dump `dumpId`. The dump service reads them + * before it deletes the dump or changes its visibility, and passes them to + * {@link broadcastHoldersDumps} after. + */ +export function playlistsHolding(dumpId: string): PlaylistHolder[] { + const rows = db.prepare( + `SELECT p.id, p.is_public, p.user_id + FROM playlist_dumps pd INNER JOIN playlists p ON p.id = pd.playlist_id + WHERE pd.dump_id = ?;`, + ).all(dumpId) as Array<{ id: string; is_public: number; user_id: string }>; + return rows.map((r) => ({ + id: r.id, + isPublic: Boolean(r.is_public), + userId: r.user_id, + })); +} + +/** + * Sends each of `holders` its dumps again, after one of them was deleted or + * changed visibility: the count moved, or what each viewer may see of them. + */ +export function broadcastHoldersDumps(holders: PlaylistHolder[]): void { + for (const holder of holders) { + broadcastPlaylistDumpsUpdated(holder, getCurrentMembers(holder.id)); + } +} diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 5707e9be..c7bf680b 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -146,8 +146,8 @@ function broadcast( /** * Sends `data` to every socket of `userId`. The notification service sends - * `notification_created` with it, the follow service `follows_changed`, and - * the route `chat_read_update`. + * `notification_created` and `notifications_read` 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); @@ -174,43 +174,56 @@ export function broadcastPresence(): void { } /** - * Tells the clients that may see a dump how it changed, for the dump service - * after every write. `before` is null for a new dump, `after` for a deleted - * one. A private dump's changes reach its owner's sockets alone. When a dump - * goes private, everyone else gets `dump_deleted`; when it goes public, they - * get `dump_created`, since to them it is new. + * Sends the messages that tell each client how an item it may see changed, + * for {@link broadcastDumpChange} and {@link broadcastPlaylistChange}. + * `before` is null for a new item, `after` for a deleted one. A private item's + * changes reach its owner's sockets alone. When it goes private, everyone else + * is told it was deleted, and gets nothing of the change; when it goes public, + * they are told it was created, since to them it is new. + */ +function broadcastChange( + before: T | null, + after: T | null, + isPrivate: (item: T) => boolean, + messages: { + created: (item: T) => ServerToClientMessage; + updated: (item: T) => ServerToClientMessage; + deleted: (item: T) => ServerToClientMessage; + }, +): void { + const item = after ?? before; + if (!item) return; + const toOwner = (client: WsClient) => client.userId === item.userId; + const toOthers = (client: WsClient) => client.userId !== item.userId; + + if (!after) { + broadcast(messages.deleted(item), isPrivate(item) ? toOwner : undefined); + } else if (!before) { + broadcast(messages.created(after), isPrivate(after) ? toOwner : undefined); + } else if (isPrivate(after)) { + broadcast(messages.updated(after), toOwner); + if (!isPrivate(before)) broadcast(messages.deleted(after), toOthers); + } else if (isPrivate(before)) { + broadcast(messages.updated(after), toOwner); + broadcast(messages.created(after), toOthers); + } else { + broadcast(messages.updated(after)); + } +} + +/** + * Tells the clients that may see a dump how it changed, as + * {@link broadcastChange} says, for the dump service after every write. */ export function broadcastDumpChange( before: Dump | null, after: Dump | null, ): void { - const dump = after ?? before; - if (!dump) return; - const ownerId = dump.userId; - const toOwner = (client: WsClient) => client.userId === ownerId; - const toOthers = (client: WsClient) => client.userId !== ownerId; - - if (!after) { - broadcast( - { type: "dump_deleted", dumpId: dump.id }, - dump.isPrivate ? toOwner : undefined, - ); - } else if (!before) { - broadcast( - { type: "dump_created", dump: after }, - after.isPrivate ? toOwner : undefined, - ); - } else if (after.isPrivate) { - broadcast({ type: "dump_updated", dump: after }, toOwner); - if (!before.isPrivate) { - broadcast({ type: "dump_deleted", dumpId: after.id }, toOthers); - } - } else if (before.isPrivate) { - broadcast({ type: "dump_updated", dump: after }, toOwner); - broadcast({ type: "dump_created", dump: after }, toOthers); - } else { - broadcast({ type: "dump_updated", dump: after }); - } + broadcastChange(before, after, (dump) => dump.isPrivate, { + created: (dump) => ({ type: "dump_created", dump }), + updated: (dump) => ({ type: "dump_updated", dump }), + deleted: (dump) => ({ type: "dump_deleted", dumpId: dump.id }), + }); } /** @@ -248,50 +261,24 @@ export function broadcastCommentLikeUpdate( ); } -/** Sends `data` to everyone for a public playlist, else to its owner alone. */ -function sendToPlaylistAudience( - playlist: Pick, - data: ServerToClientMessage, -): void { - broadcast( - data, - (client) => playlist.isPublic || client.userId === playlist.userId, - ); -} - /** - * Sends `playlist_created` to the new playlist's audience. The playlist service - * calls it. + * Tells the clients that may see a playlist how it changed, as + * {@link broadcastChange} says, for the playlist service after every write to + * its details. A playlist turned private thus leaves the others' views without + * their learning what it became. */ -export function broadcastPlaylistCreated(playlist: Playlist): void { - sendToPlaylistAudience(playlist, { type: "playlist_created", playlist }); -} - -/** - * Sends `playlist_updated` to the playlist's audience. `wasPublic` is its - * visibility before the change: a playlist that just went private still goes - * to everyone, so that the others can drop it. - */ -export function broadcastPlaylistUpdated( - playlist: Playlist, - wasPublic: boolean, +export function broadcastPlaylistChange( + before: Playlist | null, + after: Playlist | null, ): void { - sendToPlaylistAudience( - { isPublic: playlist.isPublic || wasPublic, userId: playlist.userId }, - { type: "playlist_updated", playlist }, - ); -} - -/** Sends `playlist_deleted` to the audience the playlist had. */ -export function broadcastPlaylistDeleted( - playlistId: string, - userId: string, - isPublic: boolean, -): void { - sendToPlaylistAudience({ isPublic, userId }, { - type: "playlist_deleted", - playlistId, - userId, + broadcastChange(before, after, (playlist) => !playlist.isPublic, { + created: (playlist) => ({ type: "playlist_created", playlist }), + updated: (playlist) => ({ type: "playlist_updated", playlist }), + deleted: (p) => ({ + type: "playlist_deleted", + playlistId: p.id, + userId: p.userId, + }), }); } @@ -301,7 +288,7 @@ export function broadcastPlaylistDeleted( * in a public playlist stay hidden from everyone but their posters. */ export function broadcastPlaylistDumpsUpdated( - playlist: Playlist, + playlist: Pick, members: PlaylistMember[], ): void { for (const client of clients) { @@ -326,24 +313,53 @@ export function broadcastUserUpdated(user: PublicUser): void { } /** - * Sends everyone `comment_created`. The comments route calls it only for a - * comment on a public dump, as it does the two below. + * Returns the filter of a broadcast about a comment: everyone while its dump + * is public, the dump's owner alone while it is private, since no one else may + * see it. */ -export function broadcastCommentCreated(comment: Comment): void { - broadcast({ type: "comment_created", comment }); +function audienceOf( + item: { isPrivate: boolean; userId: string }, +): ((client: WsClient) => boolean) | undefined { + return item.isPrivate ? (client) => client.userId === item.userId : undefined; } -/** Sends everyone `comment_deleted`. */ +/** + * Sends `comment_created` to those who may see the comment's dump: everyone, + * or its owner alone when it is private. The comments route calls it, as it + * does the two below. + */ +export function broadcastCommentCreated( + comment: Comment, + dump: { isPrivate: boolean; userId: string }, +): void { + broadcast({ type: "comment_created", comment }, audienceOf(dump)); +} + +/** Sends `comment_deleted`, as {@link broadcastCommentCreated} does. */ export function broadcastCommentDeleted( commentId: string, - dumpId: string, + dump: { id: string; isPrivate: boolean; userId: string }, ): void { - broadcast({ type: "comment_deleted", commentId, dumpId }); + broadcast( + { type: "comment_deleted", commentId, dumpId: dump.id }, + audienceOf(dump), + ); } -/** Sends everyone `comment_updated`. */ -export function broadcastCommentUpdated(comment: Comment): void { - broadcast({ type: "comment_updated", comment }); +/** Sends `comment_updated`, as {@link broadcastCommentCreated} does. */ +export function broadcastCommentUpdated( + comment: Comment, + dump: { isPrivate: boolean; userId: string }, +): void { + broadcast({ type: "comment_updated", comment }, audienceOf(dump)); +} + +/** + * Sends everyone `categories_changed`, after the category manager created, + * edited or deleted one, so that every client fetches them again. + */ +export function broadcastCategoriesChanged(): void { + broadcast({ type: "categories_changed" }); } /** diff --git a/api/test/routes/ws.test.ts b/api/test/routes/ws.test.ts index c6647433..c48fa4ba 100644 --- a/api/test/routes/ws.test.ts +++ b/api/test/routes/ws.test.ts @@ -10,6 +10,7 @@ import { insertDump, insertPlaylist, PASSWORD, + patch, PNG, post, put, @@ -19,7 +20,9 @@ import { } from "../helpers.ts"; import { assert, assertEquals, assertExists, assertFalse } from "@std/assert"; import type { AuthTokens } from "../../../shared/user.ts"; +import type { Comment } from "../../../shared/comment.ts"; import type { Dump } from "../../../shared/dump.ts"; +import type { Playlist } from "../../../shared/playlist.ts"; import type { ServerToClientMessage } from "../../../shared/ws.ts"; // deno.json's "dom" lib types WebSocket's second argument as protocols only, @@ -618,12 +621,20 @@ wsTest("REST writes broadcast to the right audience", async (connect) => { await o.sync(); assertEquals(o.all("dump_deleted").length, 1); - // Comments on a private dump stay quiet. + // Comments on a private dump reach its owner alone. const priv = insertDump({ userId: owner.id, isPrivate: true }); - await post(`/api/dumps/${priv.id}/comments`, { + const shh = await post(`/api/dumps/${priv.id}/comments`, { auth: owner.auth, json: { body: "shh" }, }); + await o.next("comment_created", (m) => m.comment.body === "shh"); + await patch(`/api/comments/${shh.data.id}`, { + auth: owner.auth, + json: { body: "shhh" }, + }); + await o.next("comment_updated", (m) => m.comment.body === "shhh"); + await del(`/api/comments/${shh.data.id}`, { auth: owner.auth }); + await o.next("comment_deleted", (m) => m.commentId === shh.data.id); await post(`/api/dumps/${pub.data.id}/comments`, { auth: owner.auth, json: { body: "hi" }, @@ -632,6 +643,8 @@ wsTest("REST writes broadcast to the right audience", async (connect) => { assertEquals(created.comment.body, "hi"); await x.sync(); assertEquals(x.all("comment_created").length, 1); + assertEquals(x.all("comment_updated"), []); + assertEquals(x.all("comment_deleted"), []); // A private playlist's events go to its owner alone. const pl = insertPlaylist({ userId: owner.id, isPublic: false }); @@ -744,3 +757,128 @@ wsTest( assertEquals((await x.next("dump_created")).dump.id, id); }, ); + +wsTest( + "a playlist gone private leaves the others' views without its new details", + async (connect) => { + const owner = await createUser(); + const other = await createUser(); + const pl = insertPlaylist({ userId: owner.id, title: "Open" }); + const o = await connect(owner); + const x = await connect(other); + const anon = await connect(); + + await put(`/api/playlists/${pl.id}`, { + auth: owner.auth, + json: { title: "Secret plans", isPublic: false }, + }); + const mine = await o.next("playlist_updated"); + assertEquals(mine.playlist.title, "Secret plans"); + for (const c of [x, anon]) { + assertEquals((await c.next("playlist_deleted")).playlistId, pl.id); + await c.sync(); + assertEquals(c.all("playlist_updated"), []); + } + + // Going public again is an arrival for the others. + await put(`/api/playlists/${pl.id}`, { + auth: owner.auth, + json: { isPublic: true }, + }); + await o.next("playlist_updated", (m) => m.playlist.isPublic); + for (const c of [x, anon]) { + const back = await c.next("playlist_created"); + assertEquals(back.playlist.title, "Secret plans"); + } + }, +); + +wsTest( + "a dump deleted or turned private is taken out of its playlists live", + async (connect) => { + const owner = await createUser(); + const other = await createUser(); + const pl = insertPlaylist({ userId: owner.id }); + const keep = insertDump({ userId: owner.id }); + const flip = insertDump({ userId: owner.id }); + const gone = insertDump({ userId: owner.id }); + for (const [i, d] of [keep, flip, gone].entries()) { + db.prepare( + `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) + VALUES (?, ?, ?, ?);`, + ).run(pl.id, d.id, i, new Date().toISOString()); + } + const o = await connect(owner); + const x = await connect(other); + + await put(`/api/dumps/${flip.id}`, { + auth: owner.auth, + json: { isPrivate: true }, + }); + const hidden = await x.next("playlist_dumps_updated"); + assertEquals(hidden.dumpIds, [keep.id, gone.id]); + assertEquals(hidden.dumpCount, 3); + assertEquals( + (await o.next("playlist_dumps_updated")).dumpIds, + [keep.id, flip.id, gone.id], + ); + + await put(`/api/dumps/${flip.id}`, { + auth: owner.auth, + json: { isPrivate: false }, + }); + assertEquals( + (await x.next("playlist_dumps_updated")).dumpIds, + [keep.id, flip.id, gone.id], + ); + + await del(`/api/dumps/${gone.id}`, { auth: owner.auth }); + const after = await x.next( + "playlist_dumps_updated", + (m) => m.dumpCount === 2, + ); + assertEquals(after.dumpIds, [keep.id, flip.id]); + + // An edit that keeps the visibility leaves the playlists alone. + await put(`/api/dumps/${keep.id}`, { + auth: owner.auth, + json: { title: "Renamed" }, + }); + await x.next("dump_updated"); + await x.sync(); + assertEquals(x.all("playlist_dumps_updated").length, 3); + }, +); + +wsTest( + "reading the notifications clears the bell on every socket", + async (connect) => { + const user = await createUser(); + const other = await createUser(); + const a = await connect(user); + const b = await connect(user); + const x = await connect(other); + await post("/api/me/notifications/read", { auth: user.auth }); + await a.next("notifications_read"); + await b.next("notifications_read"); + await x.sync(); + assertEquals(x.all("notifications_read"), []); + }, +); + +wsTest("category changes are announced to everyone", async (connect) => { + const admin = await createUser({ role: "admin" }); + const anon = await connect(); + const created = await post<{ id: string }>("/api/categories", { + auth: admin.auth, + json: { kind: "dump", slug: "jazz", name: "Jazz" }, + }); + await anon.next("categories_changed"); + await put(`/api/categories/${created.data.id}`, { + auth: admin.auth, + json: { name: "Jazz & co" }, + }); + await anon.next("categories_changed"); + await del(`/api/categories/${created.data.id}`, { auth: admin.auth }); + await anon.next("categories_changed"); +}); diff --git a/locales/en.mjs b/locales/en.mjs index 7dbcd4a1..551414b2 100644 --- a/locales/en.mjs +++ b/locales/en.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"-K0AvT\":[\"Disconnect\"],\"-K9EZb\":[\"Add email…\"],\"-Ya-b9\":[\"Failed to save\"],\"-iDCtG\":[\"Your settings\"],\"-siMqD\":[\"Journal\"],\"0byTmd\":[\"All playlists\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Search dumps, users, playlists…\"],\"1QfxQT\":[\"Dismiss\"],\"1YKhJ3\":[\"1 new message\"],\"1njn7W\":[\"Light\"],\"1utXA6\":[\"Dumps\"],\"26iNma\":[\"Post comment\"],\"2DoBvq\":[\"Feeds\"],\"2Hlmdt\":[\"Write a reply…\"],\"2_DHgA\":[\"URL slug\"],\"2nrsEn\":[\"deleted message\"],\"2rwzb1\":[\"You haven't signed in to another gerbeur with this account yet.\"],\"2wxgft\":[\"Rename\"],\"2ygf_L\":[\"← Back\"],\"32TndD\":[\"Blocked\"],\"3KKSM4\":[\"private\"],\"3QuNUr\":[\"Several people are typing…\"],\"3SEwY1\":[\"Delete category \\\"\",[\"0\"],\"\\\"? This cannot be undone.\"],\"3qkggm\":[\"Fullscreen\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" followed your playlist <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…\"],\"5NZ3tg\":[\"Turning off…\"],\"5TviPn\":[\"Cancel search\"],\"5cC8f2\":[\"Edited \",[\"0\"]],\"5oD9f_\":[\"Earlier\"],\"6C4kKS\":[\"Other instances\"],\"6Qly-0\":[\"a comment\"],\"6YE0P6\":[\"Editing \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"6_dCYd\":[\"Overview\"],\"6gRgw8\":[\"Retry\"],\"7PHCIN\":[\"Cancel removal\"],\"7PzzBU\":[\"User\"],\"7d1a0d\":[\"Public\"],\"7noMWp\":[\"Nothing happened this week.\"],\"7sNhEz\":[\"Username\"],\"8-8abB\":[\"Disconnected — reconnecting…\"],\"8F1i42\":[\"Page not found\"],\"8JplJJ\":[\"Unsubscribed\"],\"8Ug9jB\":[\"Related\"],\"8ZsakT\":[\"Password\"],\"8rPWOT\":[\"Can't sign in there\"],\"92o1-U\":[\"Failed to delete\"],\"9BruTc\":[\"Why?\"],\"9eEYEq\":[\"This sign-in link is incomplete.\"],\"9l4qcT\":[\"Drop a replacement here\"],\"9uI_rE\":[\"Undo\"],\"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\"],\"AwUsnG\":[\"Instances\"],\"B4LeYP\":[\"Delete failed (\",[\"0\"],\")\"],\"BjitBo\":[\"Download \",[\"0\"]],\"BlpJ2G\":[\"Picked up where you left off.\"],\"CI50ct\":[\"Upvoted\"],\"CJkEfx\":[\"Block\"],\"CL-DkX\":[\"An instance's URL\"],\"C_qQE4\":[\"Already dumped by \",[\"0\"],\" \",[\"1\"]],\"Cf_gxi\":[\"Turn off the weekly digest?\"],\"Cj24wt\":[\"Registering…\"],\"ClFfRL\":[\"Playlist categories\"],\"CnqayG\":[\"Couldn't update. Try again.\"],\"Czya-u\":[\"Users of a trusted instance sign in here with their account there. Those of a blocked one can't, and users here can't sign in to it. Users of any other instance need an invite.\"],\"D8DRYV\":[\"Delete this message?\"],\"DI3NBO\":[\"Open on \",[\"0\"]],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"E417RG\":[\"You won't get the weekly digest any more. You can turn it back on in your settings.\"],\"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\"],\"IF7RiR\":[\"Cover\"],\"IO9KL_\":[\"Next track\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Letters, numbers and underscores only\"],\"ImOQa9\":[\"Reply\"],\"JJ-Bhk\":[\"Your email address\"],\"JLasL-\":[\"Couldn't unsubscribe\"],\"JRQitQ\":[\"Confirm new password\"],\"JXr41k\":[\"<0>\",[\"0\"],\" commented on <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Hot\"],\"Jf0PuK\":[\"Go to login\"],\"JlFcis\":[\"Send\"],\"JrFTcr\":[\"Connecting…\"],\"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…\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"MTb2Ot\":[\"No messages yet. Say hello!\"],\"MbkoRn\":[\"Or join with your account on another gerbeur\"],\"Mj6oCJ\":[[\"0\"],\" is typing…\"],\"MrIc3x\":[\"Or log in with your account on another gerbeur\"],\"N40H-G\":[\"All\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"NUrY9o\":[\"Categories\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Edit description\"],\"O3oNi5\":[\"Email\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"P5MuiB\":[\"Add image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" new\"],\"other\":[\"#\",\" 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 files\"],\"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.\"],\"RLe7Vk\":[\"Checking…\"],\"RTksSy\":[\"<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…\"],\"SLaP1b\":[\"Not from here\"],\"SMaFdc\":[\"Unsubscribe\"],\"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\"],\" was added to <1>\",[\"1\"],\"\"],\"TAkYGZ\":[\"Your account lives on \",[\"home\"],\": sign in to other instances from there.\"],\"TAtbho\":[\"Loading messages…\"],\"TN382O\":[\"Invalid link\"],\"ToADL_\":[\"<0>\",[\"name\"],\" (\",[\"host\"],\") asks to sign you in with your account here, <1>@\",[\"username\"],\". It will receive your username, your email address and your avatar.\"],\"Tv9vbB\":[\"Follow playlist\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UNMVei\":[\"Forgot password?\"],\"UOV0wi\":[\"Signing you in…\"],\"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\"]],\"VLINRu\":[\"Your gerbeur's URL\"],\"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\"],\"WMw63e\":[\"Couldn't reach that instance\"],\"WfffrI\":[\"slug\"],\"WhimMi\":[\"Reset failed\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"XHqhoS\":[\"Couldn't load the chat.\"],\"XJVYo8\":[\"Why & where\"],\"XJy2oN\":[\"Logging in…\"],\"XWbTfu\":[\"last used \",[\"0\"]],\"Xan6QP\":[\"New dump\"],\"XgRtUf\":[\"Could not change password\"],\"XnL-Eu\":[\"No users match \\\"\",[\"q\"],\"\\\".\"],\"Xs2Lez\":[\"This reset link is missing or malformed.\"],\"YK1Dhc\":[\"a post\"],\"YWnSuq\":[\"This unsubscribe link is missing or malformed.\"],\"YZxXK8\":[\"Close emoji picker\"],\"YcjhhX\":[\"User management\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" liked your comment on <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\"],\"_0oJwI\":[\"Trust\"],\"_3O5R_\":[\"Request failed\"],\"_DwR-n\":[\"Creating…\"],\"_I1_HN\":[\"Lowercase letters, numbers and single hyphens only\"],\"_R_sGB\":[\"Password changed successfully.\"],\"_aept4\":[\"Post reply\"],\"_cF7Rs\":[\"Volume\"],\"_eZzBf\":[\"Sign in elsewhere\"],\"_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…\"],\"b_1P1D\":[\"Renaming…\"],\"b_VouM\":[\"Your account lives on \",[\"home\"],\": your password, email address, avatar and username change there.\"],\"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\"],\"digest.community\":[\"Community\"],\"digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"comment\"],\"other\":[\"comments\"]}]],\"digest.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"upvote\"],\"other\":[\"upvotes\"]}]],\"digest.invitedBy\":[\"invited by @\",[\"username\"]],\"digest.invitedByYou\":[\"invited by you\"],\"digest.monthly\":[\"Monthly recap\"],\"digest.monthly.off\":[\"Turn off monthly recaps\"],\"digest.newMembers\":[\"New members\"],\"digest.newPlaylists\":[\"New public playlists\"],\"digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"chat message\"],\"other\":[\"chat messages\"]}]],\"digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"dump\"],\"other\":[\"dumps\"]}]],\"digest.topMonth\":[\"Top this month\"],\"digest.topWeek\":[\"Top this week\"],\"digest.weekly\":[\"Weekly digest\"],\"digest.weekly.off\":[\"Turn off weekly digests\"],\"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.\"],\"email.digest.addedTo\":[\"added to \",[\"playlist\"]],\"email.digest.andMore\":[\"and \",[\"n\"],\" more\"],\"email.digest.by\":[\"by @\",[\"username\"]],\"email.digest.chatWeek\":[[\"n\",\"plural\",{\"one\":[\"#\",\" message this week\"],\"other\":[\"#\",\" messages this week\"]}]],\"email.digest.communityPage\":[\"See it all on the community page\"],\"email.digest.count.followers\":[[\"n\",\"plural\",{\"one\":[\"new follower\"],\"other\":[\"new followers\"]}]],\"email.digest.count.likes\":[[\"n\",\"plural\",{\"one\":[\"comment like\"],\"other\":[\"comment likes\"]}]],\"email.digest.count.mentions\":[[\"n\",\"plural\",{\"one\":[\"mention\"],\"other\":[\"mentions\"]}]],\"email.digest.count.playlistFollows\":[[\"n\",\"plural\",{\"one\":[\"playlist follow\"],\"other\":[\"playlist follows\"]}]],\"email.digest.forYou\":[\"For you\"],\"email.digest.hl.comment\":[[\"who\"],\" commented on your dump \",[\"target\"]],\"email.digest.hl.commentLike\":[[\"who\"],\" liked your comment on \",[\"target\"]],\"email.digest.hl.mentionComment\":[[\"who\"],\" mentioned you in a comment on \",[\"target\"]],\"email.digest.hl.mentionEveryone\":[[\"who\"],\" mentioned everyone in \",[\"target\"]],\"email.digest.hl.mentionIn\":[[\"who\"],\" mentioned you in \",[\"target\"]],\"email.digest.hl.mentionPlaylist\":[[\"who\"],\" mentioned you in the playlist \",[\"target\"]],\"email.digest.hl.playlistFollow\":[[\"who\"],\" followed your playlist \",[\"target\"]],\"email.digest.hl.userFollow\":[[\"who\"],\" started following you\"],\"email.digest.inChat\":[\"In the chat\"],\"email.digest.monthly.reason\":[\"You're getting the monthly recap because it's on in your settings.\"],\"email.digest.monthly.subject\":[\"The month on \",[\"site\"],\", \",[\"range\"]],\"email.digest.monthly.title\":[\"The month on \",[\"site\"]],\"email.digest.more\":[[\"n\"],\" more on your Followed feed\"],\"email.digest.network\":[\"From people you follow\"],\"email.digest.openChat\":[\"Open the chat\"],\"email.digest.playlistMeta\":[\"by @\",[\"username\"],\" · \",[\"n\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"email.digest.pre.members\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new member\"],\"other\":[\"#\",\" new members\"]}]],\"email.digest.pre.network\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new dump from people you follow\"],\"other\":[\"#\",\" new dumps from people you follow\"]}]],\"email.digest.pre.playlists\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new playlist\"],\"other\":[\"#\",\" new playlists\"]}]],\"email.digest.pre.topMonth\":[[\"n\",\"plural\",{\"one\":[\"the most upvoted dump of the month\"],\"other\":[\"the \",\"#\",\" most upvoted dumps of the month\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"the most upvoted dump of the week\"],\"other\":[\"the \",\"#\",\" most upvoted dumps of the week\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"See the notification\"],\"other\":[\"See all \",\"#\",\" notifications\"]}]],\"email.digest.theChat\":[\"the chat\"],\"email.digest.totals\":[[\"dumps\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}],\", \",[\"upvotes\",\"plural\",{\"one\":[\"#\",\" upvote\"],\"other\":[\"#\",\" upvotes\"]}],\" and \",[\"comments\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}],\".\"],\"email.digest.upload\":[\"upload\"],\"email.digest.weekly.reason\":[\"You're getting the weekly digest because it's on in your settings.\"],\"email.digest.weekly.subject\":[\"Your week on \",[\"site\"],\", \",[\"range\"]],\"email.digest.weekly.title\":[\"Your week, \",[\"username\"]],\"email.digest.yours\":[\"yours\"],\"email.reset.body\":[\"Someone asked to reset the password of your \",[\"site\"],\" account. The button below lets you choose a new one. It works once, for \",[\"hours\",\"plural\",{\"one\":[\"#\",\" hour\"],\"other\":[\"#\",\" hours\"]}],\".\"],\"email.reset.button\":[\"Reset password\"],\"email.reset.fallback\":[\"If the button doesn't work, paste this link into your browser:\"],\"email.reset.hi\":[\"Hi \",[\"username\"],\",\"],\"email.reset.ignore\":[\"If you didn't ask for this, ignore this email. Your password stays the same.\"],\"email.reset.preheader\":[\"The link works once, for \",[\"hours\",\"plural\",{\"one\":[\"#\",\" hour\"],\"other\":[\"#\",\" hours\"]}],\".\"],\"email.reset.reason\":[\"You're getting this email because a password reset was requested for this address.\"],\"email.reset.subject\":[\"Reset your \",[\"site\"],\" password\"],\"email.reset.title\":[\"Reset your password\"],\"email.settings\":[\"Settings\"],\"email.welcome.body\":[\"# Welcome to \",[\"site\"],\"!\\n\\nHi **\",[\"username\"],\"**,\\n\\nYour account has been created successfully. Welcome aboard!\"],\"email.welcome.button\":[\"Open \",[\"site\"]],\"email.welcome.digests\":[\"Once a week you'll get a digest of what happened here, and once a month a recap of the most upvoted dumps. You can turn either off in your settings.\"],\"email.welcome.preheader\":[\"Your account is ready.\"],\"email.welcome.reason\":[\"You're getting this email because an account was created on \",[\"site\"],\" with this address.\"],\"email.welcome.subject\":[\"Welcome to \",[\"site\"]],\"en9o7K\":[\"Failed to post comment\"],\"etFQQS\":[\"What makes it worth it?\"],\"f3cEDO\":[\"Change image\"],\"fC6mXb\":[\"Upvote\"],\"fI-mNw\":[\"Playlists\"],\"fgLNSM\":[\"Register\"],\"fn1ZQX\":[\"Sign in to \",[\"name\"],\"?\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"gIpLuy\":[\"A link is required\"],\"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\"],\" posted <1>\",[\"1\"],\"\"],\"hLAe-z\":[\"Back to the login page\"],\"hT8hgO\":[\"Reset to the suggested title\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"hpCc_Z\":[\"Close anyway\"],\"i3uTFh\":[\"Dump categories\"],\"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\"],\"jTGs8A\":[\"This site doesn't send email, so no digest goes out.\"],\"jZNg18\":[\"Account on <0>\",[\"host\"],\"\"],\"jbernk\":[\"Loading profile…\"],\"jnrIbz\":[\"Upvoted (\",[\"0\"],\")\"],\"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\"],\"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\"],\"m9Uf3I\":[\"Users of a trusted instance sign in here with their account there. Those of a blocked one can't. Users of any other instance need an invite.\"],\"mDJo8W\":[\"Dumps (\",[\"0\"],\")\"],\"mb_Wju\":[\"Drop a file here, or paste one\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nucyoV\":[\"<0>\",[\"0\"],\" mentioned everyone in <1>\",[\"where\"],\"\"],\"nwtY4N\":[\"Something went wrong\"],\"nx4kaN\":[\"Could not save\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"and \",\"#\",\" earlier dump\"],\"other\":[\"and \",\"#\",\" earlier dumps\"]}]],\"oW3yYY\":[\"This month\"],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pd3FCq\":[\"That file is over \",[\"limit\"],\".\"],\"pvnfJD\":[\"Dark\"],\"q018gJ\":[\"Couldn't add that instance\"],\"qIMfNQ\":[\"Delete playlist\"],\"qItrqH\":[\"You won't get the monthly recap any more. You can turn it back on in your settings.\"],\"qVdjD5\":[\"Trusted\"],\"qbDAcy\":[\"Dump it\"],\"qgx_78\":[\"Follow some public playlists to see their dumps here.\"],\"qnUJBI\":[\"The new order couldn't be saved. Try again.\"],\"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\"],\"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\"],\"tOHgQw\":[\"Continue as @\",[\"username\"]],\"tXNIqW\":[\"This sign-in was started elsewhere. Start again from the login page.\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Save\"],\"tvmuQ0\":[\"Color scheme\"],\"tyiiHe\":[\"Nothing happened this month.\"],\"u1lDX2\":[\"Fetching preview…\"],\"u8pttd\":[\"Change it on \",[\"home\"]],\"uACFmj\":[\"Log in with \",[\"0\"]],\"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\"],\"w61W3L\":[\"Remove \",[\"0\"]],\"wckWOP\":[\"Manage\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"wrFDaD\":[\"Users here can't sign in to a blocked instance.\"],\"wtxFp4\":[\"File too large (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Default tab\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xGVfLh\":[\"Continue\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y1G_DI\":[\"Could not rename\"],\"y6sq5j\":[\"Following\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yByRxz\":[\"This week\"],\"yQ2kGp\":[\"Load more\"],\"y_0uwd\":[\"Yesterday\"],\"yxlcv5\":[\"Turn off the monthly recap?\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"z0ROB3\":[\"Remove like\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"-K0AvT\":[\"Disconnect\"],\"-K9EZb\":[\"Add email…\"],\"-Ya-b9\":[\"Failed to save\"],\"-iDCtG\":[\"Your settings\"],\"-siMqD\":[\"Journal\"],\"0byTmd\":[\"All playlists\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Search dumps, users, playlists…\"],\"1QfxQT\":[\"Dismiss\"],\"1YKhJ3\":[\"1 new message\"],\"1njn7W\":[\"Light\"],\"1utXA6\":[\"Dumps\"],\"26iNma\":[\"Post comment\"],\"2DoBvq\":[\"Feeds\"],\"2Hlmdt\":[\"Write a reply…\"],\"2_DHgA\":[\"URL slug\"],\"2nrsEn\":[\"deleted message\"],\"2rwzb1\":[\"You haven't signed in to another gerbeur with this account yet.\"],\"2wxgft\":[\"Rename\"],\"2ygf_L\":[\"← Back\"],\"32TndD\":[\"Blocked\"],\"3KKSM4\":[\"private\"],\"3QuNUr\":[\"Several people are typing…\"],\"3SEwY1\":[\"Delete category \\\"\",[\"0\"],\"\\\"? This cannot be undone.\"],\"3qkggm\":[\"Fullscreen\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" followed your playlist <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…\"],\"5NZ3tg\":[\"Turning off…\"],\"5TviPn\":[\"Cancel search\"],\"5cC8f2\":[\"Edited \",[\"0\"]],\"5oD9f_\":[\"Earlier\"],\"6BV5h6\":[\"This dump was deleted, or made private.\"],\"6C4kKS\":[\"Other instances\"],\"6Qly-0\":[\"a comment\"],\"6YE0P6\":[\"Editing \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"6_dCYd\":[\"Overview\"],\"6gRgw8\":[\"Retry\"],\"7PHCIN\":[\"Cancel removal\"],\"7PzzBU\":[\"User\"],\"7d1a0d\":[\"Public\"],\"7noMWp\":[\"Nothing happened this week.\"],\"7sNhEz\":[\"Username\"],\"8-8abB\":[\"Disconnected — reconnecting…\"],\"8F1i42\":[\"Page not found\"],\"8JplJJ\":[\"Unsubscribed\"],\"8Ug9jB\":[\"Related\"],\"8ZsakT\":[\"Password\"],\"8rPWOT\":[\"Can't sign in there\"],\"92o1-U\":[\"Failed to delete\"],\"9BruTc\":[\"Why?\"],\"9eEYEq\":[\"This sign-in link is incomplete.\"],\"9l4qcT\":[\"Drop a replacement here\"],\"9uI_rE\":[\"Undo\"],\"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\"],\"AwUsnG\":[\"Instances\"],\"B4LeYP\":[\"Delete failed (\",[\"0\"],\")\"],\"BjitBo\":[\"Download \",[\"0\"]],\"BlpJ2G\":[\"Picked up where you left off.\"],\"CI50ct\":[\"Upvoted\"],\"CJkEfx\":[\"Block\"],\"CL-DkX\":[\"An instance's URL\"],\"C_qQE4\":[\"Already dumped by \",[\"0\"],\" \",[\"1\"]],\"Cf_gxi\":[\"Turn off the weekly digest?\"],\"Cj24wt\":[\"Registering…\"],\"ClFfRL\":[\"Playlist categories\"],\"CnqayG\":[\"Couldn't update. Try again.\"],\"Czya-u\":[\"Users of a trusted instance sign in here with their account there. Those of a blocked one can't, and users here can't sign in to it. Users of any other instance need an invite.\"],\"D8DRYV\":[\"Delete this message?\"],\"DI3NBO\":[\"Open on \",[\"0\"]],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"E417RG\":[\"You won't get the weekly digest any more. You can turn it back on in your settings.\"],\"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\"],\"IF7RiR\":[\"Cover\"],\"IO9KL_\":[\"Next track\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Letters, numbers and underscores only\"],\"ImOQa9\":[\"Reply\"],\"JJ-Bhk\":[\"Your email address\"],\"JLasL-\":[\"Couldn't unsubscribe\"],\"JRQitQ\":[\"Confirm new password\"],\"JXr41k\":[\"<0>\",[\"0\"],\" commented on <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Hot\"],\"Jf0PuK\":[\"Go to login\"],\"JlFcis\":[\"Send\"],\"JrFTcr\":[\"Connecting…\"],\"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…\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"MTb2Ot\":[\"No messages yet. Say hello!\"],\"MbkoRn\":[\"Or join with your account on another gerbeur\"],\"Mj6oCJ\":[[\"0\"],\" is typing…\"],\"MrIc3x\":[\"Or log in with your account on another gerbeur\"],\"N40H-G\":[\"All\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"NUrY9o\":[\"Categories\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Edit description\"],\"O3oNi5\":[\"Email\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"P5MuiB\":[\"Add image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" new\"],\"other\":[\"#\",\" 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 files\"],\"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.\"],\"RLe7Vk\":[\"Checking…\"],\"RTksSy\":[\"<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…\"],\"SLaP1b\":[\"Not from here\"],\"SMaFdc\":[\"Unsubscribe\"],\"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\"],\" was added to <1>\",[\"1\"],\"\"],\"TAkYGZ\":[\"Your account lives on \",[\"home\"],\": sign in to other instances from there.\"],\"TAtbho\":[\"Loading messages…\"],\"TN382O\":[\"Invalid link\"],\"ToADL_\":[\"<0>\",[\"name\"],\" (\",[\"host\"],\") asks to sign you in with your account here, <1>@\",[\"username\"],\". It will receive your username, your email address and your avatar.\"],\"Tv9vbB\":[\"Follow playlist\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UNMVei\":[\"Forgot password?\"],\"UOV0wi\":[\"Signing you in…\"],\"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\"]],\"VLINRu\":[\"Your gerbeur's URL\"],\"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\"],\"WMw63e\":[\"Couldn't reach that instance\"],\"WfffrI\":[\"slug\"],\"WhimMi\":[\"Reset failed\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"XHqhoS\":[\"Couldn't load the chat.\"],\"XJVYo8\":[\"Why & where\"],\"XJy2oN\":[\"Logging in…\"],\"XWbTfu\":[\"last used \",[\"0\"]],\"Xan6QP\":[\"New dump\"],\"XgRtUf\":[\"Could not change password\"],\"XnL-Eu\":[\"No users match \\\"\",[\"q\"],\"\\\".\"],\"YK1Dhc\":[\"a post\"],\"YWnSuq\":[\"This unsubscribe link is missing or malformed.\"],\"YZxXK8\":[\"Close emoji picker\"],\"YcjhhX\":[\"User management\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" liked your comment on <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\"],\"_0oJwI\":[\"Trust\"],\"_3O5R_\":[\"Request failed\"],\"_DwR-n\":[\"Creating…\"],\"_I1_HN\":[\"Lowercase letters, numbers and single hyphens only\"],\"_R_sGB\":[\"Password changed successfully.\"],\"_aept4\":[\"Post reply\"],\"_cF7Rs\":[\"Volume\"],\"_eZzBf\":[\"Sign in elsewhere\"],\"_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…\"],\"b_1P1D\":[\"Renaming…\"],\"b_VouM\":[\"Your account lives on \",[\"home\"],\": your password, email address, avatar and username change there.\"],\"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\"],\"digest.community\":[\"Community\"],\"digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"comment\"],\"other\":[\"comments\"]}]],\"digest.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"upvote\"],\"other\":[\"upvotes\"]}]],\"digest.invitedBy\":[\"invited by @\",[\"username\"]],\"digest.invitedByYou\":[\"invited by you\"],\"digest.monthly\":[\"Monthly recap\"],\"digest.monthly.off\":[\"Turn off monthly recaps\"],\"digest.newMembers\":[\"New members\"],\"digest.newPlaylists\":[\"New public playlists\"],\"digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"chat message\"],\"other\":[\"chat messages\"]}]],\"digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"dump\"],\"other\":[\"dumps\"]}]],\"digest.topMonth\":[\"Top this month\"],\"digest.topWeek\":[\"Top this week\"],\"digest.weekly\":[\"Weekly digest\"],\"digest.weekly.off\":[\"Turn off weekly digests\"],\"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.\"],\"email.digest.addedTo\":[\"added to \",[\"playlist\"]],\"email.digest.andMore\":[\"and \",[\"n\"],\" more\"],\"email.digest.by\":[\"by @\",[\"username\"]],\"email.digest.chatWeek\":[[\"n\",\"plural\",{\"one\":[\"#\",\" message this week\"],\"other\":[\"#\",\" messages this week\"]}]],\"email.digest.communityPage\":[\"See it all on the community page\"],\"email.digest.count.followers\":[[\"n\",\"plural\",{\"one\":[\"new follower\"],\"other\":[\"new followers\"]}]],\"email.digest.count.likes\":[[\"n\",\"plural\",{\"one\":[\"comment like\"],\"other\":[\"comment likes\"]}]],\"email.digest.count.mentions\":[[\"n\",\"plural\",{\"one\":[\"mention\"],\"other\":[\"mentions\"]}]],\"email.digest.count.playlistFollows\":[[\"n\",\"plural\",{\"one\":[\"playlist follow\"],\"other\":[\"playlist follows\"]}]],\"email.digest.forYou\":[\"For you\"],\"email.digest.hl.comment\":[[\"who\"],\" commented on your dump \",[\"target\"]],\"email.digest.hl.commentLike\":[[\"who\"],\" liked your comment on \",[\"target\"]],\"email.digest.hl.mentionComment\":[[\"who\"],\" mentioned you in a comment on \",[\"target\"]],\"email.digest.hl.mentionEveryone\":[[\"who\"],\" mentioned everyone in \",[\"target\"]],\"email.digest.hl.mentionIn\":[[\"who\"],\" mentioned you in \",[\"target\"]],\"email.digest.hl.mentionPlaylist\":[[\"who\"],\" mentioned you in the playlist \",[\"target\"]],\"email.digest.hl.playlistFollow\":[[\"who\"],\" followed your playlist \",[\"target\"]],\"email.digest.hl.userFollow\":[[\"who\"],\" started following you\"],\"email.digest.inChat\":[\"In the chat\"],\"email.digest.monthly.reason\":[\"You're getting the monthly recap because it's on in your settings.\"],\"email.digest.monthly.subject\":[\"The month on \",[\"site\"],\", \",[\"range\"]],\"email.digest.monthly.title\":[\"The month on \",[\"site\"]],\"email.digest.more\":[[\"n\"],\" more on your Followed feed\"],\"email.digest.network\":[\"From people you follow\"],\"email.digest.openChat\":[\"Open the chat\"],\"email.digest.playlistMeta\":[\"by @\",[\"username\"],\" · \",[\"n\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"email.digest.pre.members\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new member\"],\"other\":[\"#\",\" new members\"]}]],\"email.digest.pre.network\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new dump from people you follow\"],\"other\":[\"#\",\" new dumps from people you follow\"]}]],\"email.digest.pre.playlists\":[[\"n\",\"plural\",{\"one\":[\"#\",\" new playlist\"],\"other\":[\"#\",\" new playlists\"]}]],\"email.digest.pre.topMonth\":[[\"n\",\"plural\",{\"one\":[\"the most upvoted dump of the month\"],\"other\":[\"the \",\"#\",\" most upvoted dumps of the month\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"the most upvoted dump of the week\"],\"other\":[\"the \",\"#\",\" most upvoted dumps of the week\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"See the notification\"],\"other\":[\"See all \",\"#\",\" notifications\"]}]],\"email.digest.theChat\":[\"the chat\"],\"email.digest.totals\":[[\"dumps\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}],\", \",[\"upvotes\",\"plural\",{\"one\":[\"#\",\" upvote\"],\"other\":[\"#\",\" upvotes\"]}],\" and \",[\"comments\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}],\".\"],\"email.digest.upload\":[\"upload\"],\"email.digest.weekly.reason\":[\"You're getting the weekly digest because it's on in your settings.\"],\"email.digest.weekly.subject\":[\"Your week on \",[\"site\"],\", \",[\"range\"]],\"email.digest.weekly.title\":[\"Your week, \",[\"username\"]],\"email.digest.yours\":[\"yours\"],\"email.reset.body\":[\"Someone asked to reset the password of your \",[\"site\"],\" account. The button below lets you choose a new one. It works once, for \",[\"hours\",\"plural\",{\"one\":[\"#\",\" hour\"],\"other\":[\"#\",\" hours\"]}],\".\"],\"email.reset.button\":[\"Reset password\"],\"email.reset.fallback\":[\"If the button doesn't work, paste this link into your browser:\"],\"email.reset.hi\":[\"Hi \",[\"username\"],\",\"],\"email.reset.ignore\":[\"If you didn't ask for this, ignore this email. Your password stays the same.\"],\"email.reset.preheader\":[\"The link works once, for \",[\"hours\",\"plural\",{\"one\":[\"#\",\" hour\"],\"other\":[\"#\",\" hours\"]}],\".\"],\"email.reset.reason\":[\"You're getting this email because a password reset was requested for this address.\"],\"email.reset.subject\":[\"Reset your \",[\"site\"],\" password\"],\"email.reset.title\":[\"Reset your password\"],\"email.settings\":[\"Settings\"],\"email.welcome.body\":[\"# Welcome to \",[\"site\"],\"!\\n\\nHi **\",[\"username\"],\"**,\\n\\nYour account has been created successfully. Welcome aboard!\"],\"email.welcome.button\":[\"Open \",[\"site\"]],\"email.welcome.digests\":[\"Once a week you'll get a digest of what happened here, and once a month a recap of the most upvoted dumps. You can turn either off in your settings.\"],\"email.welcome.preheader\":[\"Your account is ready.\"],\"email.welcome.reason\":[\"You're getting this email because an account was created on \",[\"site\"],\" with this address.\"],\"email.welcome.subject\":[\"Welcome to \",[\"site\"]],\"en9o7K\":[\"Failed to post comment\"],\"etFQQS\":[\"What makes it worth it?\"],\"f3cEDO\":[\"Change image\"],\"fC6mXb\":[\"Upvote\"],\"fI-mNw\":[\"Playlists\"],\"fgLNSM\":[\"Register\"],\"fn1ZQX\":[\"Sign in to \",[\"name\"],\"?\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"gIpLuy\":[\"A link is required\"],\"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\"],\" posted <1>\",[\"1\"],\"\"],\"hLAe-z\":[\"Back to the login page\"],\"hT8hgO\":[\"Reset to the suggested title\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"hpCc_Z\":[\"Close anyway\"],\"i3uTFh\":[\"Dump categories\"],\"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\"],\"jTGs8A\":[\"This site doesn't send email, so no digest goes out.\"],\"jZNg18\":[\"Account on <0>\",[\"host\"],\"\"],\"jbernk\":[\"Loading profile…\"],\"jnrIbz\":[\"Upvoted (\",[\"0\"],\")\"],\"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\"],\"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\"],\"m9Uf3I\":[\"Users of a trusted instance sign in here with their account there. Those of a blocked one can't. Users of any other instance need an invite.\"],\"mDJo8W\":[\"Dumps (\",[\"0\"],\")\"],\"mb_Wju\":[\"Drop a file here, or paste one\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nucyoV\":[\"<0>\",[\"0\"],\" mentioned everyone in <1>\",[\"where\"],\"\"],\"nwtY4N\":[\"Something went wrong\"],\"nx4kaN\":[\"Could not save\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"and \",\"#\",\" earlier dump\"],\"other\":[\"and \",\"#\",\" earlier dumps\"]}]],\"oW3yYY\":[\"This month\"],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pd3FCq\":[\"That file is over \",[\"limit\"],\".\"],\"pvnfJD\":[\"Dark\"],\"q018gJ\":[\"Couldn't add that instance\"],\"qIMfNQ\":[\"Delete playlist\"],\"qItrqH\":[\"You won't get the monthly recap any more. You can turn it back on in your settings.\"],\"qVdjD5\":[\"Trusted\"],\"qbDAcy\":[\"Dump it\"],\"qgx_78\":[\"Follow some public playlists to see their dumps here.\"],\"qnUJBI\":[\"The new order couldn't be saved. Try again.\"],\"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\"],\"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\"],\"tOHgQw\":[\"Continue as @\",[\"username\"]],\"tXNIqW\":[\"This sign-in was started elsewhere. Start again from the login page.\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Save\"],\"tvmuQ0\":[\"Color scheme\"],\"tyiiHe\":[\"Nothing happened this month.\"],\"u1lDX2\":[\"Fetching preview…\"],\"u8pttd\":[\"Change it on \",[\"home\"]],\"uACFmj\":[\"Log in with \",[\"0\"]],\"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\"],\"w61W3L\":[\"Remove \",[\"0\"]],\"wckWOP\":[\"Manage\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"wrFDaD\":[\"Users here can't sign in to a blocked instance.\"],\"wtxFp4\":[\"File too large (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Default tab\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xGVfLh\":[\"Continue\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y1G_DI\":[\"Could not rename\"],\"y6sq5j\":[\"Following\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yByRxz\":[\"This week\"],\"yQ2kGp\":[\"Load more\"],\"y_0uwd\":[\"Yesterday\"],\"yxlcv5\":[\"Turn off the monthly recap?\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"z0ROB3\":[\"Remove like\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}"); \ No newline at end of file diff --git a/locales/en.po b/locales/en.po index c5325ecd..ae42c03a 100644 --- a/locales/en.po +++ b/locales/en.po @@ -60,7 +60,7 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores" msgid "email.digest.totals" msgstr "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}." -#: src/pages/Search.tsx:152 +#: src/pages/Search.tsx:178 msgid "{label} ({count})" msgstr "{label} ({count})" @@ -144,7 +144,7 @@ msgstr "{n, plural, one {upvote} other {upvotes}}" msgid "email.digest.more" msgstr "{n} more on your Followed feed" -#: src/pages/Notifications.tsx:508 +#: src/pages/Notifications.tsx:499 msgid "{totalUnread, plural, one {# new} other {# new}}" msgstr "{totalUnread, plural, one {# new} other {# new}}" @@ -211,8 +211,9 @@ msgstr "# Welcome to {site}!\n" msgid "← Back" msgstr "← Back" -#: src/pages/Dump.tsx:330 -#: src/pages/Dump.tsx:557 +#: src/pages/Dump.tsx:353 +#: src/pages/Dump.tsx:378 +#: src/pages/Dump.tsx:605 #: src/pages/DumpEdit.tsx:234 msgid "← Back to all dumps" msgstr "← Back to all dumps" @@ -225,7 +226,7 @@ msgstr "+ Invite someone" msgid "+ New playlist" msgstr "+ New playlist" -#: src/pages/Dump.tsx:404 +#: src/pages/Dump.tsx:452 msgid "+ Playlist" msgstr "+ Playlist" @@ -418,8 +419,8 @@ msgstr "by @{username}" msgid "email.digest.playlistMeta" msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}" -#: src/contexts/ws/WSProvider.tsx:395 -#: src/contexts/ws/WSProvider.tsx:764 +#: src/contexts/ws/WSProvider.tsx:376 +#: src/contexts/ws/WSProvider.tsx:765 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." @@ -439,7 +440,7 @@ msgstr "Can't sign in there" #: src/components/profile/EmailEditor.tsx:83 #: src/components/shared/ConfirmModal.tsx:63 #: src/pages/Authorize.tsx:158 -#: src/pages/Dump.tsx:446 +#: src/pages/Dump.tsx:494 #: src/pages/DumpEdit.tsx:561 msgid "Cancel" msgstr "Cancel" @@ -554,7 +555,7 @@ msgstr "Confirm new password" msgid "Connecting…" msgstr "Connecting…" -#: src/contexts/ws/WSProvider.tsx:757 +#: src/contexts/ws/WSProvider.tsx:758 msgid "Connection lost — this message may not have been sent" msgstr "Connection lost — this message may not have been sent" @@ -582,7 +583,7 @@ msgstr "Could not change password" 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/components/profile/ProfilePlaylistsTab.tsx:178 +#: src/components/profile/ProfilePlaylistsTab.tsx:171 #: src/pages/DumpEdit.tsx:555 msgid "Could not delete" msgstr "Could not delete" @@ -680,7 +681,7 @@ msgstr "Delete failed ({0})" #: src/components/playlist/PlaylistCard.tsx:149 #: src/components/playlist/PlaylistEditForm.tsx:213 -#: src/components/profile/ProfilePlaylistsTab.tsx:208 +#: src/components/profile/ProfilePlaylistsTab.tsx:201 #: src/pages/PlaylistDetail.tsx:172 msgid "Delete playlist" msgstr "Delete playlist" @@ -697,7 +698,7 @@ msgstr "Delete this dump? This cannot be undone." msgid "Delete this message?" msgstr "Delete this message?" -#: src/components/profile/ProfilePlaylistsTab.tsx:207 +#: src/components/profile/ProfilePlaylistsTab.tsx:200 #: src/pages/PlaylistDetail.tsx:171 msgid "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone." @@ -769,7 +770,7 @@ msgid "Dumped!" msgstr "Dumped!" #: src/components/profile/ProfileDumpsTab.tsx:46 -#: src/pages/Search.tsx:148 +#: src/pages/Search.tsx:174 #: src/pages/UserPublicProfile.tsx:160 msgid "Dumps" msgstr "Dumps" @@ -788,7 +789,7 @@ msgstr "Earlier" #: src/components/chat/PendingItem.tsx:93 #: src/components/comment/CommentNode.tsx:256 #: src/components/playlist/PlaylistHeader.tsx:60 -#: src/pages/Dump.tsx:553 +#: src/pages/Dump.tsx:601 msgid "Edit" msgstr "Edit" @@ -806,7 +807,7 @@ msgid "Edit email" msgstr "Edit email" #: src/components/dump/create/TitleField.tsx:69 -#: src/pages/Dump.tsx:463 +#: src/pages/Dump.tsx:511 msgid "Edit title" msgstr "Edit title" @@ -820,7 +821,7 @@ msgstr "edited" #: src/components/chat/ChatMessageItem.tsx:132 #: src/components/comment/CommentNode.tsx:205 #: src/components/playlist/PlaylistHeader.tsx:95 -#: src/pages/Dump.tsx:506 +#: src/pages/Dump.tsx:554 msgid "edited {0}" msgstr "edited {0}" @@ -830,7 +831,7 @@ msgstr "edited {0}" #: src/components/chat/ChatMessageItem.tsx:128 #: src/components/comment/CommentNode.tsx:203 #: src/components/playlist/PlaylistHeader.tsx:93 -#: src/pages/Dump.tsx:504 +#: src/pages/Dump.tsx:552 msgid "Edited {0}" msgstr "Edited {0}" @@ -851,7 +852,7 @@ msgstr "Email" msgid "Email address" msgstr "Email address" -#: src/pages/Search.tsx:204 +#: src/pages/Search.tsx:230 msgid "Enter a query to search." msgstr "Enter a query to search." @@ -942,16 +943,16 @@ msgstr "Follow {targetUsername}" msgid "Follow playlist" msgstr "Follow playlist" -#: src/components/feed/FollowedFeed.tsx:164 +#: src/components/feed/FollowedFeed.tsx:192 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:156 +#: src/components/feed/FollowedFeed.tsx:184 msgid "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here." #: src/components/feed/FeedTabBar.tsx:61 -#: src/components/profile/ProfileFollowTab.tsx:140 +#: src/components/profile/ProfileFollowTab.tsx:144 #: src/pages/Settings.tsx:57 #: src/pages/UserPublicProfile.tsx:163 msgid "Followed" @@ -971,7 +972,7 @@ msgstr "For you" msgid "Forgot password?" msgstr "Forgot password?" -#: src/components/feed/FollowedFeed.tsx:137 +#: src/components/feed/FollowedFeed.tsx:165 msgid "From people" msgstr "From people" @@ -980,7 +981,7 @@ msgstr "From people" msgid "email.digest.network" msgstr "From people you follow" -#: src/components/feed/FollowedFeed.tsx:138 +#: src/components/feed/FollowedFeed.tsx:166 msgid "From playlists" msgstr "From playlists" @@ -1024,7 +1025,7 @@ msgstr "If you didn't ask for this, ignore this email. Your password stays the s msgid "image" msgstr "image" -#: src/pages/Dump.tsx:573 +#: src/pages/Dump.tsx:621 msgid "In collections" msgstr "In collections" @@ -1109,7 +1110,7 @@ msgstr "Link" msgid "Link or file" msgstr "Link or file" -#: src/contexts/ws/WSProvider.tsx:763 +#: src/contexts/ws/WSProvider.tsx:764 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…" @@ -1125,7 +1126,7 @@ msgstr "Load more" msgid "Load older messages" msgstr "Load older messages" -#: src/pages/Dump.tsx:306 +#: src/pages/Dump.tsx:337 #: src/pages/DumpEdit.tsx:210 msgid "Loading dump…" msgstr "Loading dump…" @@ -1271,7 +1272,7 @@ msgstr "Next track" msgid "No dumps in this playlist yet." msgstr "No dumps in this playlist yet." -#: src/pages/Search.tsx:220 +#: src/pages/Search.tsx:246 msgid "No dumps match \"{q}\"." msgstr "No dumps match \"{q}\"." @@ -1284,7 +1285,7 @@ msgstr "No dumps yet. Be the first!" msgid "No emoji found." msgstr "No emoji found." -#: src/components/profile/ProfileFollowTab.tsx:113 +#: src/components/profile/ProfileFollowTab.tsx:117 msgid "No followed playlists yet." msgstr "No followed playlists yet." @@ -1300,21 +1301,21 @@ msgstr "No messages yet. Say hello!" msgid "No one yet." msgstr "No one yet." -#: src/pages/Search.tsx:261 +#: src/pages/Search.tsx:287 msgid "No playlists match \"{q}\"." msgstr "No playlists match \"{q}\"." #: src/components/playlist/PlaylistMembershipPanel.tsx:51 -#: src/components/profile/ProfilePlaylistsTab.tsx:185 -#: src/pages/Playlists.tsx:118 +#: src/components/profile/ProfilePlaylistsTab.tsx:178 +#: src/pages/Playlists.tsx:126 msgid "No playlists yet." msgstr "No playlists yet." -#: src/contexts/ws/WSProvider.tsx:932 +#: src/contexts/ws/WSProvider.tsx:934 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:235 +#: src/pages/Search.tsx:261 msgid "No users match \"{q}\"." msgstr "No users match \"{q}\"." @@ -1336,18 +1337,18 @@ msgstr "Nothing happened this week." #: src/components/profile/ProfileDumpsTab.tsx:97 #: src/components/profile/ProfileOverviewTab.tsx:47 -#: src/pages/Notifications.tsx:522 +#: src/pages/Notifications.tsx:513 msgid "Nothing here yet." msgstr "Nothing here yet." -#: src/components/layout/NotificationBell.tsx:57 +#: src/components/layout/NotificationBell.tsx:49 #: src/pages/Notifications.tsx:387 -#: src/pages/Notifications.tsx:505 +#: src/pages/Notifications.tsx:496 #: src/pages/Settings.tsx:163 msgid "Notifications" msgstr "Notifications" -#: src/components/layout/NotificationBell.tsx:56 +#: src/components/layout/NotificationBell.tsx:48 msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} unread)" @@ -1469,11 +1470,11 @@ msgstr "Playlist categories" #: src/components/dump/create/StepIndicator.tsx:21 #: src/components/layout/AppHeader.tsx:172 #: src/components/layout/UserMenu.tsx:92 -#: src/components/profile/ProfileFollowTab.tsx:144 -#: src/components/profile/ProfilePlaylistsTab.tsx:156 -#: src/pages/Playlists.tsx:52 -#: src/pages/Playlists.tsx:106 -#: src/pages/Search.tsx:151 +#: src/components/profile/ProfileFollowTab.tsx:148 +#: src/components/profile/ProfilePlaylistsTab.tsx:149 +#: src/pages/Playlists.tsx:53 +#: src/pages/Playlists.tsx:114 +#: src/pages/Search.tsx:177 #: src/pages/UserPublicProfile.tsx:162 msgid "Playlists" msgstr "Playlists" @@ -1501,7 +1502,7 @@ msgstr "Previous track" #: src/components/playlist/PlaylistCard.tsx:106 #: src/components/playlist/PlaylistHeader.tsx:77 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 -#: src/pages/Dump.tsx:512 +#: src/pages/Dump.tsx:560 msgid "private" msgstr "private" @@ -1544,7 +1545,7 @@ msgstr "Registering…" msgid "Registration failed" msgstr "Registration failed" -#: src/pages/Dump.tsx:564 +#: src/pages/Dump.tsx:612 msgid "Related" msgstr "Related" @@ -1631,7 +1632,7 @@ msgid "email.reset.title" msgstr "Reset your password" #: src/components/chat/PendingItem.tsx:86 -#: src/pages/Dump.tsx:323 +#: src/pages/Dump.tsx:371 #: src/pages/DumpEdit.tsx:227 msgid "Retry" msgstr "Retry" @@ -1646,7 +1647,7 @@ msgstr "Role" #: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/profile/DescriptionEditor.tsx:89 #: src/components/profile/EmailEditor.tsx:75 -#: src/pages/Dump.tsx:438 +#: src/pages/Dump.tsx:486 #: src/pages/DumpEdit.tsx:564 msgid "Save" msgstr "Save" @@ -1656,14 +1657,14 @@ msgstr "Save" #: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/DescriptionEditor.tsx:86 #: src/components/profile/EmailEditor.tsx:72 -#: src/pages/Dump.tsx:437 +#: src/pages/Dump.tsx:485 #: src/pages/ResetPassword.tsx:191 msgid "Saving…" msgstr "Saving…" #: src/components/layout/AppHeader.tsx:182 #: src/components/layout/SearchBar.tsx:83 -#: src/pages/Search.tsx:87 +#: src/pages/Search.tsx:90 msgid "Search" msgstr "Search" @@ -1679,11 +1680,11 @@ msgstr "Search emoji" msgid "Search emoji…" msgstr "Search emoji…" -#: src/pages/Search.tsx:215 +#: src/pages/Search.tsx:241 msgid "Search failed" msgstr "Search failed" -#: src/pages/Search.tsx:210 +#: src/pages/Search.tsx:236 msgid "Searching…" msgstr "Searching…" @@ -1765,7 +1766,7 @@ msgstr "Someone asked to reset the password of your {site} account. The button b #: src/components/form/FormError.tsx:31 #: src/components/layout/PageError.tsx:29 -#: src/contexts/ws/WSProvider.tsx:732 +#: src/contexts/ws/WSProvider.tsx:733 #: src/pages/PlaylistDetail.tsx:155 msgid "Something went wrong" msgstr "Something went wrong" @@ -1799,7 +1800,7 @@ msgstr "the chat" msgid "the chat" msgstr "the chat" -#: src/hooks/playlist/usePlaylist.ts:182 +#: src/hooks/playlist/usePlaylist.ts:177 msgid "The dump couldn't be removed. Try again." msgstr "The dump couldn't be removed. Try again." @@ -1822,7 +1823,7 @@ msgstr "The month on {site}" msgid "email.digest.monthly.subject" msgstr "The month on {site}, {range}" -#: src/hooks/playlist/usePlaylist.ts:325 +#: src/hooks/playlist/usePlaylist.ts:317 msgid "The new order couldn't be saved. Try again." msgstr "The new order couldn't be saved. Try again." @@ -1830,6 +1831,10 @@ msgstr "The new order couldn't be saved. Try again." msgid "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again." +#: src/pages/Dump.tsx:346 +msgid "This dump was deleted, or made private." +msgstr "This dump was deleted, or made private." + #: src/pages/UserRegister.tsx:122 msgid "This invite link is missing, expired, or already used." msgstr "This invite link is missing, expired, or already used." @@ -2035,8 +2040,8 @@ msgstr "User menu" msgid "Username" msgstr "Username" -#: src/components/profile/ProfileFollowTab.tsx:143 -#: src/pages/Search.tsx:150 +#: src/components/profile/ProfileFollowTab.tsx:147 +#: src/pages/Search.tsx:176 msgid "Users" msgstr "Users" @@ -2122,7 +2127,7 @@ msgstr "You won't get the monthly recap any more. You can turn it back on in you msgid "You won't get the weekly digest any more. You can turn it back on in your settings." msgstr "You won't get the weekly digest any more. You can turn it back on in your settings." -#: src/pages/Notifications.tsx:525 +#: src/pages/Notifications.tsx:516 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." @@ -2146,7 +2151,7 @@ msgstr "You're getting this email because a password reset was requested for thi msgid "email.welcome.reason" msgstr "You're getting this email because an account was created on {site} with this address." -#: src/contexts/ws/WSProvider.tsx:913 +#: src/contexts/ws/WSProvider.tsx:915 msgid "You're offline — not sent" msgstr "You're offline — not sent" diff --git a/locales/fr.mjs b/locales/fr.mjs index df4878de..61690c11 100644 --- a/locales/fr.mjs +++ b/locales/fr.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"-K0AvT\":[\"Déconnecter\"],\"-K9EZb\":[\"Ajouter un e-mail…\"],\"-Ya-b9\":[\"Enregistrement échoué\"],\"-iDCtG\":[\"Vos paramètres\"],\"-siMqD\":[\"Journal\"],\"0byTmd\":[\"Toutes les collections\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Rechercher des recos, utilisateurs, collections…\"],\"1QfxQT\":[\"Fermer\"],\"1YKhJ3\":[\"1 nouveau message\"],\"1njn7W\":[\"Clair\"],\"1utXA6\":[\"Recos\"],\"26iNma\":[\"Publier le commentaire\"],\"2DoBvq\":[\"Flux\"],\"2Hlmdt\":[\"Écrire une réponse…\"],\"2_DHgA\":[\"Identifiant d’URL\"],\"2nrsEn\":[\"message supprimé\"],\"2rwzb1\":[\"Vous ne vous êtes encore connecté à aucun autre gerbeur avec ce compte.\"],\"2wxgft\":[\"Renommer\"],\"2ygf_L\":[\"← Retour\"],\"32TndD\":[\"Bloquée\"],\"3KKSM4\":[\"privé\"],\"3QuNUr\":[\"Plusieurs personnes écrivent…\"],\"3SEwY1\":[\"Supprimer la catégorie \\\"\",[\"0\"],\"\\\" ? Cette action est irréversible.\"],\"3qkggm\":[\"Plein écran\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" a suivi votre collection <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…\"],\"5NZ3tg\":[\"Désactivation…\"],\"5TviPn\":[\"Annuler la recherche\"],\"5cC8f2\":[\"Modifié le \",[\"0\"]],\"5oD9f_\":[\"Plus tôt\"],\"6C4kKS\":[\"Autres instances\"],\"6Qly-0\":[\"un commentaire\"],\"6YE0P6\":[\"Édition de \",[\"0\"]],\"6YtxFj\":[\"Nom\"],\"6_dCYd\":[\"Aperçu\"],\"6gRgw8\":[\"Réessayer\"],\"7PHCIN\":[\"Annuler la suppression\"],\"7PzzBU\":[\"Utilisateur\"],\"7d1a0d\":[\"Public\"],\"7noMWp\":[\"Rien de neuf cette semaine.\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8-8abB\":[\"Déconnecté — reconnexion…\"],\"8F1i42\":[\"Page introuvable\"],\"8JplJJ\":[\"Désabonné\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"8rPWOT\":[\"Connexion impossible\"],\"92o1-U\":[\"Suppression échouée\"],\"9BruTc\":[\"Pourquoi ?\"],\"9eEYEq\":[\"Ce lien de connexion est incomplet.\"],\"9l4qcT\":[\"Déposez un fichier de remplacement ici\"],\"9uI_rE\":[\"Annuler\"],\"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\"],\"AwUsnG\":[\"Instances\"],\"B4LeYP\":[\"Échec de la suppression (\",[\"0\"],\")\"],\"BjitBo\":[\"Télécharger \",[\"0\"]],\"BlpJ2G\":[\"Reprise de votre brouillon.\"],\"CI50ct\":[\"Validé\"],\"CJkEfx\":[\"Bloquer\"],\"CL-DkX\":[\"URL d'une instance\"],\"C_qQE4\":[\"Déjà recommandé par \",[\"0\"],\" \",[\"1\"]],\"Cf_gxi\":[\"Ne plus recevoir le résumé hebdomadaire ?\"],\"Cj24wt\":[\"Inscription…\"],\"ClFfRL\":[\"Catégories de collections\"],\"CnqayG\":[\"La mise à jour a échoué. Réessayez.\"],\"Czya-u\":[\"Les utilisateurs d'une instance approuvée se connectent ici avec leur compte de là-bas. Ceux d'une instance bloquée ne le peuvent pas, et les utilisateurs d'ici ne peuvent pas s'y connecter. Ceux de toute autre instance ont besoin d'une invitation.\"],\"D8DRYV\":[\"Supprimer ce message ?\"],\"DI3NBO\":[\"Ouvrir sur \",[\"0\"]],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a validé <1>\",[\"1\"],\"\"],\"E417RG\":[\"Vous ne recevrez plus le résumé hebdomadaire. Vous pouvez le réactiver dans vos paramètres.\"],\"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\"],\"IF7RiR\":[\"Couverture\"],\"IO9KL_\":[\"Piste suivante\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Lettres, chiffres et tirets bas seulement\"],\"ImOQa9\":[\"Répondre\"],\"JJ-Bhk\":[\"Votre adresse e-mail\"],\"JLasL-\":[\"Désabonnement impossible\"],\"JRQitQ\":[\"Confirmer le nouveau mot de passe\"],\"JXr41k\":[\"<0>\",[\"0\"],\" a commenté sur <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Tendances\"],\"Jf0PuK\":[\"Aller à la connexion\"],\"JlFcis\":[\"Envoyer\"],\"JrFTcr\":[\"Connexion…\"],\"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…\"],\"MHrjPM\":[\"Titre\"],\"MKEPCY\":[\"Suivre\"],\"MTb2Ot\":[\"Aucun message pour l'instant. Dites bonjour !\"],\"MbkoRn\":[\"Ou rejoignez-nous avec votre compte d'un autre gerbeur\"],\"Mj6oCJ\":[[\"0\"],\" est en train d'écrire…\"],\"MrIc3x\":[\"Ou connectez-vous avec votre compte d'un autre gerbeur\"],\"N40H-G\":[\"Tout\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"NUrY9o\":[\"Catégories\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Modifier la description\"],\"O3oNi5\":[\"E-mail\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"P5MuiB\":[\"Ajouter une image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" nouvelle\"],\"other\":[\"#\",\" nouvelles\"]}]],\"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 fichiers\"],\"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.\"],\"RLe7Vk\":[\"Vérification…\"],\"RTksSy\":[\"<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…\"],\"SLaP1b\":[\"Compte d'ailleurs\"],\"SMaFdc\":[\"Se désabonner\"],\"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\"],\" a été ajouté à <1>\",[\"1\"],\"\"],\"TAkYGZ\":[\"Votre compte est sur \",[\"home\"],\" : connectez-vous aux autres instances depuis là-bas.\"],\"TAtbho\":[\"Chargement des messages…\"],\"TN382O\":[\"Lien invalide\"],\"ToADL_\":[\"<0>\",[\"name\"],\" (\",[\"host\"],\") demande à vous connecter avec votre compte d'ici, <1>@\",[\"username\"],\". Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar.\"],\"Tv9vbB\":[\"Suivre la collection\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Administrateur\"],\"UNMVei\":[\"Mot de passe oublié ?\"],\"UOV0wi\":[\"Connexion…\"],\"UOZith\":[\"Publication échouée\"],\"URAieT\":[\"Se connecter pour valider\"],\"V38P77\":[\"Seuls son auteur et les modérateurs peuvent modifier ce dump.\"],\"VCoEm-\":[\"Retour à la connexion\"],\"VKsaTi\":[\"En réponse à\"],\"VLAd8f\":[\"Max \",[\"limit\"]],\"VLINRu\":[\"URL de votre gerbeur\"],\"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\"],\"WMw63e\":[\"Impossible de joindre cette instance\"],\"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…\"],\"XWbTfu\":[\"utilisée \",[\"0\"]],\"Xan6QP\":[\"Nouvelle reco\"],\"XgRtUf\":[\"Impossible de changer le mot de passe\"],\"XnL-Eu\":[\"Aucun utilisateur ne correspond à « \",[\"q\"],\" ».\"],\"Xs2Lez\":[\"Ce lien de réinitialisation est absent ou malformé.\"],\"YK1Dhc\":[\"une publication\"],\"YWnSuq\":[\"Ce lien de désabonnement est absent ou mal formé.\"],\"YZxXK8\":[\"Fermer le sélecteur d'emoji\"],\"YcjhhX\":[\"Gestion utilisateur\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" a aimé votre commentaire sur <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\"],\"_0oJwI\":[\"Approuver\"],\"_3O5R_\":[\"Échec de la demande\"],\"_DwR-n\":[\"Création…\"],\"_I1_HN\":[\"Minuscules, chiffres et tirets simples seulement\"],\"_R_sGB\":[\"Mot de passe modifié avec succès.\"],\"_aept4\":[\"Publier la réponse\"],\"_cF7Rs\":[\"Volume\"],\"_eZzBf\":[\"Se connecter ailleurs\"],\"_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…\"],\"b_1P1D\":[\"Renommage…\"],\"b_VouM\":[\"Votre compte est sur \",[\"home\"],\" : votre mot de passe, votre adresse e-mail, votre avatar et votre nom d'utilisateur se changent là-bas.\"],\"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\"],\"digest.community\":[\"Communauté\"],\"digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"commentaire\"],\"other\":[\"commentaires\"]}]],\"digest.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"vote\"],\"other\":[\"votes\"]}]],\"digest.invitedBy\":[\"sur invitation de @\",[\"username\"]],\"digest.invitedByYou\":[\"sur votre invitation\"],\"digest.monthly\":[\"Récap mensuel\"],\"digest.monthly.off\":[\"Ne plus recevoir le récap mensuel\"],\"digest.newMembers\":[\"Nouveaux membres\"],\"digest.newPlaylists\":[\"Nouvelles collections publiques\"],\"digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"message dans la tribune\"],\"other\":[\"messages dans la tribune\"]}]],\"digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"reco\"],\"other\":[\"recos\"]}]],\"digest.topMonth\":[\"Les plus validées du mois\"],\"digest.topWeek\":[\"Les plus validées de la semaine\"],\"digest.weekly\":[\"Résumé hebdomadaire\"],\"digest.weekly.off\":[\"Ne plus recevoir le résumé hebdomadaire\"],\"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.\"],\"email.digest.addedTo\":[\"ajoutée à \",[\"playlist\"]],\"email.digest.andMore\":[\"et \",[\"n\"],\" de plus\"],\"email.digest.by\":[\"par @\",[\"username\"]],\"email.digest.chatWeek\":[[\"n\",\"plural\",{\"one\":[\"#\",\" message cette semaine\"],\"other\":[\"#\",\" messages cette semaine\"]}]],\"email.digest.communityPage\":[\"Tout voir sur la page de la communauté\"],\"email.digest.count.followers\":[[\"n\",\"plural\",{\"one\":[\"nouvel abonné\"],\"other\":[\"nouveaux abonnés\"]}]],\"email.digest.count.likes\":[[\"n\",\"plural\",{\"one\":[\"j'aime sur vos commentaires\"],\"other\":[\"j'aime sur vos commentaires\"]}]],\"email.digest.count.mentions\":[[\"n\",\"plural\",{\"one\":[\"mention\"],\"other\":[\"mentions\"]}]],\"email.digest.count.playlistFollows\":[[\"n\",\"plural\",{\"one\":[\"abonnement à vos collections\"],\"other\":[\"abonnements à vos collections\"]}]],\"email.digest.forYou\":[\"Pour vous\"],\"email.digest.hl.comment\":[[\"who\"],\" a commenté votre reco \",[\"target\"]],\"email.digest.hl.commentLike\":[[\"who\"],\" a aimé votre commentaire sur \",[\"target\"]],\"email.digest.hl.mentionComment\":[[\"who\"],\" vous a mentionné dans un commentaire sur \",[\"target\"]],\"email.digest.hl.mentionEveryone\":[[\"who\"],\" a mentionné tout le monde dans \",[\"target\"]],\"email.digest.hl.mentionIn\":[[\"who\"],\" vous a mentionné dans \",[\"target\"]],\"email.digest.hl.mentionPlaylist\":[[\"who\"],\" vous a mentionné dans la collection \",[\"target\"]],\"email.digest.hl.playlistFollow\":[[\"who\"],\" a suivi votre collection \",[\"target\"]],\"email.digest.hl.userFollow\":[[\"who\"],\" vous suit désormais\"],\"email.digest.inChat\":[\"Dans la tribune\"],\"email.digest.monthly.reason\":[\"Vous recevez ce récap mensuel car il est activé dans vos paramètres.\"],\"email.digest.monthly.subject\":[\"Le mois sur \",[\"site\"],\", \",[\"range\"]],\"email.digest.monthly.title\":[\"Le mois sur \",[\"site\"]],\"email.digest.more\":[[\"n\"],\" de plus dans votre fil Suivi\"],\"email.digest.network\":[\"De ceux que vous suivez\"],\"email.digest.openChat\":[\"Ouvrir la tribune\"],\"email.digest.playlistMeta\":[\"par @\",[\"username\"],\" · \",[\"n\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"email.digest.pre.members\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouveau membre\"],\"other\":[\"#\",\" nouveaux membres\"]}]],\"email.digest.pre.network\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouvelle reco de ceux que vous suivez\"],\"other\":[\"#\",\" nouvelles recos de ceux que vous suivez\"]}]],\"email.digest.pre.playlists\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouvelle collection\"],\"other\":[\"#\",\" nouvelles collections\"]}]],\"email.digest.pre.topMonth\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus validée du mois\"],\"other\":[\"les \",\"#\",\" recos les plus validées du mois\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus validée de la semaine\"],\"other\":[\"les \",\"#\",\" recos les plus validées de la semaine\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"Voir la notification\"],\"other\":[\"Voir les \",\"#\",\" notifications\"]}]],\"email.digest.theChat\":[\"la tribune\"],\"email.digest.totals\":[[\"dumps\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}],\", \",[\"upvotes\",\"plural\",{\"one\":[\"#\",\" vote\"],\"other\":[\"#\",\" votes\"]}],\" et \",[\"comments\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}],\".\"],\"email.digest.upload\":[\"fichier\"],\"email.digest.weekly.reason\":[\"Vous recevez ce résumé hebdomadaire car il est activé dans vos paramètres.\"],\"email.digest.weekly.subject\":[\"Votre semaine sur \",[\"site\"],\", \",[\"range\"]],\"email.digest.weekly.title\":[\"Votre semaine, \",[\"username\"]],\"email.digest.yours\":[\"à vous\"],\"email.reset.body\":[\"Quelqu'un a demandé à réinitialiser le mot de passe de votre compte \",[\"site\"],\". Le bouton ci-dessous vous permet d'en choisir un nouveau. Il ne fonctionne qu'une fois, pendant \",[\"hours\",\"plural\",{\"one\":[\"#\",\" heure\"],\"other\":[\"#\",\" heures\"]}],\".\"],\"email.reset.button\":[\"Choisir un nouveau mot de passe\"],\"email.reset.fallback\":[\"Si le bouton ne fonctionne pas, collez ce lien dans votre navigateur :\"],\"email.reset.hi\":[\"Bonjour \",[\"username\"],\",\"],\"email.reset.ignore\":[\"Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. Votre mot de passe ne change pas.\"],\"email.reset.preheader\":[\"Le lien ne fonctionne qu'une fois, pendant \",[\"hours\",\"plural\",{\"one\":[\"#\",\" heure\"],\"other\":[\"#\",\" heures\"]}],\".\"],\"email.reset.reason\":[\"Vous recevez cet e-mail car une réinitialisation de mot de passe a été demandée pour cette adresse.\"],\"email.reset.subject\":[\"Réinitialisez votre mot de passe \",[\"site\"]],\"email.reset.title\":[\"Réinitialiser votre mot de passe\"],\"email.settings\":[\"Paramètres\"],\"email.welcome.body\":[\"# Bienvenue sur \",[\"site\"],\" !\\n\\nBonjour **\",[\"username\"],\"**,\\n\\nVotre compte a bien été créé. Bienvenue à bord !\"],\"email.welcome.button\":[\"Ouvrir \",[\"site\"]],\"email.welcome.digests\":[\"Chaque semaine, vous recevrez un résumé de ce qui s'est passé ici, et chaque mois un récap des recos les plus validées. Vous pouvez désactiver l'un ou l'autre dans vos paramètres.\"],\"email.welcome.preheader\":[\"Votre compte est prêt.\"],\"email.welcome.reason\":[\"Vous recevez cet e-mail car un compte \",[\"site\"],\" a été créé avec cette adresse.\"],\"email.welcome.subject\":[\"Bienvenue sur \",[\"site\"]],\"en9o7K\":[\"Impossible de publier le commentaire\"],\"etFQQS\":[\"Pourquoi on en voudrait ?\"],\"f3cEDO\":[\"Changer l'image\"],\"fC6mXb\":[\"Valider\"],\"fI-mNw\":[\"Collections\"],\"fgLNSM\":[\"S'inscrire\"],\"fn1ZQX\":[\"Se connecter à \",[\"name\"],\" ?\"],\"gANddk\":[\"Envoi…\"],\"gGx5tM\":[\"Modification\"],\"gIQQwD\":[\"Chargement échoué\"],\"gIpLuy\":[\"Il faut un lien\"],\"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\"],\" a publié <1>\",[\"1\"],\"\"],\"hLAe-z\":[\"Retour à la page de connexion\"],\"hT8hgO\":[\"Rétablir le titre suggéré\"],\"hYgDIe\":[\"Créer\"],\"he3ygx\":[\"Copier\"],\"hpCc_Z\":[\"Fermer quand même\"],\"i3uTFh\":[\"Catégories de recos\"],\"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\"],\"jTGs8A\":[\"Ce site n'envoie pas d'e-mails, aucun résumé ne part donc.\"],\"jZNg18\":[\"Compte sur <0>\",[\"host\"],\"\"],\"jbernk\":[\"Chargement du profil…\"],\"jnrIbz\":[\"Validé (\",[\"0\"],\")\"],\"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\"],\"lUanmi\":[\"Vous serez notifié lorsque quelqu'un suit vos collections, valide 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\"],\"m9Uf3I\":[\"Les utilisateurs d'une instance approuvée se connectent ici avec leur compte de là-bas. Ceux d'une instance bloquée ne le peuvent pas. Ceux de toute autre instance ont besoin d'une invitation.\"],\"mDJo8W\":[\"Recos (\",[\"0\"],\")\"],\"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…\"],\"nucyoV\":[\"<0>\",[\"0\"],\" a mentionné tout le monde dans <1>\",[\"where\"],\"\"],\"nwtY4N\":[\"Une erreur est survenue\"],\"nx4kaN\":[\"Sauvegarde impossible\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"et \",\"#\",\" reco plus ancienne\"],\"other\":[\"et \",\"#\",\" recos plus anciennes\"]}]],\"oW3yYY\":[\"Ce mois-ci\"],\"ogtYkT\":[\"Mot de passe mis à jour\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" vous a mentionné dans <1>\",[\"where\"],\"\"],\"pSheLH\":[\"Aucun invité pour le moment.\"],\"pd3FCq\":[\"Ce fichier dépasse \",[\"limit\"],\".\"],\"pvnfJD\":[\"Sombre\"],\"q018gJ\":[\"Impossible d'ajouter cette instance\"],\"qIMfNQ\":[\"Supprimer la collection\"],\"qItrqH\":[\"Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans vos paramètres.\"],\"qVdjD5\":[\"Approuvée\"],\"qbDAcy\":[\"Recommander\"],\"qgx_78\":[\"Suivez des collections publiques pour voir leurs recos ici.\"],\"qnUJBI\":[\"Le nouvel ordre n'a pas pu être enregistré. Réessayez.\"],\"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\"],\"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\"],\"tOHgQw\":[\"Continuer en tant que @\",[\"username\"]],\"tXNIqW\":[\"Cette connexion a été commencée ailleurs. Recommencez depuis la page de connexion.\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Enregistrer\"],\"tvmuQ0\":[\"Thème de couleur\"],\"tyiiHe\":[\"Rien de neuf ce mois-ci.\"],\"u1lDX2\":[\"Récupération de l'aperçu…\"],\"u8pttd\":[\"Le changer sur \",[\"home\"]],\"uACFmj\":[\"Se connecter avec \",[\"0\"]],\"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\"],\"w61W3L\":[\"Retirer \",[\"0\"]],\"wckWOP\":[\"Administration\"],\"wixIgH\":[\"Vous avez déjà un compte ? <0>Se connecter\"],\"wrFDaD\":[\"Les utilisateurs d'ici ne peuvent pas se connecter à une instance bloquée.\"],\"wtxFp4\":[\"Fichier trop volumineux (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Onglet par défaut\"],\"xEWkgZ\":[\"← Retour à toutes les recos\"],\"xGVfLh\":[\"Continuer\"],\"xPHtx0\":[\"Lancer la recherche\"],\"xVuNgt\":[\"+ Nouvelle collection\"],\"xc9O_u\":[\"Supprimer la reco\"],\"y1G_DI\":[\"Renommage impossible\"],\"y6sq5j\":[\"Abonné\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"yByRxz\":[\"Cette semaine\"],\"yQ2kGp\":[\"Charger plus\"],\"y_0uwd\":[\"Hier\"],\"yxlcv5\":[\"Ne plus recevoir le récap mensuel ?\"],\"yz7wBu\":[\"Fermer\"],\"yzF66j\":[\"Lien\"],\"z0ROB3\":[\"Retirer le j'aime\"],\"z1uNN0\":[\"Aucun emoji trouvé.\"],\"zVuxvN\":[\"Actualisation…\"],\"zwBp5t\":[\"Privé\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"-K0AvT\":[\"Déconnecter\"],\"-K9EZb\":[\"Ajouter un e-mail…\"],\"-Ya-b9\":[\"Enregistrement échoué\"],\"-iDCtG\":[\"Vos paramètres\"],\"-siMqD\":[\"Journal\"],\"0byTmd\":[\"Toutes les collections\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Rechercher des recos, utilisateurs, collections…\"],\"1QfxQT\":[\"Fermer\"],\"1YKhJ3\":[\"1 nouveau message\"],\"1njn7W\":[\"Clair\"],\"1utXA6\":[\"Recos\"],\"26iNma\":[\"Publier le commentaire\"],\"2DoBvq\":[\"Flux\"],\"2Hlmdt\":[\"Écrire une réponse…\"],\"2_DHgA\":[\"Identifiant d’URL\"],\"2nrsEn\":[\"message supprimé\"],\"2rwzb1\":[\"Vous ne vous êtes encore connecté à aucun autre gerbeur avec ce compte.\"],\"2wxgft\":[\"Renommer\"],\"2ygf_L\":[\"← Retour\"],\"32TndD\":[\"Bloquée\"],\"3KKSM4\":[\"privé\"],\"3QuNUr\":[\"Plusieurs personnes écrivent…\"],\"3SEwY1\":[\"Supprimer la catégorie \\\"\",[\"0\"],\"\\\" ? Cette action est irréversible.\"],\"3qkggm\":[\"Plein écran\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" a suivi votre collection <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…\"],\"5NZ3tg\":[\"Désactivation…\"],\"5TviPn\":[\"Annuler la recherche\"],\"5cC8f2\":[\"Modifié le \",[\"0\"]],\"5oD9f_\":[\"Plus tôt\"],\"6BV5h6\":[\"Cette reco a été supprimée, ou rendue privée.\"],\"6C4kKS\":[\"Autres instances\"],\"6Qly-0\":[\"un commentaire\"],\"6YE0P6\":[\"Édition de \",[\"0\"]],\"6YtxFj\":[\"Nom\"],\"6_dCYd\":[\"Aperçu\"],\"6gRgw8\":[\"Réessayer\"],\"7PHCIN\":[\"Annuler la suppression\"],\"7PzzBU\":[\"Utilisateur\"],\"7d1a0d\":[\"Public\"],\"7noMWp\":[\"Rien de neuf cette semaine.\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8-8abB\":[\"Déconnecté — reconnexion…\"],\"8F1i42\":[\"Page introuvable\"],\"8JplJJ\":[\"Désabonné\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"8rPWOT\":[\"Connexion impossible\"],\"92o1-U\":[\"Suppression échouée\"],\"9BruTc\":[\"Pourquoi ?\"],\"9eEYEq\":[\"Ce lien de connexion est incomplet.\"],\"9l4qcT\":[\"Déposez un fichier de remplacement ici\"],\"9uI_rE\":[\"Annuler\"],\"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\"],\"AwUsnG\":[\"Instances\"],\"B4LeYP\":[\"Échec de la suppression (\",[\"0\"],\")\"],\"BjitBo\":[\"Télécharger \",[\"0\"]],\"BlpJ2G\":[\"Reprise de votre brouillon.\"],\"CI50ct\":[\"Validé\"],\"CJkEfx\":[\"Bloquer\"],\"CL-DkX\":[\"URL d'une instance\"],\"C_qQE4\":[\"Déjà recommandé par \",[\"0\"],\" \",[\"1\"]],\"Cf_gxi\":[\"Ne plus recevoir le résumé hebdomadaire ?\"],\"Cj24wt\":[\"Inscription…\"],\"ClFfRL\":[\"Catégories de collections\"],\"CnqayG\":[\"La mise à jour a échoué. Réessayez.\"],\"Czya-u\":[\"Les utilisateurs d'une instance approuvée se connectent ici avec leur compte de là-bas. Ceux d'une instance bloquée ne le peuvent pas, et les utilisateurs d'ici ne peuvent pas s'y connecter. Ceux de toute autre instance ont besoin d'une invitation.\"],\"D8DRYV\":[\"Supprimer ce message ?\"],\"DI3NBO\":[\"Ouvrir sur \",[\"0\"]],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a validé <1>\",[\"1\"],\"\"],\"E417RG\":[\"Vous ne recevrez plus le résumé hebdomadaire. Vous pouvez le réactiver dans vos paramètres.\"],\"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\"],\"IF7RiR\":[\"Couverture\"],\"IO9KL_\":[\"Piste suivante\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"IhK90M\":[\"Lettres, chiffres et tirets bas seulement\"],\"ImOQa9\":[\"Répondre\"],\"JJ-Bhk\":[\"Votre adresse e-mail\"],\"JLasL-\":[\"Désabonnement impossible\"],\"JRQitQ\":[\"Confirmer le nouveau mot de passe\"],\"JXr41k\":[\"<0>\",[\"0\"],\" a commenté sur <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Tendances\"],\"Jf0PuK\":[\"Aller à la connexion\"],\"JlFcis\":[\"Envoyer\"],\"JrFTcr\":[\"Connexion…\"],\"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…\"],\"MHrjPM\":[\"Titre\"],\"MKEPCY\":[\"Suivre\"],\"MTb2Ot\":[\"Aucun message pour l'instant. Dites bonjour !\"],\"MbkoRn\":[\"Ou rejoignez-nous avec votre compte d'un autre gerbeur\"],\"Mj6oCJ\":[[\"0\"],\" est en train d'écrire…\"],\"MrIc3x\":[\"Ou connectez-vous avec votre compte d'un autre gerbeur\"],\"N40H-G\":[\"Tout\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"NUrY9o\":[\"Catégories\"],\"Nu4oKW\":[\"Description\"],\"O32mmL\":[\"Modifier la description\"],\"O3oNi5\":[\"E-mail\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"P5MuiB\":[\"Ajouter une image\"],\"PJTy3N\":[[\"totalUnread\",\"plural\",{\"one\":[\"#\",\" nouvelle\"],\"other\":[\"#\",\" nouvelles\"]}]],\"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 fichiers\"],\"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.\"],\"RLe7Vk\":[\"Vérification…\"],\"RTksSy\":[\"<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…\"],\"SLaP1b\":[\"Compte d'ailleurs\"],\"SMaFdc\":[\"Se désabonner\"],\"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\"],\" a été ajouté à <1>\",[\"1\"],\"\"],\"TAkYGZ\":[\"Votre compte est sur \",[\"home\"],\" : connectez-vous aux autres instances depuis là-bas.\"],\"TAtbho\":[\"Chargement des messages…\"],\"TN382O\":[\"Lien invalide\"],\"ToADL_\":[\"<0>\",[\"name\"],\" (\",[\"host\"],\") demande à vous connecter avec votre compte d'ici, <1>@\",[\"username\"],\". Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar.\"],\"Tv9vbB\":[\"Suivre la collection\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Administrateur\"],\"UNMVei\":[\"Mot de passe oublié ?\"],\"UOV0wi\":[\"Connexion…\"],\"UOZith\":[\"Publication échouée\"],\"URAieT\":[\"Se connecter pour valider\"],\"V38P77\":[\"Seuls son auteur et les modérateurs peuvent modifier ce dump.\"],\"VCoEm-\":[\"Retour à la connexion\"],\"VKsaTi\":[\"En réponse à\"],\"VLAd8f\":[\"Max \",[\"limit\"]],\"VLINRu\":[\"URL de votre gerbeur\"],\"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\"],\"WMw63e\":[\"Impossible de joindre cette instance\"],\"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…\"],\"XWbTfu\":[\"utilisée \",[\"0\"]],\"Xan6QP\":[\"Nouvelle reco\"],\"XgRtUf\":[\"Impossible de changer le mot de passe\"],\"XnL-Eu\":[\"Aucun utilisateur ne correspond à « \",[\"q\"],\" ».\"],\"YK1Dhc\":[\"une publication\"],\"YWnSuq\":[\"Ce lien de désabonnement est absent ou mal formé.\"],\"YZxXK8\":[\"Fermer le sélecteur d'emoji\"],\"YcjhhX\":[\"Gestion utilisateur\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" a aimé votre commentaire sur <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\"],\"_0oJwI\":[\"Approuver\"],\"_3O5R_\":[\"Échec de la demande\"],\"_DwR-n\":[\"Création…\"],\"_I1_HN\":[\"Minuscules, chiffres et tirets simples seulement\"],\"_R_sGB\":[\"Mot de passe modifié avec succès.\"],\"_aept4\":[\"Publier la réponse\"],\"_cF7Rs\":[\"Volume\"],\"_eZzBf\":[\"Se connecter ailleurs\"],\"_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…\"],\"b_1P1D\":[\"Renommage…\"],\"b_VouM\":[\"Votre compte est sur \",[\"home\"],\" : votre mot de passe, votre adresse e-mail, votre avatar et votre nom d'utilisateur se changent là-bas.\"],\"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\"],\"digest.community\":[\"Communauté\"],\"digest.count.comments\":[[\"n\",\"plural\",{\"one\":[\"commentaire\"],\"other\":[\"commentaires\"]}]],\"digest.count.upvotes\":[[\"n\",\"plural\",{\"one\":[\"vote\"],\"other\":[\"votes\"]}]],\"digest.invitedBy\":[\"sur invitation de @\",[\"username\"]],\"digest.invitedByYou\":[\"sur votre invitation\"],\"digest.monthly\":[\"Récap mensuel\"],\"digest.monthly.off\":[\"Ne plus recevoir le récap mensuel\"],\"digest.newMembers\":[\"Nouveaux membres\"],\"digest.newPlaylists\":[\"Nouvelles collections publiques\"],\"digest.stat.chat\":[[\"n\",\"plural\",{\"one\":[\"message dans la tribune\"],\"other\":[\"messages dans la tribune\"]}]],\"digest.stat.dumps\":[[\"n\",\"plural\",{\"one\":[\"reco\"],\"other\":[\"recos\"]}]],\"digest.topMonth\":[\"Les plus validées du mois\"],\"digest.topWeek\":[\"Les plus validées de la semaine\"],\"digest.weekly\":[\"Résumé hebdomadaire\"],\"digest.weekly.off\":[\"Ne plus recevoir le résumé hebdomadaire\"],\"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.\"],\"email.digest.addedTo\":[\"ajoutée à \",[\"playlist\"]],\"email.digest.andMore\":[\"et \",[\"n\"],\" de plus\"],\"email.digest.by\":[\"par @\",[\"username\"]],\"email.digest.chatWeek\":[[\"n\",\"plural\",{\"one\":[\"#\",\" message cette semaine\"],\"other\":[\"#\",\" messages cette semaine\"]}]],\"email.digest.communityPage\":[\"Tout voir sur la page de la communauté\"],\"email.digest.count.followers\":[[\"n\",\"plural\",{\"one\":[\"nouvel abonné\"],\"other\":[\"nouveaux abonnés\"]}]],\"email.digest.count.likes\":[[\"n\",\"plural\",{\"one\":[\"j'aime sur vos commentaires\"],\"other\":[\"j'aime sur vos commentaires\"]}]],\"email.digest.count.mentions\":[[\"n\",\"plural\",{\"one\":[\"mention\"],\"other\":[\"mentions\"]}]],\"email.digest.count.playlistFollows\":[[\"n\",\"plural\",{\"one\":[\"abonnement à vos collections\"],\"other\":[\"abonnements à vos collections\"]}]],\"email.digest.forYou\":[\"Pour vous\"],\"email.digest.hl.comment\":[[\"who\"],\" a commenté votre reco \",[\"target\"]],\"email.digest.hl.commentLike\":[[\"who\"],\" a aimé votre commentaire sur \",[\"target\"]],\"email.digest.hl.mentionComment\":[[\"who\"],\" vous a mentionné dans un commentaire sur \",[\"target\"]],\"email.digest.hl.mentionEveryone\":[[\"who\"],\" a mentionné tout le monde dans \",[\"target\"]],\"email.digest.hl.mentionIn\":[[\"who\"],\" vous a mentionné dans \",[\"target\"]],\"email.digest.hl.mentionPlaylist\":[[\"who\"],\" vous a mentionné dans la collection \",[\"target\"]],\"email.digest.hl.playlistFollow\":[[\"who\"],\" a suivi votre collection \",[\"target\"]],\"email.digest.hl.userFollow\":[[\"who\"],\" vous suit désormais\"],\"email.digest.inChat\":[\"Dans la tribune\"],\"email.digest.monthly.reason\":[\"Vous recevez ce récap mensuel car il est activé dans vos paramètres.\"],\"email.digest.monthly.subject\":[\"Le mois sur \",[\"site\"],\", \",[\"range\"]],\"email.digest.monthly.title\":[\"Le mois sur \",[\"site\"]],\"email.digest.more\":[[\"n\"],\" de plus dans votre fil Suivi\"],\"email.digest.network\":[\"De ceux que vous suivez\"],\"email.digest.openChat\":[\"Ouvrir la tribune\"],\"email.digest.playlistMeta\":[\"par @\",[\"username\"],\" · \",[\"n\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"email.digest.pre.members\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouveau membre\"],\"other\":[\"#\",\" nouveaux membres\"]}]],\"email.digest.pre.network\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouvelle reco de ceux que vous suivez\"],\"other\":[\"#\",\" nouvelles recos de ceux que vous suivez\"]}]],\"email.digest.pre.playlists\":[[\"n\",\"plural\",{\"one\":[\"#\",\" nouvelle collection\"],\"other\":[\"#\",\" nouvelles collections\"]}]],\"email.digest.pre.topMonth\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus validée du mois\"],\"other\":[\"les \",\"#\",\" recos les plus validées du mois\"]}]],\"email.digest.pre.topWeek\":[[\"n\",\"plural\",{\"one\":[\"la reco la plus validée de la semaine\"],\"other\":[\"les \",\"#\",\" recos les plus validées de la semaine\"]}]],\"email.digest.seeAll\":[[\"n\",\"plural\",{\"one\":[\"Voir la notification\"],\"other\":[\"Voir les \",\"#\",\" notifications\"]}]],\"email.digest.theChat\":[\"la tribune\"],\"email.digest.totals\":[[\"dumps\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}],\", \",[\"upvotes\",\"plural\",{\"one\":[\"#\",\" vote\"],\"other\":[\"#\",\" votes\"]}],\" et \",[\"comments\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}],\".\"],\"email.digest.upload\":[\"fichier\"],\"email.digest.weekly.reason\":[\"Vous recevez ce résumé hebdomadaire car il est activé dans vos paramètres.\"],\"email.digest.weekly.subject\":[\"Votre semaine sur \",[\"site\"],\", \",[\"range\"]],\"email.digest.weekly.title\":[\"Votre semaine, \",[\"username\"]],\"email.digest.yours\":[\"à vous\"],\"email.reset.body\":[\"Quelqu'un a demandé à réinitialiser le mot de passe de votre compte \",[\"site\"],\". Le bouton ci-dessous vous permet d'en choisir un nouveau. Il ne fonctionne qu'une fois, pendant \",[\"hours\",\"plural\",{\"one\":[\"#\",\" heure\"],\"other\":[\"#\",\" heures\"]}],\".\"],\"email.reset.button\":[\"Choisir un nouveau mot de passe\"],\"email.reset.fallback\":[\"Si le bouton ne fonctionne pas, collez ce lien dans votre navigateur :\"],\"email.reset.hi\":[\"Bonjour \",[\"username\"],\",\"],\"email.reset.ignore\":[\"Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. Votre mot de passe ne change pas.\"],\"email.reset.preheader\":[\"Le lien ne fonctionne qu'une fois, pendant \",[\"hours\",\"plural\",{\"one\":[\"#\",\" heure\"],\"other\":[\"#\",\" heures\"]}],\".\"],\"email.reset.reason\":[\"Vous recevez cet e-mail car une réinitialisation de mot de passe a été demandée pour cette adresse.\"],\"email.reset.subject\":[\"Réinitialisez votre mot de passe \",[\"site\"]],\"email.reset.title\":[\"Réinitialiser votre mot de passe\"],\"email.settings\":[\"Paramètres\"],\"email.welcome.body\":[\"# Bienvenue sur \",[\"site\"],\" !\\n\\nBonjour **\",[\"username\"],\"**,\\n\\nVotre compte a bien été créé. Bienvenue à bord !\"],\"email.welcome.button\":[\"Ouvrir \",[\"site\"]],\"email.welcome.digests\":[\"Chaque semaine, vous recevrez un résumé de ce qui s'est passé ici, et chaque mois un récap des recos les plus validées. Vous pouvez désactiver l'un ou l'autre dans vos paramètres.\"],\"email.welcome.preheader\":[\"Votre compte est prêt.\"],\"email.welcome.reason\":[\"Vous recevez cet e-mail car un compte \",[\"site\"],\" a été créé avec cette adresse.\"],\"email.welcome.subject\":[\"Bienvenue sur \",[\"site\"]],\"en9o7K\":[\"Impossible de publier le commentaire\"],\"etFQQS\":[\"Pourquoi on en voudrait ?\"],\"f3cEDO\":[\"Changer l'image\"],\"fC6mXb\":[\"Valider\"],\"fI-mNw\":[\"Collections\"],\"fgLNSM\":[\"S'inscrire\"],\"fn1ZQX\":[\"Se connecter à \",[\"name\"],\" ?\"],\"gANddk\":[\"Envoi…\"],\"gGx5tM\":[\"Modification\"],\"gIQQwD\":[\"Chargement échoué\"],\"gIpLuy\":[\"Il faut un lien\"],\"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\"],\" a publié <1>\",[\"1\"],\"\"],\"hLAe-z\":[\"Retour à la page de connexion\"],\"hT8hgO\":[\"Rétablir le titre suggéré\"],\"hYgDIe\":[\"Créer\"],\"he3ygx\":[\"Copier\"],\"hpCc_Z\":[\"Fermer quand même\"],\"i3uTFh\":[\"Catégories de recos\"],\"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\"],\"jTGs8A\":[\"Ce site n'envoie pas d'e-mails, aucun résumé ne part donc.\"],\"jZNg18\":[\"Compte sur <0>\",[\"host\"],\"\"],\"jbernk\":[\"Chargement du profil…\"],\"jnrIbz\":[\"Validé (\",[\"0\"],\")\"],\"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\"],\"lUanmi\":[\"Vous serez notifié lorsque quelqu'un suit vos collections, valide 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\"],\"m9Uf3I\":[\"Les utilisateurs d'une instance approuvée se connectent ici avec leur compte de là-bas. Ceux d'une instance bloquée ne le peuvent pas. Ceux de toute autre instance ont besoin d'une invitation.\"],\"mDJo8W\":[\"Recos (\",[\"0\"],\")\"],\"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…\"],\"nucyoV\":[\"<0>\",[\"0\"],\" a mentionné tout le monde dans <1>\",[\"where\"],\"\"],\"nwtY4N\":[\"Une erreur est survenue\"],\"nx4kaN\":[\"Sauvegarde impossible\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"et \",\"#\",\" reco plus ancienne\"],\"other\":[\"et \",\"#\",\" recos plus anciennes\"]}]],\"oW3yYY\":[\"Ce mois-ci\"],\"ogtYkT\":[\"Mot de passe mis à jour\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" vous a mentionné dans <1>\",[\"where\"],\"\"],\"pSheLH\":[\"Aucun invité pour le moment.\"],\"pd3FCq\":[\"Ce fichier dépasse \",[\"limit\"],\".\"],\"pvnfJD\":[\"Sombre\"],\"q018gJ\":[\"Impossible d'ajouter cette instance\"],\"qIMfNQ\":[\"Supprimer la collection\"],\"qItrqH\":[\"Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans vos paramètres.\"],\"qVdjD5\":[\"Approuvée\"],\"qbDAcy\":[\"Recommander\"],\"qgx_78\":[\"Suivez des collections publiques pour voir leurs recos ici.\"],\"qnUJBI\":[\"Le nouvel ordre n'a pas pu être enregistré. Réessayez.\"],\"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\"],\"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\"],\"tOHgQw\":[\"Continuer en tant que @\",[\"username\"]],\"tXNIqW\":[\"Cette connexion a été commencée ailleurs. Recommencez depuis la page de connexion.\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Enregistrer\"],\"tvmuQ0\":[\"Thème de couleur\"],\"tyiiHe\":[\"Rien de neuf ce mois-ci.\"],\"u1lDX2\":[\"Récupération de l'aperçu…\"],\"u8pttd\":[\"Le changer sur \",[\"home\"]],\"uACFmj\":[\"Se connecter avec \",[\"0\"]],\"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\"],\"w61W3L\":[\"Retirer \",[\"0\"]],\"wckWOP\":[\"Administration\"],\"wixIgH\":[\"Vous avez déjà un compte ? <0>Se connecter\"],\"wrFDaD\":[\"Les utilisateurs d'ici ne peuvent pas se connecter à une instance bloquée.\"],\"wtxFp4\":[\"Fichier trop volumineux (max \",[\"max\"],\").\"],\"x6tjuK\":[\"Onglet par défaut\"],\"xEWkgZ\":[\"← Retour à toutes les recos\"],\"xGVfLh\":[\"Continuer\"],\"xPHtx0\":[\"Lancer la recherche\"],\"xVuNgt\":[\"+ Nouvelle collection\"],\"xc9O_u\":[\"Supprimer la reco\"],\"y1G_DI\":[\"Renommage impossible\"],\"y6sq5j\":[\"Abonné\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"yByRxz\":[\"Cette semaine\"],\"yQ2kGp\":[\"Charger plus\"],\"y_0uwd\":[\"Hier\"],\"yxlcv5\":[\"Ne plus recevoir le récap mensuel ?\"],\"yz7wBu\":[\"Fermer\"],\"yzF66j\":[\"Lien\"],\"z0ROB3\":[\"Retirer le j'aime\"],\"z1uNN0\":[\"Aucun emoji trouvé.\"],\"zVuxvN\":[\"Actualisation…\"],\"zwBp5t\":[\"Privé\"]}"); \ No newline at end of file diff --git a/locales/fr.po b/locales/fr.po index 3a8b39e9..049be850 100644 --- a/locales/fr.po +++ b/locales/fr.po @@ -60,7 +60,7 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" msgid "email.digest.totals" msgstr "{dumps, plural, one {# reco} other {# recos}}, {upvotes, plural, one {# vote} other {# votes}} et {comments, plural, one {# commentaire} other {# commentaires}}." -#: src/pages/Search.tsx:152 +#: src/pages/Search.tsx:178 msgid "{label} ({count})" msgstr "{label} ({count})" @@ -144,7 +144,7 @@ msgstr "{n, plural, one {vote} other {votes}}" msgid "email.digest.more" msgstr "{n} de plus dans votre fil Suivi" -#: src/pages/Notifications.tsx:508 +#: src/pages/Notifications.tsx:499 msgid "{totalUnread, plural, one {# new} other {# new}}" msgstr "{totalUnread, plural, one {# nouvelle} other {# nouvelles}}" @@ -211,8 +211,9 @@ msgstr "# Bienvenue sur {site} !\n" msgid "← Back" msgstr "← Retour" -#: src/pages/Dump.tsx:330 -#: src/pages/Dump.tsx:557 +#: src/pages/Dump.tsx:353 +#: src/pages/Dump.tsx:378 +#: src/pages/Dump.tsx:605 #: src/pages/DumpEdit.tsx:234 msgid "← Back to all dumps" msgstr "← Retour à toutes les recos" @@ -225,7 +226,7 @@ msgstr "+ Inviter quelqu'un" msgid "+ New playlist" msgstr "+ Nouvelle collection" -#: src/pages/Dump.tsx:404 +#: src/pages/Dump.tsx:452 msgid "+ Playlist" msgstr "+ Collection" @@ -418,8 +419,8 @@ msgstr "par @{username}" msgid "email.digest.playlistMeta" msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}" -#: src/contexts/ws/WSProvider.tsx:395 -#: src/contexts/ws/WSProvider.tsx:764 +#: src/contexts/ws/WSProvider.tsx:376 +#: src/contexts/ws/WSProvider.tsx:765 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." @@ -439,7 +440,7 @@ msgstr "Connexion impossible" #: src/components/profile/EmailEditor.tsx:83 #: src/components/shared/ConfirmModal.tsx:63 #: src/pages/Authorize.tsx:158 -#: src/pages/Dump.tsx:446 +#: src/pages/Dump.tsx:494 #: src/pages/DumpEdit.tsx:561 msgid "Cancel" msgstr "Annuler" @@ -554,7 +555,7 @@ msgstr "Confirmer le nouveau mot de passe" msgid "Connecting…" msgstr "Connexion…" -#: src/contexts/ws/WSProvider.tsx:757 +#: src/contexts/ws/WSProvider.tsx:758 msgid "Connection lost — this message may not have been sent" msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé" @@ -582,7 +583,7 @@ msgstr "Impossible de changer le mot de passe" 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/components/profile/ProfilePlaylistsTab.tsx:178 +#: src/components/profile/ProfilePlaylistsTab.tsx:171 #: src/pages/DumpEdit.tsx:555 msgid "Could not delete" msgstr "Suppression impossible" @@ -680,7 +681,7 @@ msgstr "Échec de la suppression ({0})" #: src/components/playlist/PlaylistCard.tsx:149 #: src/components/playlist/PlaylistEditForm.tsx:213 -#: src/components/profile/ProfilePlaylistsTab.tsx:208 +#: src/components/profile/ProfilePlaylistsTab.tsx:201 #: src/pages/PlaylistDetail.tsx:172 msgid "Delete playlist" msgstr "Supprimer la collection" @@ -697,7 +698,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible." msgid "Delete this message?" msgstr "Supprimer ce message ?" -#: src/components/profile/ProfilePlaylistsTab.tsx:207 +#: src/components/profile/ProfilePlaylistsTab.tsx:200 #: src/pages/PlaylistDetail.tsx:171 msgid "Delete this playlist? This cannot be undone." msgstr "Supprimer cette collection ? Cette action est irréversible." @@ -769,7 +770,7 @@ msgid "Dumped!" msgstr "Recommandé !" #: src/components/profile/ProfileDumpsTab.tsx:46 -#: src/pages/Search.tsx:148 +#: src/pages/Search.tsx:174 #: src/pages/UserPublicProfile.tsx:160 msgid "Dumps" msgstr "Recos" @@ -788,7 +789,7 @@ msgstr "Plus tôt" #: src/components/chat/PendingItem.tsx:93 #: src/components/comment/CommentNode.tsx:256 #: src/components/playlist/PlaylistHeader.tsx:60 -#: src/pages/Dump.tsx:553 +#: src/pages/Dump.tsx:601 msgid "Edit" msgstr "Modifier" @@ -806,7 +807,7 @@ msgid "Edit email" msgstr "Modifier l'e-mail" #: src/components/dump/create/TitleField.tsx:69 -#: src/pages/Dump.tsx:463 +#: src/pages/Dump.tsx:511 msgid "Edit title" msgstr "Modifier le titre" @@ -820,7 +821,7 @@ msgstr "modifié" #: src/components/chat/ChatMessageItem.tsx:132 #: src/components/comment/CommentNode.tsx:205 #: src/components/playlist/PlaylistHeader.tsx:95 -#: src/pages/Dump.tsx:506 +#: src/pages/Dump.tsx:554 msgid "edited {0}" msgstr "modifié {0}" @@ -830,7 +831,7 @@ msgstr "modifié {0}" #: src/components/chat/ChatMessageItem.tsx:128 #: src/components/comment/CommentNode.tsx:203 #: src/components/playlist/PlaylistHeader.tsx:93 -#: src/pages/Dump.tsx:504 +#: src/pages/Dump.tsx:552 msgid "Edited {0}" msgstr "Modifié le {0}" @@ -851,7 +852,7 @@ msgstr "E-mail" msgid "Email address" msgstr "Adresse e-mail" -#: src/pages/Search.tsx:204 +#: src/pages/Search.tsx:230 msgid "Enter a query to search." msgstr "Saisissez une recherche." @@ -942,16 +943,16 @@ msgstr "Suivre {targetUsername}" msgid "Follow playlist" msgstr "Suivre la collection" -#: src/components/feed/FollowedFeed.tsx:164 +#: src/components/feed/FollowedFeed.tsx:192 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:156 +#: src/components/feed/FollowedFeed.tsx:184 msgid "Follow some users to see their dumps here." msgstr "Suivez des utilisateurs pour voir leurs recos ici." #: src/components/feed/FeedTabBar.tsx:61 -#: src/components/profile/ProfileFollowTab.tsx:140 +#: src/components/profile/ProfileFollowTab.tsx:144 #: src/pages/Settings.tsx:57 #: src/pages/UserPublicProfile.tsx:163 msgid "Followed" @@ -971,7 +972,7 @@ msgstr "Pour vous" msgid "Forgot password?" msgstr "Mot de passe oublié ?" -#: src/components/feed/FollowedFeed.tsx:137 +#: src/components/feed/FollowedFeed.tsx:165 msgid "From people" msgstr "De personnes" @@ -980,7 +981,7 @@ msgstr "De personnes" msgid "email.digest.network" msgstr "De ceux que vous suivez" -#: src/components/feed/FollowedFeed.tsx:138 +#: src/components/feed/FollowedFeed.tsx:166 msgid "From playlists" msgstr "De collections" @@ -1024,7 +1025,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V msgid "image" msgstr "image" -#: src/pages/Dump.tsx:573 +#: src/pages/Dump.tsx:621 msgid "In collections" msgstr "Dans les collections" @@ -1109,7 +1110,7 @@ msgstr "Lien" msgid "Link or file" msgstr "Lien ou fichier" -#: src/contexts/ws/WSProvider.tsx:763 +#: src/contexts/ws/WSProvider.tsx:764 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" @@ -1125,7 +1126,7 @@ msgstr "Charger plus" msgid "Load older messages" msgstr "Charger les messages plus anciens" -#: src/pages/Dump.tsx:306 +#: src/pages/Dump.tsx:337 #: src/pages/DumpEdit.tsx:210 msgid "Loading dump…" msgstr "Chargement de la reco…" @@ -1271,7 +1272,7 @@ msgstr "Piste suivante" msgid "No dumps in this playlist yet." msgstr "Aucune reco dans cette collection pour l'instant." -#: src/pages/Search.tsx:220 +#: src/pages/Search.tsx:246 msgid "No dumps match \"{q}\"." msgstr "Aucune reco ne correspond à « {q} »." @@ -1284,7 +1285,7 @@ msgstr "Pas encore de recos. Soyez le premier !" msgid "No emoji found." msgstr "Aucun emoji trouvé." -#: src/components/profile/ProfileFollowTab.tsx:113 +#: src/components/profile/ProfileFollowTab.tsx:117 msgid "No followed playlists yet." msgstr "Pas encore de collections suivies." @@ -1300,21 +1301,21 @@ msgstr "Aucun message pour l'instant. Dites bonjour !" msgid "No one yet." msgstr "Personne pour le moment." -#: src/pages/Search.tsx:261 +#: src/pages/Search.tsx:287 msgid "No playlists match \"{q}\"." msgstr "Aucune collection ne correspond à « {q} »." #: src/components/playlist/PlaylistMembershipPanel.tsx:51 -#: src/components/profile/ProfilePlaylistsTab.tsx:185 -#: src/pages/Playlists.tsx:118 +#: src/components/profile/ProfilePlaylistsTab.tsx:178 +#: src/pages/Playlists.tsx:126 msgid "No playlists yet." msgstr "Pas encore de collections." -#: src/contexts/ws/WSProvider.tsx:932 +#: src/contexts/ws/WSProvider.tsx:934 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:235 +#: src/pages/Search.tsx:261 msgid "No users match \"{q}\"." msgstr "Aucun utilisateur ne correspond à « {q} »." @@ -1336,18 +1337,18 @@ msgstr "Rien de neuf cette semaine." #: src/components/profile/ProfileDumpsTab.tsx:97 #: src/components/profile/ProfileOverviewTab.tsx:47 -#: src/pages/Notifications.tsx:522 +#: src/pages/Notifications.tsx:513 msgid "Nothing here yet." msgstr "Rien ici pour l'instant." -#: src/components/layout/NotificationBell.tsx:57 +#: src/components/layout/NotificationBell.tsx:49 #: src/pages/Notifications.tsx:387 -#: src/pages/Notifications.tsx:505 +#: src/pages/Notifications.tsx:496 #: src/pages/Settings.tsx:163 msgid "Notifications" msgstr "Notifications" -#: src/components/layout/NotificationBell.tsx:56 +#: src/components/layout/NotificationBell.tsx:48 msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} non lues)" @@ -1469,11 +1470,11 @@ msgstr "Catégories de collections" #: src/components/dump/create/StepIndicator.tsx:21 #: src/components/layout/AppHeader.tsx:172 #: src/components/layout/UserMenu.tsx:92 -#: src/components/profile/ProfileFollowTab.tsx:144 -#: src/components/profile/ProfilePlaylistsTab.tsx:156 -#: src/pages/Playlists.tsx:52 -#: src/pages/Playlists.tsx:106 -#: src/pages/Search.tsx:151 +#: src/components/profile/ProfileFollowTab.tsx:148 +#: src/components/profile/ProfilePlaylistsTab.tsx:149 +#: src/pages/Playlists.tsx:53 +#: src/pages/Playlists.tsx:114 +#: src/pages/Search.tsx:177 #: src/pages/UserPublicProfile.tsx:162 msgid "Playlists" msgstr "Collections" @@ -1501,7 +1502,7 @@ msgstr "Piste précédente" #: src/components/playlist/PlaylistCard.tsx:106 #: src/components/playlist/PlaylistHeader.tsx:77 #: src/components/playlist/PlaylistMembershipPanel.tsx:81 -#: src/pages/Dump.tsx:512 +#: src/pages/Dump.tsx:560 msgid "private" msgstr "privé" @@ -1544,7 +1545,7 @@ msgstr "Inscription…" msgid "Registration failed" msgstr "Inscription échouée" -#: src/pages/Dump.tsx:564 +#: src/pages/Dump.tsx:612 msgid "Related" msgstr "Connexe" @@ -1631,7 +1632,7 @@ msgid "email.reset.title" msgstr "Réinitialiser votre mot de passe" #: src/components/chat/PendingItem.tsx:86 -#: src/pages/Dump.tsx:323 +#: src/pages/Dump.tsx:371 #: src/pages/DumpEdit.tsx:227 msgid "Retry" msgstr "Réessayer" @@ -1646,7 +1647,7 @@ msgstr "Rôle" #: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/profile/DescriptionEditor.tsx:89 #: src/components/profile/EmailEditor.tsx:75 -#: src/pages/Dump.tsx:438 +#: src/pages/Dump.tsx:486 #: src/pages/DumpEdit.tsx:564 msgid "Save" msgstr "Enregistrer" @@ -1656,14 +1657,14 @@ msgstr "Enregistrer" #: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/DescriptionEditor.tsx:86 #: src/components/profile/EmailEditor.tsx:72 -#: src/pages/Dump.tsx:437 +#: src/pages/Dump.tsx:485 #: src/pages/ResetPassword.tsx:191 msgid "Saving…" msgstr "Enregistrement…" #: src/components/layout/AppHeader.tsx:182 #: src/components/layout/SearchBar.tsx:83 -#: src/pages/Search.tsx:87 +#: src/pages/Search.tsx:90 msgid "Search" msgstr "Rechercher" @@ -1679,11 +1680,11 @@ msgstr "Rechercher un emoji" msgid "Search emoji…" msgstr "Rechercher un emoji…" -#: src/pages/Search.tsx:215 +#: src/pages/Search.tsx:241 msgid "Search failed" msgstr "Recherche échouée" -#: src/pages/Search.tsx:210 +#: src/pages/Search.tsx:236 msgid "Searching…" msgstr "Recherche…" @@ -1765,7 +1766,7 @@ msgstr "Quelqu'un a demandé à réinitialiser le mot de passe de votre compte { #: src/components/form/FormError.tsx:31 #: src/components/layout/PageError.tsx:29 -#: src/contexts/ws/WSProvider.tsx:732 +#: src/contexts/ws/WSProvider.tsx:733 #: src/pages/PlaylistDetail.tsx:155 msgid "Something went wrong" msgstr "Une erreur est survenue" @@ -1799,7 +1800,7 @@ msgstr "la tribune" msgid "the chat" msgstr "la tribune" -#: src/hooks/playlist/usePlaylist.ts:182 +#: src/hooks/playlist/usePlaylist.ts:177 msgid "The dump couldn't be removed. Try again." msgstr "Le dump n'a pas pu être retiré. Réessayez." @@ -1822,7 +1823,7 @@ msgstr "Le mois sur {site}" msgid "email.digest.monthly.subject" msgstr "Le mois sur {site}, {range}" -#: src/hooks/playlist/usePlaylist.ts:325 +#: src/hooks/playlist/usePlaylist.ts:317 msgid "The new order couldn't be saved. Try again." msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez." @@ -1830,6 +1831,10 @@ msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez." msgid "This comment couldn't be deleted. Try again." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." +#: src/pages/Dump.tsx:346 +msgid "This dump was deleted, or made private." +msgstr "Cette reco a été supprimée, ou rendue privée." + #: src/pages/UserRegister.tsx:122 msgid "This invite link is missing, expired, or already used." msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé." @@ -2035,8 +2040,8 @@ msgstr "Menu utilisateur" msgid "Username" msgstr "Nom d'utilisateur" -#: src/components/profile/ProfileFollowTab.tsx:143 -#: src/pages/Search.tsx:150 +#: src/components/profile/ProfileFollowTab.tsx:147 +#: src/pages/Search.tsx:176 msgid "Users" msgstr "Utilisateurs" @@ -2122,7 +2127,7 @@ msgstr "Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans msgid "You won't get the weekly digest any more. You can turn it back on in your settings." msgstr "Vous ne recevrez plus le résumé hebdomadaire. Vous pouvez le réactiver dans vos paramètres." -#: src/pages/Notifications.tsx:525 +#: src/pages/Notifications.tsx:516 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, valide vos recos ou publie du nouveau contenu." @@ -2146,7 +2151,7 @@ msgstr "Vous recevez cet e-mail car une réinitialisation de mot de passe a ét msgid "email.welcome.reason" msgstr "Vous recevez cet e-mail car un compte {site} a été créé avec cette adresse." -#: src/contexts/ws/WSProvider.tsx:913 +#: src/contexts/ws/WSProvider.tsx:915 msgid "You're offline — not sent" msgstr "Hors ligne — message non envoyé" diff --git a/shared/ws.ts b/shared/ws.ts index ed18c66c..8aeddb04 100644 --- a/shared/ws.ts +++ b/shared/ws.ts @@ -283,8 +283,10 @@ export interface DumpDeletedMessage { /** * Sent by the server when a playlist is created, to every socket if it is - * public, else to its owner's. `WSProvider` passes it on to the playlist pages - * as `lastPlaylistEvent`, as it does the three messages below. + * public, else to its owner's. It also goes to everyone but the owner when a + * private playlist goes public, since to them it is new. `WSProvider` passes + * it on to the playlist pages as a `playlist` event, as it does the three + * messages below. */ export interface PlaylistCreatedMessage { type: "playlist_created"; @@ -292,16 +294,18 @@ export interface PlaylistCreatedMessage { } /** - * Sent by the server when a playlist changes, to every socket if it is public - * or was until this change, so that a playlist gone private leaves the others' - * views. + * Sent by the server when a playlist changes, to every socket if it is public, + * else to its owner's, as when it has just gone private. */ export interface PlaylistUpdatedMessage { type: "playlist_updated"; playlist: Playlist; } -/** Sent by the server when a playlist is deleted, to those who could see it. */ +/** + * Sent by the server when a playlist is deleted, to those who could see it. It + * also goes to everyone but the owner when a public playlist goes private. + */ export interface PlaylistDeletedMessage { type: "playlist_deleted"; playlistId: string; @@ -310,7 +314,8 @@ export interface PlaylistDeletedMessage { /** * Sent by the server when dumps are added to, removed from or reordered in a - * playlist, to those who can see it. + * playlist, to those who can see it, and when one of its dumps is deleted or + * changes visibility, which changes what its viewers see of it. */ export interface PlaylistDumpsUpdatedMessage { type: "playlist_dumps_updated"; @@ -325,8 +330,10 @@ export interface PlaylistDumpsUpdatedMessage { } /** - * Sent by the server to every socket after a profile edit, a role change or an - * avatar upload. `WSProvider` refreshes the user's avatar wherever it shows. + * Sent by the server to every socket after a profile edit, a rename, a role + * change or an avatar upload. `WSProvider` refreshes the user's avatar + * wherever it shows, and passes it on as a `user` event, which updates their + * profile and their name on the lists. */ export interface UserUpdatedMessage { type: "user_updated"; @@ -334,9 +341,9 @@ export interface UserUpdatedMessage { } /** - * Sent by the server to every socket when a comment is posted on a public - * dump. Comments on a private dump are never broadcast. `WSProvider` passes it - * on to the dump's page as `lastCommentEvent`, as it does the two messages + * Sent by the server when a comment is posted, to every socket if its dump is + * public, else to the dump's poster's. `WSProvider` passes it on to the dump's + * page and the dump lists as a `comment` event, as it does the two messages * below. */ export interface CommentCreatedMessage { @@ -344,18 +351,13 @@ export interface CommentCreatedMessage { comment: Comment; } -/** - * Sent by the server to every socket when a comment on a public dump is edited. - */ +/** Sent by the server when a comment is edited, as when one is posted. */ export interface CommentUpdatedMessage { type: "comment_updated"; comment: Comment; } -/** - * Sent by the server to every socket when a comment on a public dump is - * deleted. - */ +/** Sent by the server when a comment is deleted, as when one is posted. */ export interface CommentDeletedMessage { type: "comment_deleted"; commentId: string; @@ -372,6 +374,23 @@ export interface NotificationCreatedMessage { notification: Notification; } +/** + * Sent by the notification service to every socket of a user who opened their + * notifications, which marks them all read. `WSProvider` zeroes the bell's + * count, in the other tabs and devices too. + */ +export interface NotificationsReadMessage { + type: "notifications_read"; +} + +/** + * Sent by the category service to every socket after a category is created, + * edited or deleted. `CategoriesProvider` fetches the categories again. + */ +export interface CategoriesChangedMessage { + type: "categories_changed"; +} + /** * Sent by the server to the socket whose message failed. With `dumpId` or * `commentId`, `WSProvider` undoes that optimistic vote or like at once rather @@ -478,6 +497,8 @@ export type ServerToClientMessage = | CommentUpdatedMessage | CommentDeletedMessage | NotificationCreatedMessage + | NotificationsReadMessage + | CategoriesChangedMessage | ErrorMessage | ChatMessageMessage | ChatMessageUpdatedMessage diff --git a/src/components/comment/CommentThread.tsx b/src/components/comment/CommentThread.tsx index e3a0a492..d5a4b4b5 100644 --- a/src/components/comment/CommentThread.tsx +++ b/src/components/comment/CommentThread.tsx @@ -21,7 +21,7 @@ import { CommentNode } from "./CommentNode.tsx"; /** * Passed to {@link CommentThread} by the `Dump` page, which holds the comments. * The page applies the changes this user makes through the callbacks, and the - * ones others make from `WSContext`'s `lastCommentEvent`, so a change can come + * ones others make from the socket's `comment` events, so a change can come * both ways and must be applied once. */ export interface CommentThreadProps { diff --git a/src/components/feed/FollowedFeed.tsx b/src/components/feed/FollowedFeed.tsx index 7d35154a..bdbf17ad 100644 --- a/src/components/feed/FollowedFeed.tsx +++ b/src/components/feed/FollowedFeed.tsx @@ -17,6 +17,10 @@ import type { DumpFeedState } from "./DumpFeed.tsx"; import { API_URL } from "../../config/api.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useDumpListSync } from "../../hooks/dump/useDumpListSync.ts"; +import { useFollows } from "../../hooks/social/useFollows.ts"; +import { useCategories } from "../../hooks/category/useCategories.ts"; +import { useCategoryFilter } from "../../hooks/category/useCategoryFilter.ts"; +import { useOnReconnect, useWSEvent } from "../../hooks/ws/useWS.ts"; import { useFeedCache } from "../../hooks/feed/useFeedCache.ts"; import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts"; import { usePagedList } from "../../hooks/feed/usePagedList.ts"; @@ -33,7 +37,10 @@ import { useViewerDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts"; /** * Returns one view's feed, from `GET /api/dumps?follow=`, and the call * that loads its next page. A view cached like the index's feed starts loaded - * and isn't fetched. + * and isn't fetched. The socket keeps it live: "users" gains the public dumps + * the users followed post, and "playlists" fetches its pages again when the + * dumps of a playlist followed change, since its order is when each was + * added. A reconnection fetches them again too. */ function useFollowedView( section: FollowView, @@ -48,14 +55,35 @@ function useFollowedView( `feed:followed-${section}:${categorySlug ?? "all"}`, hydrateDump, ); - const { state, setItems, loadMore } = usePagedList( + const { state, setItems, loadMore, resync } = usePagedList( user ? `${url}:${user.id}` : undefined, (page, signal) => authFetch(pageUrl(url, page), { signal }) .then((res) => readPage(res, deserializeDump)), cached, ); - useDumpListSync(setItems); + const { followedUserIds, followedPlaylistIds } = useFollows(); + const { categories } = useCategories("dump"); + // A slug the categories don't know yet lets everything through. + const categoryId = categorySlug && + categories.find((c) => c.slug === categorySlug)?.id; + useDumpListSync( + state.status === "loaded" ? state.data.items : [], + useCategoryFilter(setItems, categoryId || undefined), + section === "users" + ? { + belongs: (d) => !d.isPrivate && followedUserIds.has(d.userId), + addNew: true, + } + : undefined, + ); + useWSEvent("playlist", (ev) => { + if ( + section === "playlists" && ev.type === "dumps_updated" && + followedPlaylistIds.has(ev.playlistId) + ) resync(); + }); + useOnReconnect(resync); // Only the list is kept here, since `Index` saves and restores the page's // scroll for every tab, this one included. diff --git a/src/components/layout/NotificationBell.tsx b/src/components/layout/NotificationBell.tsx index d97ba6b3..b56d4cdf 100644 --- a/src/components/layout/NotificationBell.tsx +++ b/src/components/layout/NotificationBell.tsx @@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router"; import { t } from "@lingui/core/macro"; -import { useWS } from "../../hooks/ws/useWS.ts"; +import { useWS, useWSEvent } from "../../hooks/ws/useWS.ts"; import { IconBell } from "./NavIcons.tsx"; /** @@ -17,7 +17,7 @@ import { IconBell } from "./NavIcons.tsx"; * a new one. */ export function NotificationBell(): React.JSX.Element { - const { unreadNotificationCount, lastNotification } = useWS(); + const { unreadNotificationCount } = useWS(); const navigate = useNavigate(); const [ringing, setRinging] = useState(false); // The ring's timers outlive the effect that starts them: a notification @@ -25,15 +25,7 @@ export function NotificationBell(): React.JSX.Element { 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); - - useEffect(() => { - if (!lastNotification) return; - if (lastNotification.id === lastSeenIdRef.current) return; - lastSeenIdRef.current = lastNotification.id; - + useWSEvent("notification", () => { if (animatingRef.current) return; animatingRef.current = true; timersRef.current = [ @@ -43,7 +35,7 @@ export function NotificationBell(): React.JSX.Element { animatingRef.current = false; }, 700), ]; - }, [lastNotification]); + }); return ( + } + /> + ); + } + if (dumpState.status === "error") { return ( { - if (connectionEpoch === backfillEpochRef.current) return; - backfillEpochRef.current = connectionEpoch; - const controller = new AbortController(); - fetchPage(1, controller.signal) - .then(({ items }) => - setItems((dumps) => { - const known = new Set(dumps.map((d) => d.id)); - const newOnes = items.filter((d) => !known.has(d.id)); - return newOnes.length === 0 ? dumps : [...newOnes, ...dumps]; - }) - ) - .catch(() => {}); - return () => controller.abort(); - }, [connectionEpoch, fetchPage, setItems]); + // What changed while the socket was down never arrived: the pages loaded + // are fetched again, keeping the scroll position. + useOnReconnect(resync); const sentinelRef = useInfiniteScroll( loadMore, @@ -153,6 +139,8 @@ export function Index( () => dumpsState.status === "loaded" ? dumpsState.data.items : [], [dumpsState], ); + // Signed in, the feed holds the user's own private dumps. + useDumpListSync(dumps, setScopedItems, { isOwner: true }); // A category's feed takes only the new dumps filed under it. const categoryId = category?.id; diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index 2d53e4f0..26ffbdef 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -19,7 +19,7 @@ import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { ListState } from "../components/shared/ListState.tsx"; import { Tooltip } from "../components/shared/Tooltip.tsx"; import { FeedFooter } from "../components/feed/FeedFooter.tsx"; -import { useWS } from "../hooks/ws/useWS.ts"; +import { useWS, useWSEvent } from "../hooks/ws/useWS.ts"; import type { CommentLikedData, DumpCommentedData, @@ -386,11 +386,7 @@ function NotificationItem({ n }: { n: Notification }) { export function Notifications(): React.JSX.Element { useDocumentTitle(t`Notifications`); const { user, authFetch } = useAuth(); - const { - clearUnreadNotifications, - lastNotification, - connectionEpoch, - } = useWS(); + const { clearUnreadNotifications, connectionEpoch } = useWS(); const { cached, saveState } = useFeedCache( "feed:notifications", hydrateNotification, @@ -414,17 +410,12 @@ export function Notifications(): React.JSX.Element { ); const loaded = state.status === "loaded" ? state.data : null; - // What arrives while the page is open goes on top, newest first. Each new - // `lastNotification` is kept once, during render rather than in an effect, - // so it shows in the same frame. The list may predate it or not. + // What arrives while the page is open goes on top, newest first. The list + // may predate it or not. const [arrived, setArrived] = useState([]); - const [prevLastNotification, setPrevLastNotification] = useState( - lastNotification, - ); - if (prevLastNotification !== lastNotification && lastNotification !== null) { - setPrevLastNotification(lastNotification); - setArrived((a) => newestFirst([lastNotification], a)); - } + useWSEvent("notification", (notification) => { + setArrived((a) => newestFirst([notification], a)); + }); // What came while the page wasn't listening, on a reconnect and on a // restored list, is fetched as the first page again and arrives with the diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index 77762786..87c9a56a 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -29,6 +29,7 @@ import { useScrollSave, } from "../hooks/feed/useScrollSave.ts"; import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts"; +import { useOnReconnect } from "../hooks/ws/useWS.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; import { PageHeader } from "../components/layout/PageHeader.tsx"; import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx"; @@ -65,7 +66,8 @@ export function Playlists(): React.JSX.Element { * Renders the public playlists, from `GET /api/playlists`, a page at a time * as the list scrolls; with `categorySlug`, only those in that playlist * category, which the menu in the title row switches. The socket keeps them - * live, and going back restores the list and the scroll. + * live, a reconnection fetches the pages loaded again, and going back + * restores the list and the scroll. */ function PlaylistsList( { categorySlug, categoryId }: { categorySlug?: string; categoryId?: string }, @@ -79,7 +81,9 @@ function PlaylistsList( const url = `${API_URL}/api/playlists${ categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : "" }`; - const { state, setItems: setAllItems, loadMore } = usePagedList( + const { state, setItems: setAllItems, loadMore, resync } = usePagedList< + Playlist + >( `${url}:${user?.id ?? "guest"}`, useCallback( (page: number, signal?: AbortSignal) => @@ -90,7 +94,11 @@ function PlaylistsList( cached, ); const loaded = state.status === "loaded" ? state.data : null; - usePlaylistListSync(useCategoryFilter(setAllItems, categoryId)); + usePlaylistListSync( + loaded?.items ?? [], + useCategoryFilter(setAllItems, categoryId), + ); + useOnReconnect(resync); const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded)); useScrollSave( diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index 10619aee..89484c7e 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -42,6 +42,8 @@ import { } from "../utils/feed/pagedList.ts"; import { useFetchState } from "../hooks/shared/useFetchState.ts"; import { useLoadMore } from "../hooks/feed/usePagedList.ts"; +import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts"; +import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts"; import { SEARCH_TABS, searchPath, @@ -77,7 +79,8 @@ const TAB_CATEGORIES: Partial< * Renders `/search?q=` and `/search/:searchTab?q=`, for anyone. The results come * from `GET /api/search`, signed in when the viewer is, so they find their own * private dumps and playlists too. The dumps load a page at a time as the - * "dumps" tab scrolls. A new `q` aborts the search under way. + * "dumps" tab scrolls. A new `q` aborts the search under way. The socket keeps + * the dumps and playlists found live, as edited or deleted, but adds none. * * On the "dumps" and "playlists" tabs, a menu narrows the results to a * category of their kind, kept in the URL's `?category=`, which another tab @@ -133,6 +136,29 @@ export function Search(): React.JSX.Element { hasMore: dumps.hasMore, })), ); + useDumpListSync( + dumps?.items ?? [], + useCallback( + (fn: (items: Dump[]) => Dump[]) => + setData((d) => ({ + ...d, + dumps: { ...d.dumps, items: fn(d.dumps.items) }, + })), + [setData], + ), + // Signed in, the results hold the user's own private dumps and playlists. + { isOwner: true }, + ); + usePlaylistListSync( + state.status === "loaded" ? state.data.playlists : [], + useCallback( + (fn: (items: Playlist[]) => Playlist[]) => + setData((d) => ({ ...d, playlists: fn(d.playlists) })), + [setData], + ), + { isOwner: true, noNewEntries: true }, + ); + const sentinelRef = useInfiniteScroll( loadMore, tab === "dumps" && canLoadMore(dumps), diff --git a/src/test/components/layout/NotificationBell.test.tsx b/src/test/components/layout/NotificationBell.test.tsx index 05475c6c..d8c6fbc5 100644 --- a/src/test/components/layout/NotificationBell.test.tsx +++ b/src/test/components/layout/NotificationBell.test.tsx @@ -4,7 +4,7 @@ import { userEvent } from "@testing-library/user-event"; import { NotificationBell } from "../../../components/layout/NotificationBell.tsx"; import { makeNotification } from "../../fixtures.ts"; import { currentPath, renderWithContexts } from "../../render.tsx"; -import { defaultWSValue, WSContext } from "../../../contexts/ws/WSContext.ts"; +import { createWSEventBus } from "../../../contexts/ws/WSContext.ts"; describe("NotificationBell", () => { test("the bell shows unread notifications and goes to them", async () => { @@ -20,33 +20,28 @@ describe("NotificationBell", () => { expect(currentPath()).toBe("/notifications"); }); - test("the bell rings for a new notification, not for one already there", () => { + test("the bell rings for each new notification", () => { vi.useFakeTimers(); try { - const n1 = makeNotification({ id: "n1" }); - const withLast = (n: ReturnType) => ( - - - - ); - const { rerender } = renderWithContexts(withLast(n1)); + const events = createWSEventBus(); + renderWithContexts(, { events }); + const ring = (id: string) => + act(() => events.emit("notification", makeNotification({ id }))); const bell = () => screen.getByRole("button", { name: "Notifications" }); act(() => vi.advanceTimersByTime(10)); expect(bell()).not.toHaveClass("notification-bell--ringing"); - rerender(withLast(makeNotification({ id: "n2" }))); + ring("n2"); act(() => vi.advanceTimersByTime(10)); 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" }))); + ring("n3"); act(() => vi.advanceTimersByTime(100)); - rerender(withLast(makeNotification({ id: "n4" }))); + ring("n4"); act(() => vi.advanceTimersByTime(700)); expect(bell()).not.toHaveClass("notification-bell--ringing"); - rerender(withLast(makeNotification({ id: "n5" }))); + ring("n5"); act(() => vi.advanceTimersByTime(10)); expect(bell()).toHaveClass("notification-bell--ringing"); } finally { diff --git a/src/test/contexts/category/CategoriesProvider.test.tsx b/src/test/contexts/category/CategoriesProvider.test.tsx index f812ee05..b028499a 100644 --- a/src/test/contexts/category/CategoriesProvider.test.tsx +++ b/src/test/contexts/category/CategoriesProvider.test.tsx @@ -5,6 +5,7 @@ import { CategoriesProvider } from "../../../contexts/category/CategoriesProvide import { useCategories } from "../../../hooks/category/useCategories.ts"; import { fail, route } from "../../fetch.ts"; import { makeCategory, wire } from "../../fixtures.ts"; +import { liveWS } from "../../live.tsx"; describe("CategoriesProvider", () => { const wrapper = ({ children }: { children: ReactNode }) => ( @@ -64,4 +65,21 @@ describe("CategoriesProvider", () => { vi.useRealTimers(); } }); + + test("a category change elsewhere refetches", async () => { + let list = [makeCategory({ id: "a" })]; + route("GET /api/categories", () => wire(list)); + const ws = liveWS(); + const { result } = renderHook(() => useCategories("dump"), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + await waitFor(() => expect(result.current.categories).toHaveLength(1)); + list = [...list, makeCategory({ id: "b" })]; + ws.emit("categories", null); + await waitFor(() => expect(result.current.categories).toHaveLength(2)); + }); }); diff --git a/src/test/contexts/ws/WSProvider.test.tsx b/src/test/contexts/ws/WSProvider.test.tsx index 12cf3278..31425377 100644 --- a/src/test/contexts/ws/WSProvider.test.tsx +++ b/src/test/contexts/ws/WSProvider.test.tsx @@ -1,10 +1,16 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { act, renderHook } from "@testing-library/react"; -import { type ReactNode, useContext } from "react"; +import { type ReactNode, useContext, useState } from "react"; import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx"; import { WSProvider } from "../../../contexts/ws/WSProvider.tsx"; import { AvatarOverrideContext } from "../../../contexts/ws/AvatarOverrideContext.ts"; -import { useWS, useWSChat, useWSPresence } from "../../../hooks/ws/useWS.ts"; +import { + useWS, + useWSChat, + useWSEvent, + useWSPresence, +} from "../../../hooks/ws/useWS.ts"; +import type { WSEvents } from "../../../contexts/ws/WSContext.ts"; import { useAuth } from "../../../hooks/auth/useAuth.ts"; import { FakeWebSocket } from "../../ws.ts"; import { defaultRoutes } from "../../render.tsx"; @@ -30,6 +36,14 @@ function wrapper({ children }: { children: ReactNode }) { ); } +/** Every event of `kind` the provider emitted, oldest first. */ +function useEvents(kind: K): WSEvents[K][] { + // One array for the hook's life, which the subscription fills. + const [received] = useState(() => []); + useWSEvent(kind, (event) => received.push(event)); + return received; +} + function setup({ signedIn = true } = {}) { if (signedIn) storeSession(me); defaultRoutes({ session: signedIn ? me : null }); @@ -38,11 +52,25 @@ function setup({ signedIn = true } = {}) { ws: { ...useWS(), ...useWSChat(), ...useWSPresence() }, auth: useAuth(), avatars: useContext(AvatarOverrideContext), + events: { + vote: useEvents("vote"), + dump: useEvents("dump"), + playlist: useEvents("playlist"), + comment: useEvents("comment"), + user: useEvents("user"), + notification: useEvents("notification"), + categories: useEvents("categories"), + }, }), { wrapper }, ); const socket = FakeWebSocket.last; - return { hook, socket, ws: () => hook.result.current.ws }; + return { + hook, + socket, + ws: () => hook.result.current.ws, + events: () => hook.result.current.events, + }; } beforeEach(() => @@ -227,7 +255,7 @@ describe("votes", () => { }); test("votes_update from another tab of mine syncs myVotes", () => { - const { socket, ws } = setup(); + const { socket, ws, events } = setup(); socket.welcome(); socket.receive({ type: "votes_update", @@ -237,7 +265,7 @@ describe("votes", () => { action: "cast", }); expect(ws().myVotes.has("d9")).toBe(true); - expect(ws().lastVoteEvent).toEqual({ + expect(events().vote.at(-1)).toEqual({ dumpId: "d9", voterId: "user-me", action: "cast", @@ -397,7 +425,7 @@ describe("dumps, playlists, comments, users", () => { }); test("an update refreshes a live entry but doesn't make it an arrival", () => { - const { socket, ws } = setup(); + const { socket, ws, events } = setup(); socket.welcome(); socket.receive({ type: "dump_created", @@ -415,7 +443,11 @@ describe("dumps, playlists, comments, users", () => { "a", "two", ]]); - expect(ws().lastDumpEvent?.id).toBe("old"); + expect(events().dump.map((e) => [e.type, e.dump.id])).toEqual([ + ["created", "a"], + ["updated", "a"], + ["updated", "old"], + ]); }); test("deletion removes from the live buffer", () => { @@ -435,23 +467,23 @@ describe("dumps, playlists, comments, users", () => { }); test("playlist events", () => { - const { socket, ws } = setup(); + const { socket, ws, events } = setup(); socket.welcome(); const p = makePlaylist({ id: "p" }); socket.receive({ type: "playlist_created", playlist: wire(p) }); - expect(ws().lastPlaylistEvent).toMatchObject({ + expect(events().playlist.at(-1)).toMatchObject({ type: "created", playlistId: "p", }); socket.receive({ type: "playlist_updated", playlist: wire(p) }); - expect(ws().lastPlaylistEvent?.type).toBe("updated"); + expect(events().playlist.at(-1)?.type).toBe("updated"); socket.receive({ type: "playlist_dumps_updated", playlistId: "p", dumpIds: ["x"], dumpCount: 2, }); - expect(ws().lastPlaylistEvent).toEqual({ + expect(events().playlist.at(-1)).toEqual({ type: "dumps_updated", playlistId: "p", dumpIds: ["x"], @@ -459,27 +491,50 @@ describe("dumps, playlists, comments, users", () => { }); socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" }); expect(ws().deletedPlaylistIds.has("p")).toBe(true); - expect(ws().lastPlaylistEvent).toEqual({ + expect(events().playlist.at(-1)).toEqual({ type: "deleted", playlistId: "p", userId: "u", }); + // Going public again, after going private, is an arrival. + socket.receive({ type: "playlist_created", playlist: wire(p) }); + expect(ws().deletedPlaylistIds.has("p")).toBe(false); + }); + + test("events arriving together all reach their subscribers", () => { + const { socket, events } = setup(); + socket.welcome(); + // Two messages in one task, as from one network read: a single state + // slot would keep only the last until the next render. + act(() => { + for (const id of ["n1", "n2"]) { + socket.onmessage?.( + new MessageEvent("message", { + data: JSON.stringify({ + type: "notification_created", + notification: wire(makeNotification({ id })), + }), + }), + ); + } + }); + expect(events().notification.map((n) => n.id)).toEqual(["n1", "n2"]); }); test("comment events", () => { - const { socket, ws } = setup(); + const { socket, events } = setup(); socket.welcome(); const c = makeComment({ id: "c", dumpId: "d" }); socket.receive({ type: "comment_created", comment: wire(c) }); - expect(ws().lastCommentEvent).toMatchObject({ + expect(events().comment.at(-1)).toMatchObject({ type: "created", dumpId: "d", }); - expect(ws().lastCommentEvent?.comment?.createdAt).toBeInstanceOf(Date); + expect(events().comment.at(-1)?.comment?.createdAt).toBeInstanceOf(Date); socket.receive({ type: "comment_updated", comment: wire(c) }); - expect(ws().lastCommentEvent?.type).toBe("updated"); + expect(events().comment.at(-1)?.type).toBe("updated"); socket.receive({ type: "comment_deleted", commentId: "c", dumpId: "d" }); - expect(ws().lastCommentEvent).toEqual({ + expect(events().comment.at(-1)).toEqual({ type: "deleted", dumpId: "d", commentId: "c", @@ -487,7 +542,7 @@ describe("dumps, playlists, comments, users", () => { }); test("user_updated overrides that user's avatar everywhere", () => { - const { socket, hook } = setup(); + const { socket, hook, events } = setup(); socket.welcome(); const u = makeUser({ id: "u1", @@ -499,7 +554,7 @@ describe("dumps, playlists, comments, users", () => { hasAvatar: true, version: 5000, }); - expect(hook.result.current.ws.lastUserEvent?.user.id).toBe("u1"); + expect(events().user.at(-1)?.user.id).toBe("u1"); }); test("presence updates", () => { @@ -514,18 +569,32 @@ describe("dumps, playlists, comments, users", () => { }); describe("notifications", () => { - test("a new one bumps the count and is remembered", () => { - const { socket, ws } = setup(); + test("a new one bumps the count and is passed on", () => { + const { socket, ws, events } = setup(); socket.welcome({ unreadNotificationCount: 1 }); socket.receive({ type: "notification_created", notification: wire(makeNotification({ id: "n" })), }); expect(ws().unreadNotificationCount).toBe(2); - expect(ws().lastNotification?.id).toBe("n"); + expect(events().notification.map((n) => n.id)).toEqual(["n"]); act(() => ws().clearUnreadNotifications()); expect(ws().unreadNotificationCount).toBe(0); }); + + test("reading them in another tab clears the count here", () => { + const { socket, ws } = setup(); + socket.welcome({ unreadNotificationCount: 3 }); + socket.receive({ type: "notifications_read" }); + expect(ws().unreadNotificationCount).toBe(0); + }); + + test("a category change is passed on", () => { + const { socket, events } = setup(); + socket.welcome(); + socket.receive({ type: "categories_changed" }); + expect(events().categories).toEqual([null]); + }); }); describe("chat", () => { diff --git a/src/test/hooks/dump/useDumpListSync.test.tsx b/src/test/hooks/dump/useDumpListSync.test.tsx index bf3abe54..e7fe6cf5 100644 --- a/src/test/hooks/dump/useDumpListSync.test.tsx +++ b/src/test/hooks/dump/useDumpListSync.test.tsx @@ -1,93 +1,144 @@ import { describe, expect, test } from "vitest"; import { act, renderHook } from "@testing-library/react"; -import { type ReactNode, useState } from "react"; -import { - defaultWSValue, - WSContext, - type WSContextValue, -} from "../../../contexts/ws/WSContext.ts"; +import { useState } from "react"; import type { Dump } from "../../../../shared/dump.ts"; -import { useDumpListSync } from "../../../hooks/dump/useDumpListSync.ts"; -import { makeDump } from "../../fixtures.ts"; - -/** A WS context whose value a test can push new events into. */ -function liveWS() { - let push: (patch: Partial) => void = () => {}; - const base = defaultWSValue; - function Wrapper({ children }: { children: ReactNode }) { - const [value, setValue] = useState(base); - push = (patch) => setValue((v) => ({ ...v, ...patch })); - return {children}; - } - return { - Wrapper, - emit: (patch: Partial) => act(() => push(patch)), - }; -} - -function useList(initial: T[]) { - const [items, setItems] = useState(initial); - return { items, setItems }; -} +import { + type DumpListSyncOptions, + useDumpListSync, +} from "../../../hooks/dump/useDumpListSync.ts"; +import { makeDump, makeUser } from "../../fixtures.ts"; +import { liveWS } from "../../live.tsx"; const ids = (xs: { id: string }[]) => xs.map((x) => x.id); +const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)); describe("useDumpListSync", () => { - function setup( - initial: Dump[], - opts?: Parameters[1], - ) { + function setup(initial: Dump[], opts?: DumpListSyncOptions) { const ws = liveWS(); const hook = renderHook(() => { - const list = useList(initial); - useDumpListSync(list.setItems, opts); - return list; + const [items, setItems] = useState(initial); + useDumpListSync(items, setItems, opts); + return { items, setItems }; }, { wrapper: ws.Wrapper }); - return { ...ws, hook }; + const items = () => hook.result.current.items; + const update = (dump: Dump) => ws.emit("dump", { type: "updated", dump }); + const create = (dump: Dump) => ws.emit("dump", { type: "created", dump }); + return { ...ws, hook, items, update, create }; } test("drops deleted dumps", () => { - const { hook, emit } = setup([ + const { set, items } = setup([ makeDump({ id: "a" }), makeDump({ id: "b" }), ]); - emit({ deletedDumpIds: new Set(["a"]) }); - expect(ids(hook.result.current.items)).toEqual(["b"]); + set({ deletedDumpIds: new Set(["a"]) }); + expect(ids(items())).toEqual(["b"]); }); test("updates a dump in place", () => { - const { hook, emit } = setup([makeDump({ id: "a", title: "old" })]); - emit({ lastDumpEvent: makeDump({ id: "a", title: "new" }) }); - expect(hook.result.current.items[0].title).toBe("new"); + const { update, items } = setup([makeDump({ id: "a", title: "old" })]); + update(makeDump({ id: "a", title: "new" })); + expect(items()[0].title).toBe("new"); }); test("a dump gone private leaves others' lists but not the owner's", () => { const a = makeDump({ id: "a" }); const viewer = setup([a]); - viewer.emit({ lastDumpEvent: { ...a, isPrivate: true } }); - expect(viewer.hook.result.current.items).toEqual([]); + viewer.update({ ...a, isPrivate: true }); + expect(viewer.items()).toEqual([]); const owner = setup([a], { isOwner: true }); - owner.emit({ lastDumpEvent: { ...a, isPrivate: true } }); - expect(owner.hook.result.current.items[0].isPrivate).toBe(true); + owner.update({ ...a, isPrivate: true }); + expect(owner.items()[0].isPrivate).toBe(true); }); - 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("without `belongs`, never adds a dump the list doesn't hold", () => { + const { create, items } = setup([]); + create(makeDump({ id: "m", userId: "me" })); + expect(items()).toEqual([]); + }); + + test("every event of a burst applies, however many before a render", () => { + const { events, items } = setup([ + makeDump({ id: "a", commentCount: 0 }), + makeDump({ id: "b", commentCount: 0 }), + ]); + act(() => { + events.emit("comment", { type: "created", dumpId: "a" }); + events.emit("comment", { type: "created", dumpId: "b" }); + events.emit("comment", { type: "created", dumpId: "a" }); + }); + expect(items().map((d) => d.commentCount)).toEqual([2, 1]); + }); + + test("a dump that left comes back where it was, if it belongs", () => { + const initial = ["a", "b", "c"].map((id) => makeDump({ id })); + const { set, create, items } = setup(initial, { + belongs: (d) => !d.isPrivate, + }); + // Gone private for others, as a deletion. + set({ deletedDumpIds: new Set(["b"]) }); + expect(ids(items())).toEqual(["a", "c"]); + set({ deletedDumpIds: new Set() }); + create(initial[1]); + expect(ids(items())).toEqual(["a", "b", "c"]); + }); + + test("a position clamps to the list's end, and is used once", () => { + const initial = ["a", "b", "c"].map((id) => makeDump({ id })); + const { hook, update, items } = setup(initial, { belongs: () => true }); + act(() => hook.result.current.setItems((prev) => prev.slice(0, 2))); + act(() => hook.result.current.setItems((prev) => prev.slice(0, 1))); + update(initial[2]); + expect(ids(items())).toEqual(["a", "c"]); + // Back in the list, it is edited in place, not put back again. + update({ ...initial[2], title: "again" }); + expect(ids(items())).toEqual(["a", "c"]); + expect(items()[1].title).toBe("again"); + }); + + test("with `addNew`, a new dump goes where its date puts it", () => { + const { create, items } = setup( + [ + makeDump({ id: "b", createdAt: at(20) }), + makeDump({ id: "d", createdAt: at(10) }), + ], + { belongs: (d) => d.userId === "me", addNew: true }, + ); + create(makeDump({ id: "a", userId: "me", createdAt: at(30) })); + create(makeDump({ id: "c", userId: "me", createdAt: at(15) })); + // Older than everything loaded: a later page has it. + create(makeDump({ id: "e", userId: "me", createdAt: at(5) })); + // Someone else's. + create(makeDump({ id: "x", userId: "them", createdAt: at(40) })); + expect(ids(items())).toEqual(["a", "b", "c", "d"]); + }); + + test("with `addNew`, an empty list takes the first dump", () => { + const { create, items } = setup([], { belongs: () => true, addNew: true }); + create(makeDump({ id: "a" })); + expect(ids(items())).toEqual(["a"]); }); test("comment events move the count, never below zero; edits don't", () => { - const { hook, emit } = setup([makeDump({ id: "a", commentCount: 0 })]); - emit({ lastCommentEvent: { type: "created", dumpId: "a" } }); - expect(hook.result.current.items[0].commentCount).toBe(1); - emit({ lastCommentEvent: { type: "updated", dumpId: "a" } }); - expect(hook.result.current.items[0].commentCount).toBe(1); - emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } }); - emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } }); - expect(hook.result.current.items[0].commentCount).toBe(0); - emit({ lastCommentEvent: { type: "created", dumpId: "zzz" } }); - expect(hook.result.current.items).toHaveLength(1); + const { emit, items } = setup([makeDump({ id: "a", commentCount: 0 })]); + emit("comment", { type: "created", dumpId: "a" }); + expect(items()[0].commentCount).toBe(1); + emit("comment", { type: "updated", dumpId: "a" }); + expect(items()[0].commentCount).toBe(1); + emit("comment", { type: "deleted", dumpId: "a" }); + emit("comment", { type: "deleted", dumpId: "a" }); + expect(items()[0].commentCount).toBe(0); + emit("comment", { type: "created", dumpId: "zzz" }); + expect(items()).toHaveLength(1); + }); + + test("a renamed user's dumps take the new name", () => { + const { emit, items } = setup([ + makeDump({ id: "a", userId: "u1", ownerUsername: "old" }), + makeDump({ id: "b", userId: "u2", ownerUsername: "other" }), + ]); + emit("user", { user: makeUser({ id: "u1", username: "new" }) }); + expect(items().map((d) => d.ownerUsername)).toEqual(["new", "other"]); }); }); diff --git a/src/test/hooks/feed/usePagedList.test.tsx b/src/test/hooks/feed/usePagedList.test.tsx index e0703e16..03b88f23 100644 --- a/src/test/hooks/feed/usePagedList.test.tsx +++ b/src/test/hooks/feed/usePagedList.test.tsx @@ -3,10 +3,14 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { usePagedList } from "../../../hooks/feed/usePagedList.ts"; import { deferred } from "../../fetch.ts"; +/** Items with the ids the list needs, `item(1)` for `{ id: "1" }`. */ +const item = (n: number) => ({ id: String(n) }); +const items = (...ns: number[]) => ns.map(item); + describe("usePagedList", () => { test("loads the first page, then appends the next ones", async () => { const fetchPage = vi.fn((page: number) => - Promise.resolve({ items: [page], hasMore: page < 2 }) + Promise.resolve({ items: items(page), hasMore: page < 2 }) ); const { result } = renderHook(() => usePagedList("k", fetchPage)); await waitFor(() => expect(result.current.state.status).toBe("loaded")); @@ -14,7 +18,12 @@ describe("usePagedList", () => { await waitFor(() => expect(result.current.state).toEqual({ status: "loaded", - data: { items: [1, 2], hasMore: false, page: 2, loadingMore: false }, + data: { + items: items(1, 2), + hasMore: false, + page: 2, + loadingMore: false, + }, }) ); act(() => result.current.loadMore()); @@ -22,9 +31,11 @@ describe("usePagedList", () => { }); test("loads one next page at a time, and a failed one can be tried again", async () => { - const next = deferred<{ items: number[]; hasMore: boolean }>(); + const next = deferred<{ items: { id: string }[]; hasMore: boolean }>(); const fetchPage = vi.fn((page: number) => - page === 1 ? Promise.resolve({ items: [1], hasMore: true }) : next.promise + page === 1 + ? Promise.resolve({ items: items(1), hasMore: true }) + : next.promise ); const { result } = renderHook(() => usePagedList("k", fetchPage)); await waitFor(() => expect(result.current.state.status).toBe("loaded")); @@ -36,29 +47,62 @@ describe("usePagedList", () => { await next.promise.catch(() => {}); }); expect(result.current.state).toMatchObject({ - data: { items: [1], page: 1, loadingMore: false }, + data: { items: items(1), page: 1, loadingMore: false }, }); }); test("a cached list shows as it was, and carries on from its page", async () => { const fetchPage = vi.fn((page: number) => - Promise.resolve({ items: [page], hasMore: false }) + Promise.resolve({ items: items(page), hasMore: false }) ); const { result } = renderHook(() => usePagedList("k", fetchPage, { - items: [1, 2], + items: items(1, 2), page: 2, hasMore: true, scrollY: 0, }) ); - expect(result.current.state).toMatchObject({ data: { items: [1, 2] } }); + expect(result.current.state).toMatchObject({ + data: { items: items(1, 2) }, + }); act(() => result.current.loadMore()); await waitFor(() => expect(result.current.state).toMatchObject({ - data: { items: [1, 2, 3] }, + data: { items: items(1, 2, 3) }, }) ); expect(fetchPage).toHaveBeenCalledTimes(1); }); + + test("resync fetches every page loaded again, in the list's place", async () => { + let version = 0; + const fetchPage = vi.fn((page: number) => + Promise.resolve({ + // After the first load, an item arrives on top and pushes the others + // down a slot, across the pages: 2 shows on both. + items: version === 0 + ? items(page * 2 - 1, page * 2) + : page === 1 + ? items(0, 1) + : items(2, 3), + hasMore: true, + }) + ); + const { result } = renderHook(() => usePagedList("k", fetchPage)); + await waitFor(() => expect(result.current.state.status).toBe("loaded")); + act(() => result.current.loadMore()); + await waitFor(() => + expect(result.current.state).toMatchObject({ data: { page: 2 } }) + ); + version = 1; + fetchPage.mockClear(); + act(() => result.current.resync()); + await waitFor(() => + expect(result.current.state).toMatchObject({ + data: { items: items(0, 1, 2, 3), page: 2, hasMore: true }, + }) + ); + expect(fetchPage.mock.calls.map(([page]) => page)).toEqual([1, 2]); + }); }); diff --git a/src/test/hooks/feed/usePositionAwareSync.test.tsx b/src/test/hooks/feed/usePositionAwareSync.test.tsx deleted file mode 100644 index 6c207056..00000000 --- a/src/test/hooks/feed/usePositionAwareSync.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { act, renderHook } from "@testing-library/react"; -import { type ReactNode, useState } from "react"; -import { - defaultWSValue, - WSContext, - type WSContextValue, -} from "../../../contexts/ws/WSContext.ts"; -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. */ -function liveWS() { - let push: (patch: Partial) => void = () => {}; - const base = defaultWSValue; - function Wrapper({ children }: { children: ReactNode }) { - const [value, setValue] = useState(base); - push = (patch) => setValue((v) => ({ ...v, ...patch })); - return {children}; - } - return { - Wrapper, - emit: (patch: Partial) => act(() => push(patch)), - }; -} - -function useList(initial: T[]) { - const [items, setItems] = useState(initial); - return { items, setItems }; -} - -const ids = (xs: { id: string }[]) => xs.map((x) => x.id); - -describe("usePositionAwareSync", () => { - test("a dump that goes private and back returns to its old place", () => { - const ws = liveWS(); - const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" })); - // Wire the pair exactly as UserPublicProfile does. - const both = renderHook(() => { - const { items, setItems } = useList(initial); - const [event, setEvent] = useState(null); - usePositionAwareSync(items, setItems, event, (d) => !d.isPrivate); - useDumpListSync(setItems); - return { items, setEvent }; - }, { wrapper: ws.Wrapper }); - - const b = initial[1]; - act(() => both.result.current.setEvent({ ...b, isPrivate: true })); - ws.emit({ lastDumpEvent: { ...b, isPrivate: true } }); - expect(ids(both.result.current.items)).toEqual(["a", "c"]); - - act(() => both.result.current.setEvent({ ...b, isPrivate: false })); - ws.emit({ lastDumpEvent: { ...b, isPrivate: false } }); - 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); - return { items, setItems, setEvent }; - }); - const c = initial[2]; - // 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"]); - // 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", "c"]); - }); -}); diff --git a/src/test/hooks/playlist/usePlaylistListSync.test.tsx b/src/test/hooks/playlist/usePlaylistListSync.test.tsx index 575a6ffb..8712af49 100644 --- a/src/test/hooks/playlist/usePlaylistListSync.test.tsx +++ b/src/test/hooks/playlist/usePlaylistListSync.test.tsx @@ -1,155 +1,120 @@ import { describe, expect, test } from "vitest"; -import { act, renderHook } from "@testing-library/react"; -import { type ReactNode, useState } from "react"; -import { - defaultWSValue, - WSContext, - type WSContextValue, -} from "../../../contexts/ws/WSContext.ts"; +import { renderHook } from "@testing-library/react"; +import { useState } from "react"; import type { Playlist } from "../../../../shared/playlist.ts"; -import { usePlaylistListSync } from "../../../hooks/playlist/usePlaylistListSync.ts"; -import { makePlaylist } from "../../fixtures.ts"; - -/** A WS context whose value a test can push new events into. */ -function liveWS() { - let push: (patch: Partial) => void = () => {}; - const base = defaultWSValue; - function Wrapper({ children }: { children: ReactNode }) { - const [value, setValue] = useState(base); - push = (patch) => setValue((v) => ({ ...v, ...patch })); - return {children}; - } - return { - Wrapper, - emit: (patch: Partial) => act(() => push(patch)), - }; -} - -function useList(initial: T[]) { - const [items, setItems] = useState(initial); - return { items, setItems }; -} +import { + type PlaylistListSyncOptions, + usePlaylistListSync, +} from "../../../hooks/playlist/usePlaylistListSync.ts"; +import { makePlaylist, makeUser } from "../../fixtures.ts"; +import { liveWS } from "../../live.tsx"; const ids = (xs: { id: string }[]) => xs.map((x) => x.id); describe("usePlaylistListSync", () => { - function setup( - initial: Playlist[], - opts?: Parameters[1], - ) { + function setup(initial: Playlist[], opts?: PlaylistListSyncOptions) { const ws = liveWS(); const hook = renderHook(() => { - const list = useList(initial); - usePlaylistListSync(list.setItems, opts); - return list; + const [items, setItems] = useState(initial); + usePlaylistListSync(items, setItems, opts); + return items; }, { wrapper: ws.Wrapper }); - return { ...ws, hook }; + const items = () => hook.result.current; + const send = (type: "created" | "updated", playlist: Playlist) => + ws.emit("playlist", { type, playlistId: playlist.id, playlist }); + return { ...ws, items, send }; } test("prepends a created playlist once", () => { - const { hook, emit } = setup([makePlaylist({ id: "a" })]); + const { send, items } = setup([makePlaylist({ id: "a" })]); const p = makePlaylist({ id: "b" }); - emit({ - lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p }, - }); - emit({ - lastPlaylistEvent: { - type: "created", - playlistId: "b", - playlist: { ...p }, - }, - }); - expect(ids(hook.result.current.items)).toEqual(["b", "a"]); + send("created", p); + send("created", { ...p }); + expect(ids(items())).toEqual(["b", "a"]); }); test("created: filtered by owner, privacy and noNewEntries", () => { const p = makePlaylist({ id: "p", userId: "them" }); const byOwner = setup([], { ownerId: "me" }); - byOwner.emit({ - lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p }, - }); - expect(byOwner.hook.result.current.items).toEqual([]); + byOwner.send("created", p); + expect(byOwner.items()).toEqual([]); const priv = setup([]); - priv.emit({ - lastPlaylistEvent: { - type: "created", - playlistId: "p", - playlist: { ...p, isPublic: false }, - }, - }); - expect(priv.hook.result.current.items).toEqual([]); + priv.send("created", { ...p, isPublic: false }); + expect(priv.items()).toEqual([]); const followed = setup([], { noNewEntries: true }); - followed.emit({ - lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p }, - }); - expect(followed.hook.result.current.items).toEqual([]); + followed.send("created", p); + expect(followed.items()).toEqual([]); }); - test("updated: in place, removed when private, never re-added by itself", () => { + test("updated: in place, removed when private, never added by itself", () => { const p = makePlaylist({ id: "p", title: "one" }); - const { hook, emit } = setup([p]); - emit({ - lastPlaylistEvent: { - type: "updated", - playlistId: "p", - playlist: { ...p, title: "two" }, - }, - }); - expect(hook.result.current.items[0].title).toBe("two"); - emit({ - lastPlaylistEvent: { - type: "updated", - playlistId: "p", - playlist: { ...p, isPublic: false }, - }, - }); - expect(hook.result.current.items).toEqual([]); - emit({ - lastPlaylistEvent: { - type: "updated", - playlistId: "p", - playlist: { ...p, title: "back" }, - }, - }); - // usePositionAwareSync puts it back, where it was. - expect(hook.result.current.items).toEqual([]); + const other = makePlaylist({ id: "q" }); + const { send, items } = setup([p]); + send("updated", { ...p, title: "two" }); + expect(items()[0].title).toBe("two"); + send("updated", { ...p, isPublic: false }); + expect(items()).toEqual([]); + send("updated", other); + expect(items()).toEqual([]); + }); + + test("one that left comes back where it was, even to a followed list", () => { + const initial = ["a", "b", "c"].map((id) => makePlaylist({ id })); + const { set, send, items } = setup(initial, { noNewEntries: true }); + // Gone private for others, as a deletion. + set({ deletedPlaylistIds: new Set(["b"]) }); + expect(ids(items())).toEqual(["a", "c"]); + set({ deletedPlaylistIds: new Set() }); + send("created", initial[1]); + expect(ids(items())).toEqual(["a", "b", "c"]); + }); + + test("its owner's list keeps it private, in place", () => { + const p = makePlaylist({ id: "p", userId: "me" }); + const { send, items } = setup([p], { isOwner: true, ownerId: "me" }); + send("updated", { ...p, isPublic: false }); + expect(items()[0].isPublic).toBe(false); }); test("deleted and deletedPlaylistIds both remove", () => { - const { hook, emit } = setup([ + const { emit, set, items } = setup([ makePlaylist({ id: "a" }), makePlaylist({ id: "b" }), makePlaylist({ id: "c" }), ]); - emit({ lastPlaylistEvent: { type: "deleted", playlistId: "a" } }); - emit({ deletedPlaylistIds: new Set(["b"]) }); - expect(ids(hook.result.current.items)).toEqual(["c"]); + emit("playlist", { type: "deleted", playlistId: "a" }); + set({ deletedPlaylistIds: new Set(["b"]) }); + expect(ids(items())).toEqual(["c"]); }); test("dumps_updated sets the count", () => { - const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]); - emit({ - lastPlaylistEvent: { - type: "dumps_updated", - playlistId: "a", - dumpIds: ["x", "y", "z"], - }, + const { emit, items } = setup([makePlaylist({ id: "a", dumpCount: 1 })]); + emit("playlist", { + type: "dumps_updated", + playlistId: "a", + dumpIds: ["x", "y", "z"], }); - expect(hook.result.current.items[0].dumpCount).toBe(3); + expect(items()[0].dumpCount).toBe(3); }); test("dumps_updated counts the members this viewer can't see too", () => { - const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]); - emit({ - lastPlaylistEvent: { - type: "dumps_updated", - playlistId: "a", - dumpIds: ["x", "y"], - dumpCount: 4, - }, + const { emit, items } = setup([makePlaylist({ id: "a", dumpCount: 3 })]); + emit("playlist", { + type: "dumps_updated", + playlistId: "a", + dumpIds: ["x", "y"], + dumpCount: 4, }); - expect(hook.result.current.items[0].dumpCount).toBe(4); + expect(items()[0].dumpCount).toBe(4); + }); + + test("a renamed owner's playlists take the new name", () => { + const { emit, items } = setup([ + makePlaylist({ id: "a", userId: "u1", ownerUsername: "old" }), + ]); + emit("user", { user: makeUser({ id: "u1", username: "new" }) }); + expect(items()[0].ownerUsername).toBe("new"); }); }); diff --git a/src/test/hooks/shared/useFetchState.test.tsx b/src/test/hooks/shared/useFetchState.test.tsx index 1cb34136..f6f8cde1 100644 --- a/src/test/hooks/shared/useFetchState.test.tsx +++ b/src/test/hooks/shared/useFetchState.test.tsx @@ -93,4 +93,25 @@ describe("useFetchState", () => { expect(result.current.state).toEqual({ status: "loaded", data: 2 }) ); }); + + test("reload replaces the data, and keeps it when the load fails", async () => { + let n = 0; + const load = vi.fn(() => + ++n === 3 ? Promise.reject(new Error("offline")) : Promise.resolve(n) + ); + const { result } = renderHook(() => useFetchState("a", load)); + await waitFor(() => + expect(result.current.state).toEqual({ status: "loaded", data: 1 }) + ); + act(() => result.current.reload()); + await waitFor(() => + expect(result.current.state).toEqual({ status: "loaded", data: 2 }) + ); + await act(async () => { + result.current.reload(); + await Promise.resolve(); + }); + expect(load).toHaveBeenCalledTimes(3); + expect(result.current.state).toEqual({ status: "loaded", data: 2 }); + }); }); diff --git a/src/test/live.tsx b/src/test/live.tsx new file mode 100644 index 00000000..1d29bd66 --- /dev/null +++ b/src/test/live.tsx @@ -0,0 +1,38 @@ +import { act } from "@testing-library/react"; +import { type ReactNode, useState } from "react"; +import { + createWSEventBus, + defaultWSValue, + WSContext, + type WSContextValue, + type WSEvents, + WSEventsContext, +} from "../contexts/ws/WSContext.ts"; + +/** + * Stands in for `WSProvider` around a hook: `set` changes the live state, as + * a message does, and `emit` sends an event to the hook's subscriptions, or + * `events` several within one `act`. + */ +export function liveWS() { + let push: (patch: Partial) => void = () => {}; + const events = createWSEventBus(); + function Wrapper({ children }: { children: ReactNode }) { + const [value, setValue] = useState(defaultWSValue); + push = (patch) => setValue((v) => ({ ...v, ...patch })); + return ( + + + {children} + + + ); + } + return { + Wrapper, + events, + set: (patch: Partial) => act(() => push(patch)), + emit: (kind: K, event: WSEvents[K]) => + act(() => events.emit(kind, event)), + }; +} diff --git a/src/test/pages/Dump.test.tsx b/src/test/pages/Dump.test.tsx index d29b008e..92c6294f 100644 --- a/src/test/pages/Dump.test.tsx +++ b/src/test/pages/Dump.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import { screen, waitFor, within } from "@testing-library/react"; +import { act, screen, waitFor, within } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { Route } from "react-router"; import { Dump } from "../../pages/Dump.tsx"; @@ -237,6 +237,69 @@ describe("Dump page", () => { .toBeInTheDocument(); }); + test("a dump deleted, or made private, while on screen gives way to a notice", async () => { + dumpRoutes(); + const { socket } = openDump(other); + await screen.findByRole("heading", { name: "A find" }); + socket.receive({ type: "dump_deleted", dumpId: "d1" }); + expect(screen.getByText("This dump was deleted, or made private.")) + .toBeInTheDocument(); + // Made public again by its poster. + socket.receive({ + type: "dump_created", + dump: wire(makeDump({ id: "d1", slug: "a-find", title: "Back" })), + }); + expect(screen.getByRole("heading", { name: "Back" })).toBeInTheDocument(); + }); + + test("a reconnect fetches the dump again", async () => { + const d = dumpRoutes(); + route("GET /api/dumps/:id", () => wire({ ...d, title: "Edited offline" })); + const { socket } = openDump(other); + await screen.findByRole("heading", { name: "A find" }); + socket.drop(); + await act(() => new Promise((r) => setTimeout(r, 600))); + FakeWebSocket.last.welcome(); + expect(await screen.findByRole("heading", { name: "Edited offline" })) + .toBeInTheDocument(); + }); + + test("a reconnect finds a dump deleted meanwhile", async () => { + dumpRoutes(); + route("GET /api/dumps/:id", () => fail("Dump not found", 404)); + const { socket } = openDump(other); + await screen.findByRole("heading", { name: "A find" }); + socket.drop(); + await act(() => new Promise((r) => setTimeout(r, 600))); + FakeWebSocket.last.welcome(); + expect( + await screen.findByText("This dump was deleted, or made private."), + ).toBeInTheDocument(); + }); + + test("a renamed commenter's name follows", async () => { + dumpRoutes(); + route( + "GET /api/dumps/:id/comments", + () => + wire([ + makeComment({ + id: "c1", + dumpId: "d1", + userId: "u9", + authorUsername: "oldname", + }), + ]), + ); + const { socket } = openDump(other); + await screen.findByText("oldname"); + socket.receive({ + type: "user_updated", + user: wire(makeUser({ id: "u9", username: "newname" })), + }); + expect(screen.getByText("newname")).toBeInTheDocument(); + }); + test("adding to a playlist", async () => { dumpRoutes(); route("GET /api/me/playlists", () => [ diff --git a/src/test/pages/Index.test.tsx b/src/test/pages/Index.test.tsx index bb4ee780..f55e737c 100644 --- a/src/test/pages/Index.test.tsx +++ b/src/test/pages/Index.test.tsx @@ -248,6 +248,22 @@ describe("Index feeds", () => { expect(screen.getByText("Another song")).toBeInTheDocument(); }); + test("a dump edited out of the category leaves its feed", async () => { + const song = makeDump({ + id: "s", + title: "A song", + categoryIds: ["c-music"], + }); + route("GET /api/dumps", () => page([song])); + const { socket } = setup("/music/new"); + await screen.findByText("A song"); + socket.receive({ + type: "dump_updated", + dump: wire({ ...song, categoryIds: [] }), + }); + expect(screen.queryByText("A song")).not.toBeInTheDocument(); + }); + test("an unknown category is a 404", async () => { route("GET /api/dumps", () => fail("nope", 404)); setup("/nonsense/new"); @@ -306,6 +322,74 @@ describe("Followed feed", () => { expect(currentPath()).toBe("/follow"); }); + test("a followed user's new dump arrives live, others' don't", async () => { + route("GET /api/me/follow", () => ({ + followedUserIds: ["friend"], + followedPlaylistIds: [], + })); + route( + "GET /api/dumps", + (req) => + page( + req.url.searchParams.get("follow") === "users" + ? [ + makeDump({ + title: "By a friend", + userId: "friend", + createdAt: new Date(Date.now() - 60_000), + }), + ] + : [], + ), + ); + const { socket } = setup("/follow", { session: makeSession() }); + await screen.findByText("By a friend"); + // The follows load beside the feed: their answer must be in. + await waitFor(() => expect(calls("GET /api/me/follow")).toHaveLength(1)); + await act(() => new Promise((r) => setTimeout(r, 0))); + socket.receive({ + type: "dump_created", + dump: wire(makeDump({ title: "Fresh", userId: "friend" })), + }); + socket.receive({ + type: "dump_created", + dump: wire(makeDump({ title: "A stranger's", userId: "stranger" })), + }); + expect(screen.getByText("Fresh")).toBeInTheDocument(); + expect(screen.queryByText("A stranger's")).not.toBeInTheDocument(); + }); + + test("a followed playlist's new dumps bring its view up to date", async () => { + route("GET /api/me/follow", () => ({ + followedUserIds: [], + followedPlaylistIds: ["mix"], + })); + route("GET /api/dumps", () => page([]), { once: true }); + route("GET /api/dumps", () => page([]), { once: true }); + route( + "GET /api/dumps", + (req) => + page( + req.url.searchParams.get("follow") === "playlists" + ? [makeDump({ title: "Added to the mix" })] + : [], + ), + ); + const { socket } = setup("/follow/playlists", { session: makeSession() }); + await screen.findByText( + "Follow some public playlists to see their dumps here.", + ); + await waitFor(() => expect(calls("GET /api/me/follow")).toHaveLength(1)); + await act(() => new Promise((r) => setTimeout(r, 0))); + socket.receive({ + type: "playlist_dumps_updated", + playlistId: "mix", + dumpIds: ["x"], + dumpCount: 1, + }); + expect(await screen.findByText("Added to the mix")).toBeInTheDocument(); + }); + test("its view is in the path, a category's too", async () => { route("GET /api/dumps", () => page([])); setup("/music/follow/playlists", { session: makeSession() }); diff --git a/src/test/pages/PlaylistDetail.test.tsx b/src/test/pages/PlaylistDetail.test.tsx index 752934ce..5013c038 100644 --- a/src/test/pages/PlaylistDetail.test.tsx +++ b/src/test/pages/PlaylistDetail.test.tsx @@ -440,18 +440,47 @@ describe("PlaylistDetail", () => { }); test("a dump that goes private leaves, and comes back at its place", async () => { + route( + "GET /api/dumps/:id", + () => wire(makeDump({ id: "b", title: "Bravo" })), + ); const { socket } = open({ session: visitor }); await screen.findByText("Bravo"); + // What the server sends others when it goes private, then public again. socket.receive({ type: "dump_deleted", dumpId: "b" }); + socket.receive({ + type: "playlist_dumps_updated", + playlistId: "p1", + dumpIds: ["a", "c"], + dumpCount: 3, + }); expect(titles()).toEqual(["Alpha", "Charlie"]); socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "b", title: "Bravo" })), }); + socket.receive({ + type: "playlist_dumps_updated", + playlistId: "p1", + dumpIds: ["a", "b", "c"], + dumpCount: 3, + }); expect(await screen.findByText("Bravo")).toBeInTheDocument(); expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]); }); + test("a playlist made private by its owner sends a visitor home", async () => { + const { socket } = open({ session: visitor }); + await screen.findByText("Alpha"); + // What the server sends everyone but the owner. + socket.receive({ + type: "playlist_deleted", + playlistId: "p1", + userId: "owner", + }); + expect(await screen.findByText("home")).toBeInTheDocument(); + }); + test("deleting the playlist asks, then goes home", async () => { route( "DELETE /api/playlists/:id", diff --git a/src/test/pages/Search.test.tsx b/src/test/pages/Search.test.tsx index 39699e65..095c8980 100644 --- a/src/test/pages/Search.test.tsx +++ b/src/test/pages/Search.test.tsx @@ -13,6 +13,7 @@ import { } from "../fixtures.ts"; import { currentPath, renderApp } from "../render.tsx"; import { FakeIntersectionObserver } from "../setup.ts"; +import { FakeWebSocket } from "../ws.ts"; function results( { @@ -209,4 +210,32 @@ describe("Search", () => { await screen.findByText("About two"); expect(screen.queryByText("About one")).not.toBeInTheDocument(); }); + + test("the socket keeps the results live, and adds none", async () => { + const found = makeDump({ id: "d1", title: "Cat video" }); + route("GET /api/search", () => + results({ + dumps: [found, makeDump({ id: "d2", title: "Cat photo" })], + playlists: [makePlaylist({ id: "p1", title: "Cats" })], + })); + const user = open("/search?q=cat"); + const socket = FakeWebSocket.last; + socket.welcome(); + await screen.findByText("Cat video"); + socket.receive({ + type: "dump_updated", + dump: wire({ ...found, title: "Cat video, remastered" }), + }); + socket.receive({ type: "dump_deleted", dumpId: "d2" }); + socket.receive({ + type: "dump_created", + dump: wire(makeDump({ title: "Cat news" })), + }); + expect(screen.getByText("Cat video, remastered")).toBeInTheDocument(); + expect(screen.queryByText("Cat photo")).not.toBeInTheDocument(); + expect(screen.queryByText("Cat news")).not.toBeInTheDocument(); + socket.receive({ type: "playlist_deleted", playlistId: "p1", userId: "u" }); + await user.click(screen.getByRole("button", { name: /Playlists/ })); + expect(screen.queryByText("Cats")).not.toBeInTheDocument(); + }); }); diff --git a/src/test/pages/UserPublicProfile.test.tsx b/src/test/pages/UserPublicProfile.test.tsx index 146d939b..c9efa95a 100644 --- a/src/test/pages/UserPublicProfile.test.tsx +++ b/src/test/pages/UserPublicProfile.test.tsx @@ -177,6 +177,38 @@ describe("UserPublicProfile", () => { expect(titles).toEqual(["T-x", "T-y", "T-z"]); }); + test("the dumps tab gains her new dumps live, and reads its pages again on a reconnect", async () => { + const old = makeDump({ + id: "o", + title: "Older", + userId: "ann", + createdAt: new Date(Date.now() - 60_000), + }); + const { socket } = open({ url: "/@ann/dumps", dumps: [old] }); + await screen.findByText("Older"); + socket.welcome(); + socket.receive({ + type: "dump_created", + dump: wire(makeDump({ id: "n", title: "Fresh", userId: "ann" })), + }); + // Someone else's goes to their own profile. + socket.receive({ + type: "dump_created", + dump: wire(makeDump({ id: "x", title: "Not hers", userId: "bob" })), + }); + const titles = () => + Array.from(document.querySelectorAll(".dump-card-title")) + .map((a) => a.textContent); + expect(titles()).toEqual(["Fresh", "Older"]); + + // Deleted while the socket was down. + route("GET /api/users/:u/dumps", () => page([old])); + socket.drop(); + await act(() => new Promise((r) => setTimeout(r, 600))); + FakeWebSocket.last.welcome(); + await waitFor(() => expect(titles()).toEqual(["Older"])); + }); + test("the dumps tab narrows to a category, in its URL, and drops a dump edited out of it; only the dump lists keep it", async () => { const music = makeCategory({ id: "c1", slug: "music", name: "Music" }); const tagged = makeDump({ diff --git a/src/test/render.tsx b/src/test/render.tsx index 640e132f..69052d7c 100644 --- a/src/test/render.tsx +++ b/src/test/render.tsx @@ -10,6 +10,7 @@ import { DEFAULT_SETTINGS } from "../../shared/settings.ts"; import { AuthContext } from "../contexts/auth/AuthContext.ts"; import { AuthProvider } from "../contexts/auth/AuthProvider.tsx"; import { + createWSEventBus, defaultWSChatValue, defaultWSPresenceValue, defaultWSValue, @@ -17,6 +18,8 @@ import { type WSChatContextValue, WSContext, type WSContextValue, + type WSEventBus, + WSEventsContext, WSPresenceContext, type WSPresenceContextValue, } from "../contexts/ws/WSContext.ts"; @@ -97,6 +100,8 @@ export interface StubOptions extends RouterOptions { session?: Session | null; /** Any of the socket's three contexts' fields; each goes to its own. */ ws?: Partial; + /** The bus the socket's events come from: a test emits on it. */ + events?: WSEventBus; follows?: Partial; categories?: Category[] | Partial; player?: Partial; @@ -145,6 +150,8 @@ export function renderWithContexts( opts: StubOptions = {}, renderOptions?: RenderOptions, ) { + // One bus for every render, as `WSProvider` keeps one. + const events = opts.events ?? createWSEventBus(); // A wrapper rather than one tree, so `rerender(ui)` keeps the contexts. const Wrapper = ({ children }: { children: ReactNode }) => ( @@ -161,25 +168,29 @@ export function renderWithContexts( }} > - - + - - - {children} - - - - + + {children} + + + + +