v4: every live update reaches every page that shows what it changed — the client kept each kind of event in one "latest" slot, so two arriving before a render lost the first (two notifications of one dump going public showed one); they now go through a subscription, useWSEvent, and each reaches every subscriber. A playlist made private sent its new title and description to everyone, and kept them on a visitor's page; playlists now follow the dumps' rule, broadcastChange: others get playlist_deleted, and playlist_created when it goes public again. A dump deleted or turned private sends its playlists' dumps again, so their counts and pages follow. Comments on a private dump reach its poster's other tabs. Reading the notifications clears the bell in every tab (notifications_read), and a category change makes every client fetch the categories again (categories_changed). The dump page gives way to a notice when its dump is deleted or made private. A profile gains the user's new dumps, the followed feed those of the users followed and the playlists followed, the search results follow edits and deletions, the index's category feed drops a dump edited out of it, and a renamed user's name follows on cards and comments. After a reconnection, every live list fetches the pages it holds again (usePagedList's resync), and the playlist, the profile, the dump, the categories and the follows are fetched again. The position memory of usePositionAwareSync is now inside the list sync hooks, so a returning item is put back without depending on the order the hooks are called.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Serves the comments on dumps. The comments of a private dump 404 for anyone
|
* Serves the comments on dumps. Comments are broadcast over the WebSocket as
|
||||||
* but its poster, and changes to them aren't broadcast. Comments are otherwise
|
* they are posted, edited and deleted, to everyone, or to its poster alone for
|
||||||
* broadcast over the WebSocket as they are posted, edited and deleted.
|
* a private dump, whose comments 404 for anyone else.
|
||||||
*
|
*
|
||||||
* - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment
|
* - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment
|
||||||
* on a dump, oldest first.
|
* on a dump, oldest first.
|
||||||
@@ -76,7 +76,7 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
|||||||
body.body,
|
body.body,
|
||||||
body.parentId ?? undefined,
|
body.parentId ?? undefined,
|
||||||
);
|
);
|
||||||
if (!dump.isPrivate) broadcastCommentCreated(comment);
|
broadcastCommentCreated(comment, dump);
|
||||||
const responseBody: APIResponse<Comment> = { success: true, data: comment };
|
const responseBody: APIResponse<Comment> = { success: true, data: comment };
|
||||||
ctx.response.status = 201;
|
ctx.response.status = 201;
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
@@ -89,13 +89,13 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
|||||||
if (!isUpdateCommentRequest(body)) {
|
if (!isUpdateCommentRequest(body)) {
|
||||||
throw new APIException(400, "Invalid comment data");
|
throw new APIException(400, "Invalid comment data");
|
||||||
}
|
}
|
||||||
const { comment, isPrivate } = updateComment(
|
const { comment, dump } = updateComment(
|
||||||
ctx.params.commentId,
|
ctx.params.commentId,
|
||||||
body.body,
|
body.body,
|
||||||
userId,
|
userId,
|
||||||
canModerate,
|
canModerate,
|
||||||
);
|
);
|
||||||
if (!isPrivate) broadcastCommentUpdated(comment);
|
broadcastCommentUpdated(comment, dump);
|
||||||
const responseBody: APIResponse<Comment> = { success: true, data: comment };
|
const responseBody: APIResponse<Comment> = { success: true, data: comment };
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
@@ -123,12 +123,12 @@ router.get("/comments/:commentId/likers", async (ctx) => {
|
|||||||
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
|
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
|
||||||
const userId = ctx.state.user.userId;
|
const userId = ctx.state.user.userId;
|
||||||
const canModerate = can(ctx.state.user, "comment:moderate");
|
const canModerate = can(ctx.state.user, "comment:moderate");
|
||||||
const { dumpId, isPrivate } = deleteComment(
|
const dump = deleteComment(
|
||||||
ctx.params.commentId,
|
ctx.params.commentId,
|
||||||
userId,
|
userId,
|
||||||
canModerate,
|
canModerate,
|
||||||
);
|
);
|
||||||
if (!isPrivate) broadcastCommentDeleted(ctx.params.commentId, dumpId);
|
broadcastCommentDeleted(ctx.params.commentId, dump);
|
||||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { type Page, pageOf } from "../lib/pagination.ts";
|
|||||||
import { isReservedSlug } from "../lib/reserved-slugs.ts";
|
import { isReservedSlug } from "../lib/reserved-slugs.ts";
|
||||||
import { validateSlug } from "../lib/slugify.ts";
|
import { validateSlug } from "../lib/slugify.ts";
|
||||||
import { VALIDATION } from "../../shared/validation.ts";
|
import { VALIDATION } from "../../shared/validation.ts";
|
||||||
|
import { broadcastCategoriesChanged } from "./ws-service.ts";
|
||||||
import { parseRows } from "../model/rows.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,
|
* 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
|
* @throws {APIException} 400 when the slug or name is invalid, 409 when
|
||||||
* either is taken within its kind.
|
* either is taken within its kind.
|
||||||
@@ -202,6 +204,7 @@ export function createCategory(req: CreateCategoryRequest): Category {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
uniqueConflict(err, req.slug, name);
|
uniqueConflict(err, req.slug, name);
|
||||||
}
|
}
|
||||||
|
broadcastCategoriesChanged();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -249,6 +252,7 @@ export function updateCategory(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
uniqueConflict(err, newSlug, newName);
|
uniqueConflict(err, newSlug, newName);
|
||||||
}
|
}
|
||||||
|
broadcastCategoriesChanged();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...existing,
|
...existing,
|
||||||
@@ -270,6 +274,7 @@ export function deleteCategory(id: string): void {
|
|||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
throw new APIException(404, "Category not found");
|
throw new APIException(404, "Category not found");
|
||||||
}
|
}
|
||||||
|
broadcastCategoriesChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -114,12 +114,44 @@ export function createComment(
|
|||||||
return comment;
|
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`.
|
* Replaces a comment's body, for `PATCH /api/comments/:commentId`.
|
||||||
* `canModerate` lets someone other than the author do it. It notifies, in the
|
* `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
|
* 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
|
* 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
|
* @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.
|
* deleted, 403 when the user is neither its author nor a moderator.
|
||||||
@@ -129,13 +161,8 @@ export function updateComment(
|
|||||||
body: string,
|
body: string,
|
||||||
requestingUserId: string,
|
requestingUserId: string,
|
||||||
canModerate: boolean,
|
canModerate: boolean,
|
||||||
): { comment: Comment; dumpId: string; isPrivate: boolean } {
|
): { comment: Comment; dump: CommentDump } {
|
||||||
const row = db.prepare(
|
const dump = commentDump(commentId);
|
||||||
`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");
|
|
||||||
}
|
|
||||||
const existing = fetchComment(commentId);
|
const existing = fetchComment(commentId);
|
||||||
if (existing.deleted) {
|
if (existing.deleted) {
|
||||||
throw new APIException(400, "Cannot edit a deleted comment");
|
throw new APIException(400, "Cannot edit a deleted comment");
|
||||||
@@ -149,9 +176,9 @@ export function updateComment(
|
|||||||
now,
|
now,
|
||||||
commentId,
|
commentId,
|
||||||
);
|
);
|
||||||
if (!row.is_private) {
|
if (!dump.isPrivate) {
|
||||||
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
||||||
row.dump_id,
|
dump.id,
|
||||||
) as { title: string } | undefined;
|
) as { title: string } | undefined;
|
||||||
// A moderator's edit keeps the author's words, so the author mentions.
|
// A moderator's edit keeps the author's words, so the author mentions.
|
||||||
notifyMentions(
|
notifyMentions(
|
||||||
@@ -160,23 +187,19 @@ export function updateComment(
|
|||||||
"comment",
|
"comment",
|
||||||
commentId,
|
commentId,
|
||||||
dumpRow?.title ?? "",
|
dumpRow?.title ?? "",
|
||||||
row.dump_id,
|
dump.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
linkAttachments(body, "comment", commentId);
|
linkAttachments(body, "comment", commentId);
|
||||||
relinkBacklinks("comment", commentId, row.dump_id, body);
|
relinkBacklinks("comment", commentId, dump.id, body);
|
||||||
return {
|
return { comment: fetchComment(commentId), dump };
|
||||||
comment: fetchComment(commentId),
|
|
||||||
dumpId: row.dump_id,
|
|
||||||
isPrivate: Boolean(row.is_private),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a comment, for `DELETE /api/comments/:commentId`. `canModerate` lets
|
* Deletes a comment, for `DELETE /api/comments/:commentId`. `canModerate` lets
|
||||||
* someone other than the author do it. The body is emptied and the backlinks
|
* 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
|
* 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
|
* @throws {APIException} 404 when there is no such comment, 403 when the user
|
||||||
* is neither its author nor a moderator.
|
* is neither its author nor a moderator.
|
||||||
@@ -185,13 +208,8 @@ export function deleteComment(
|
|||||||
commentId: string,
|
commentId: string,
|
||||||
requestingUserId: string,
|
requestingUserId: string,
|
||||||
canModerate: boolean,
|
canModerate: boolean,
|
||||||
): { dumpId: string; isPrivate: boolean } {
|
): CommentDump {
|
||||||
const row = db.prepare(
|
const dump = commentDump(commentId);
|
||||||
`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");
|
|
||||||
}
|
|
||||||
const comment = fetchComment(commentId);
|
const comment = fetchComment(commentId);
|
||||||
if (comment.userId !== requestingUserId && !canModerate) {
|
if (comment.userId !== requestingUserId && !canModerate) {
|
||||||
throw new APIException(403, "Not authorized to delete this comment");
|
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(
|
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(
|
||||||
commentId,
|
commentId,
|
||||||
);
|
);
|
||||||
relinkBacklinks("comment", commentId, row.dump_id, "");
|
relinkBacklinks("comment", commentId, dump.id, "");
|
||||||
return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) };
|
return dump;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ import {
|
|||||||
} from "./rich-content-service.ts";
|
} from "./rich-content-service.ts";
|
||||||
import { isValidHttpUrl } from "../remote/fetch.ts";
|
import { isValidHttpUrl } from "../remote/fetch.ts";
|
||||||
import { broadcastDumpChange } from "./ws-service.ts";
|
import { broadcastDumpChange } from "./ws-service.ts";
|
||||||
|
import {
|
||||||
|
broadcastHoldersDumps,
|
||||||
|
type PlaylistHolder,
|
||||||
|
playlistsHolding,
|
||||||
|
} from "./playlist-service.ts";
|
||||||
import {
|
import {
|
||||||
notifyMentions,
|
notifyMentions,
|
||||||
notifyPlaylistFollowersNowPublic,
|
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
|
* 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
|
* 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 };
|
const dump: Dump = { ...fetchDump(dumpId), categoryIds };
|
||||||
broadcastDumpChange(null, dump);
|
announceDumpChange(null, dump);
|
||||||
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, title);
|
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, title);
|
||||||
if (request.comment) {
|
if (request.comment) {
|
||||||
// A private dump's mentions are notified if it goes public.
|
// 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 };
|
const dump: Dump = { ...fetchDump(dumpId), categoryIds: dedupedCategoryIds };
|
||||||
broadcastDumpChange(null, dump);
|
announceDumpChange(null, dump);
|
||||||
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, finalTitle);
|
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, finalTitle);
|
||||||
if (comment) {
|
if (comment) {
|
||||||
// A private dump's mentions are notified if it goes public.
|
// A private dump's mentions are notified if it goes public.
|
||||||
@@ -486,7 +508,7 @@ export async function updateDump(
|
|||||||
} else {
|
} else {
|
||||||
attachCategoryIds("dump", [updatedDump]);
|
attachCategoryIds("dump", [updatedDump]);
|
||||||
}
|
}
|
||||||
broadcastDumpChange(dump, updatedDump);
|
announceDumpChange(dump, updatedDump);
|
||||||
notifyIfWentPublic(dump, updatedDump);
|
notifyIfWentPublic(dump, updatedDump);
|
||||||
if (updatedDump.comment) {
|
if (updatedDump.comment) {
|
||||||
if (!updatedDump.isPrivate) {
|
if (!updatedDump.isPrivate) {
|
||||||
@@ -558,7 +580,7 @@ export async function updateDump(
|
|||||||
attachCategoryIds("dump", [updatedDump]);
|
attachCategoryIds("dump", [updatedDump]);
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastDumpChange(dump, updatedDump);
|
announceDumpChange(dump, updatedDump);
|
||||||
notifyIfWentPublic(dump, updatedDump);
|
notifyIfWentPublic(dump, updatedDump);
|
||||||
if (updatedDump.comment) {
|
if (updatedDump.comment) {
|
||||||
if (!updatedDump.isPrivate) {
|
if (!updatedDump.isPrivate) {
|
||||||
@@ -687,7 +709,7 @@ export async function replaceFileDump(
|
|||||||
isPrivate: finalPrivate,
|
isPrivate: finalPrivate,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
broadcastDumpChange(dump, updatedDump);
|
announceDumpChange(dump, updatedDump);
|
||||||
notifyIfWentPublic(dump, updatedDump);
|
notifyIfWentPublic(dump, updatedDump);
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
@@ -799,7 +821,7 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
|||||||
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
|
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
|
||||||
).run(row.title, row.rich_content, row.id);
|
).run(row.title, row.rich_content, row.id);
|
||||||
|
|
||||||
broadcastDumpChange(dump, updatedDump);
|
announceDumpChange(dump, updatedDump);
|
||||||
|
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
@@ -826,7 +848,7 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
|
|||||||
dump.id,
|
dump.id,
|
||||||
);
|
);
|
||||||
const updatedDump: Dump = { ...dump, thumbnailMime: mime };
|
const updatedDump: Dump = { ...dump, thumbnailMime: mime };
|
||||||
broadcastDumpChange(dump, updatedDump);
|
announceDumpChange(dump, updatedDump);
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -842,18 +864,20 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
|
|||||||
.run(dump.id);
|
.run(dump.id);
|
||||||
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
|
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
|
||||||
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
|
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
|
||||||
broadcastDumpChange(dump, updatedDump);
|
announceDumpChange(dump, updatedDump);
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a dump with its files and thumbnails, for
|
* 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.
|
* @throws {APIException} 404 when there is no such dump.
|
||||||
*/
|
*/
|
||||||
export async function deleteDump(dumpId: string): Promise<void> {
|
export async function deleteDump(dumpId: string): Promise<void> {
|
||||||
const dump = fetchDump(dumpId);
|
const dump = fetchDump(dumpId);
|
||||||
|
const holders = playlistsHolding(dumpId);
|
||||||
|
|
||||||
const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId);
|
const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId);
|
||||||
|
|
||||||
@@ -869,5 +893,5 @@ export async function deleteDump(dumpId: string): Promise<void> {
|
|||||||
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
|
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastDumpChange(dump, null);
|
announceDumpChange(dump, null, holders);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,12 +167,14 @@ export function getUnreadCount(userId: string): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks every notification of `userId` read, when they open the
|
* 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 {
|
export function markAllRead(userId: string): void {
|
||||||
db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run(
|
db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run(
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
sendToUser(userId, { type: "notifications_read" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ import {
|
|||||||
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
|
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
|
||||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import {
|
import {
|
||||||
broadcastPlaylistCreated,
|
broadcastPlaylistChange,
|
||||||
broadcastPlaylistDeleted,
|
|
||||||
broadcastPlaylistDumpsUpdated,
|
broadcastPlaylistDumpsUpdated,
|
||||||
broadcastPlaylistUpdated,
|
|
||||||
} from "./ws-service.ts";
|
} from "./ws-service.ts";
|
||||||
import {
|
import {
|
||||||
notifyMentions,
|
notifyMentions,
|
||||||
@@ -138,7 +136,7 @@ export function createPlaylist(
|
|||||||
}
|
}
|
||||||
linkAttachments(req.description, "playlist", id);
|
linkAttachments(req.description, "playlist", id);
|
||||||
}
|
}
|
||||||
broadcastPlaylistCreated(playlist);
|
broadcastPlaylistChange(null, playlist);
|
||||||
return playlist;
|
return playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +274,7 @@ export function listPlaylistsByUser(
|
|||||||
* playlist is public, it notifies, in the owner's name, the users the
|
* playlist is public, it notifies, in the owner's name, the users the
|
||||||
* description mentions. When it goes public, its followers are notified of
|
* 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
|
* 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
|
* @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
|
* 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);
|
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.
|
// Its followers weren't told of the dumps added while it was private.
|
||||||
if (!playlist.isPublic && newIsPublic) {
|
if (!playlist.isPublic && newIsPublic) {
|
||||||
notifyPlaylistFollowersNowPublic({ playlistId: playlist.id });
|
notifyPlaylistFollowersNowPublic({ playlistId: playlist.id });
|
||||||
@@ -376,7 +374,7 @@ export function deletePlaylist(
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
|
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 = ?;`,
|
`UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`,
|
||||||
).run(imageMime, new Date().toISOString(), playlist.id);
|
).run(imageMime, new Date().toISOString(), playlist.id);
|
||||||
const updated = getPlaylistById(playlist.id);
|
const updated = getPlaylistById(playlist.id);
|
||||||
broadcastPlaylistUpdated(updated, playlist.isPublic);
|
broadcastPlaylistChange(playlist, updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,3 +674,37 @@ function getCurrentMembers(playlistId: string): PlaylistMember[] {
|
|||||||
userId: r.user_id,
|
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<Playlist, "id" | "isPublic" | "userId">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ function broadcast(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends `data` to every socket of `userId`. The notification service sends
|
* Sends `data` to every socket of `userId`. The notification service sends
|
||||||
* `notification_created` with it, the follow service `follows_changed`, and
|
* `notification_created` and `notifications_read` with it, the follow service
|
||||||
* the route `chat_read_update`.
|
* `follows_changed`, and the route `chat_read_update`.
|
||||||
*/
|
*/
|
||||||
export function sendToUser(userId: string, data: ServerToClientMessage): void {
|
export function sendToUser(userId: string, data: ServerToClientMessage): void {
|
||||||
broadcast(data, (client) => client.userId === userId);
|
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
|
* Sends the messages that tell each client how an item it may see changed,
|
||||||
* after every write. `before` is null for a new dump, `after` for a deleted
|
* for {@link broadcastDumpChange} and {@link broadcastPlaylistChange}.
|
||||||
* one. A private dump's changes reach its owner's sockets alone. When a dump
|
* `before` is null for a new item, `after` for a deleted one. A private item's
|
||||||
* goes private, everyone else gets `dump_deleted`; when it goes public, they
|
* changes reach its owner's sockets alone. When it goes private, everyone else
|
||||||
* get `dump_created`, since to them it is new.
|
* 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<T extends { userId: string }>(
|
||||||
|
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(
|
export function broadcastDumpChange(
|
||||||
before: Dump | null,
|
before: Dump | null,
|
||||||
after: Dump | null,
|
after: Dump | null,
|
||||||
): void {
|
): void {
|
||||||
const dump = after ?? before;
|
broadcastChange(before, after, (dump) => dump.isPrivate, {
|
||||||
if (!dump) return;
|
created: (dump) => ({ type: "dump_created", dump }),
|
||||||
const ownerId = dump.userId;
|
updated: (dump) => ({ type: "dump_updated", dump }),
|
||||||
const toOwner = (client: WsClient) => client.userId === ownerId;
|
deleted: (dump) => ({ type: "dump_deleted", dumpId: dump.id }),
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -248,50 +261,24 @@ export function broadcastCommentLikeUpdate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends `data` to everyone for a public playlist, else to its owner alone. */
|
|
||||||
function sendToPlaylistAudience(
|
|
||||||
playlist: Pick<Playlist, "isPublic" | "userId">,
|
|
||||||
data: ServerToClientMessage,
|
|
||||||
): void {
|
|
||||||
broadcast(
|
|
||||||
data,
|
|
||||||
(client) => playlist.isPublic || client.userId === playlist.userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends `playlist_created` to the new playlist's audience. The playlist service
|
* Tells the clients that may see a playlist how it changed, as
|
||||||
* calls it.
|
* {@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 {
|
export function broadcastPlaylistChange(
|
||||||
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
|
before: Playlist | null,
|
||||||
}
|
after: Playlist | null,
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
): void {
|
): void {
|
||||||
sendToPlaylistAudience(
|
broadcastChange(before, after, (playlist) => !playlist.isPublic, {
|
||||||
{ isPublic: playlist.isPublic || wasPublic, userId: playlist.userId },
|
created: (playlist) => ({ type: "playlist_created", playlist }),
|
||||||
{ type: "playlist_updated", playlist },
|
updated: (playlist) => ({ type: "playlist_updated", playlist }),
|
||||||
);
|
deleted: (p) => ({
|
||||||
}
|
type: "playlist_deleted",
|
||||||
|
playlistId: p.id,
|
||||||
/** Sends `playlist_deleted` to the audience the playlist had. */
|
userId: p.userId,
|
||||||
export function broadcastPlaylistDeleted(
|
}),
|
||||||
playlistId: string,
|
|
||||||
userId: string,
|
|
||||||
isPublic: boolean,
|
|
||||||
): void {
|
|
||||||
sendToPlaylistAudience({ isPublic, userId }, {
|
|
||||||
type: "playlist_deleted",
|
|
||||||
playlistId,
|
|
||||||
userId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +288,7 @@ export function broadcastPlaylistDeleted(
|
|||||||
* in a public playlist stay hidden from everyone but their posters.
|
* in a public playlist stay hidden from everyone but their posters.
|
||||||
*/
|
*/
|
||||||
export function broadcastPlaylistDumpsUpdated(
|
export function broadcastPlaylistDumpsUpdated(
|
||||||
playlist: Playlist,
|
playlist: Pick<Playlist, "id" | "isPublic" | "userId">,
|
||||||
members: PlaylistMember[],
|
members: PlaylistMember[],
|
||||||
): void {
|
): void {
|
||||||
for (const client of clients) {
|
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
|
* Returns the filter of a broadcast about a comment: everyone while its dump
|
||||||
* comment on a public dump, as it does the two below.
|
* is public, the dump's owner alone while it is private, since no one else may
|
||||||
|
* see it.
|
||||||
*/
|
*/
|
||||||
export function broadcastCommentCreated(comment: Comment): void {
|
function audienceOf(
|
||||||
broadcast({ type: "comment_created", comment });
|
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(
|
export function broadcastCommentDeleted(
|
||||||
commentId: string,
|
commentId: string,
|
||||||
dumpId: string,
|
dump: { id: string; isPrivate: boolean; userId: string },
|
||||||
): void {
|
): void {
|
||||||
broadcast({ type: "comment_deleted", commentId, dumpId });
|
broadcast(
|
||||||
|
{ type: "comment_deleted", commentId, dumpId: dump.id },
|
||||||
|
audienceOf(dump),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends everyone `comment_updated`. */
|
/** Sends `comment_updated`, as {@link broadcastCommentCreated} does. */
|
||||||
export function broadcastCommentUpdated(comment: Comment): void {
|
export function broadcastCommentUpdated(
|
||||||
broadcast({ type: "comment_updated", comment });
|
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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
insertDump,
|
insertDump,
|
||||||
insertPlaylist,
|
insertPlaylist,
|
||||||
PASSWORD,
|
PASSWORD,
|
||||||
|
patch,
|
||||||
PNG,
|
PNG,
|
||||||
post,
|
post,
|
||||||
put,
|
put,
|
||||||
@@ -19,7 +20,9 @@ import {
|
|||||||
} from "../helpers.ts";
|
} from "../helpers.ts";
|
||||||
import { assert, assertEquals, assertExists, assertFalse } from "@std/assert";
|
import { assert, assertEquals, assertExists, assertFalse } from "@std/assert";
|
||||||
import type { AuthTokens } from "../../../shared/user.ts";
|
import type { AuthTokens } from "../../../shared/user.ts";
|
||||||
|
import type { Comment } from "../../../shared/comment.ts";
|
||||||
import type { Dump } from "../../../shared/dump.ts";
|
import type { Dump } from "../../../shared/dump.ts";
|
||||||
|
import type { Playlist } from "../../../shared/playlist.ts";
|
||||||
import type { ServerToClientMessage } from "../../../shared/ws.ts";
|
import type { ServerToClientMessage } from "../../../shared/ws.ts";
|
||||||
|
|
||||||
// deno.json's "dom" lib types WebSocket's second argument as protocols only,
|
// 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();
|
await o.sync();
|
||||||
assertEquals(o.all("dump_deleted").length, 1);
|
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 });
|
const priv = insertDump({ userId: owner.id, isPrivate: true });
|
||||||
await post(`/api/dumps/${priv.id}/comments`, {
|
const shh = await post<Comment>(`/api/dumps/${priv.id}/comments`, {
|
||||||
auth: owner.auth,
|
auth: owner.auth,
|
||||||
json: { body: "shh" },
|
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`, {
|
await post(`/api/dumps/${pub.data.id}/comments`, {
|
||||||
auth: owner.auth,
|
auth: owner.auth,
|
||||||
json: { body: "hi" },
|
json: { body: "hi" },
|
||||||
@@ -632,6 +643,8 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
|
|||||||
assertEquals(created.comment.body, "hi");
|
assertEquals(created.comment.body, "hi");
|
||||||
await x.sync();
|
await x.sync();
|
||||||
assertEquals(x.all("comment_created").length, 1);
|
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.
|
// A private playlist's events go to its owner alone.
|
||||||
const pl = insertPlaylist({ userId: owner.id, isPublic: false });
|
const pl = insertPlaylist({ userId: owner.id, isPublic: false });
|
||||||
@@ -744,3 +757,128 @@ wsTest(
|
|||||||
assertEquals((await x.next("dump_created")).dump.id, id);
|
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<Playlist>(`/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");
|
||||||
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
119
locales/en.po
119
locales/en.po
@@ -60,7 +60,7 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
|
|||||||
msgid "email.digest.totals"
|
msgid "email.digest.totals"
|
||||||
msgstr "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}."
|
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})"
|
msgid "{label} ({count})"
|
||||||
msgstr "{label} ({count})"
|
msgstr "{label} ({count})"
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ msgstr "{n, plural, one {upvote} other {upvotes}}"
|
|||||||
msgid "email.digest.more"
|
msgid "email.digest.more"
|
||||||
msgstr "{n} more on your Followed feed"
|
msgstr "{n} more on your Followed feed"
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:508
|
#: src/pages/Notifications.tsx:499
|
||||||
msgid "{totalUnread, plural, one {# new} other {# new}}"
|
msgid "{totalUnread, plural, one {# new} other {# new}}"
|
||||||
msgstr "{totalUnread, plural, one {# new} other {# new}}"
|
msgstr "{totalUnread, plural, one {# new} other {# new}}"
|
||||||
|
|
||||||
@@ -211,8 +211,9 @@ msgstr "# Welcome to {site}!\n"
|
|||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
msgstr "← Back"
|
msgstr "← Back"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:330
|
#: src/pages/Dump.tsx:353
|
||||||
#: src/pages/Dump.tsx:557
|
#: src/pages/Dump.tsx:378
|
||||||
|
#: src/pages/Dump.tsx:605
|
||||||
#: src/pages/DumpEdit.tsx:234
|
#: src/pages/DumpEdit.tsx:234
|
||||||
msgid "← Back to all dumps"
|
msgid "← Back to all dumps"
|
||||||
msgstr "← Back to all dumps"
|
msgstr "← Back to all dumps"
|
||||||
@@ -225,7 +226,7 @@ msgstr "+ Invite someone"
|
|||||||
msgid "+ New playlist"
|
msgid "+ New playlist"
|
||||||
msgstr "+ New playlist"
|
msgstr "+ New playlist"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:404
|
#: src/pages/Dump.tsx:452
|
||||||
msgid "+ Playlist"
|
msgid "+ Playlist"
|
||||||
msgstr "+ Playlist"
|
msgstr "+ Playlist"
|
||||||
|
|
||||||
@@ -418,8 +419,8 @@ msgstr "by @{username}"
|
|||||||
msgid "email.digest.playlistMeta"
|
msgid "email.digest.playlistMeta"
|
||||||
msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}"
|
msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}"
|
||||||
|
|
||||||
#: src/contexts/ws/WSProvider.tsx:395
|
#: src/contexts/ws/WSProvider.tsx:376
|
||||||
#: src/contexts/ws/WSProvider.tsx:764
|
#: src/contexts/ws/WSProvider.tsx:765
|
||||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
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."
|
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/profile/EmailEditor.tsx:83
|
||||||
#: src/components/shared/ConfirmModal.tsx:63
|
#: src/components/shared/ConfirmModal.tsx:63
|
||||||
#: src/pages/Authorize.tsx:158
|
#: src/pages/Authorize.tsx:158
|
||||||
#: src/pages/Dump.tsx:446
|
#: src/pages/Dump.tsx:494
|
||||||
#: src/pages/DumpEdit.tsx:561
|
#: src/pages/DumpEdit.tsx:561
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Cancel"
|
msgstr "Cancel"
|
||||||
@@ -554,7 +555,7 @@ msgstr "Confirm new password"
|
|||||||
msgid "Connecting…"
|
msgid "Connecting…"
|
||||||
msgstr "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"
|
msgid "Connection lost — this message may not have been sent"
|
||||||
msgstr "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."
|
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."
|
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
|
#: src/pages/DumpEdit.tsx:555
|
||||||
msgid "Could not delete"
|
msgid "Could not delete"
|
||||||
msgstr "Could not delete"
|
msgstr "Could not delete"
|
||||||
@@ -680,7 +681,7 @@ msgstr "Delete failed ({0})"
|
|||||||
|
|
||||||
#: src/components/playlist/PlaylistCard.tsx:149
|
#: src/components/playlist/PlaylistCard.tsx:149
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:213
|
#: src/components/playlist/PlaylistEditForm.tsx:213
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:208
|
#: src/components/profile/ProfilePlaylistsTab.tsx:201
|
||||||
#: src/pages/PlaylistDetail.tsx:172
|
#: src/pages/PlaylistDetail.tsx:172
|
||||||
msgid "Delete playlist"
|
msgid "Delete playlist"
|
||||||
msgstr "Delete playlist"
|
msgstr "Delete playlist"
|
||||||
@@ -697,7 +698,7 @@ msgstr "Delete this dump? This cannot be undone."
|
|||||||
msgid "Delete this message?"
|
msgid "Delete this message?"
|
||||||
msgstr "Delete this message?"
|
msgstr "Delete this message?"
|
||||||
|
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:207
|
#: src/components/profile/ProfilePlaylistsTab.tsx:200
|
||||||
#: src/pages/PlaylistDetail.tsx:171
|
#: src/pages/PlaylistDetail.tsx:171
|
||||||
msgid "Delete this playlist? This cannot be undone."
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
msgstr "Delete this playlist? This cannot be undone."
|
msgstr "Delete this playlist? This cannot be undone."
|
||||||
@@ -769,7 +770,7 @@ msgid "Dumped!"
|
|||||||
msgstr "Dumped!"
|
msgstr "Dumped!"
|
||||||
|
|
||||||
#: src/components/profile/ProfileDumpsTab.tsx:46
|
#: src/components/profile/ProfileDumpsTab.tsx:46
|
||||||
#: src/pages/Search.tsx:148
|
#: src/pages/Search.tsx:174
|
||||||
#: src/pages/UserPublicProfile.tsx:160
|
#: src/pages/UserPublicProfile.tsx:160
|
||||||
msgid "Dumps"
|
msgid "Dumps"
|
||||||
msgstr "Dumps"
|
msgstr "Dumps"
|
||||||
@@ -788,7 +789,7 @@ msgstr "Earlier"
|
|||||||
#: src/components/chat/PendingItem.tsx:93
|
#: src/components/chat/PendingItem.tsx:93
|
||||||
#: src/components/comment/CommentNode.tsx:256
|
#: src/components/comment/CommentNode.tsx:256
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:60
|
#: src/components/playlist/PlaylistHeader.tsx:60
|
||||||
#: src/pages/Dump.tsx:553
|
#: src/pages/Dump.tsx:601
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Edit"
|
msgstr "Edit"
|
||||||
|
|
||||||
@@ -806,7 +807,7 @@ msgid "Edit email"
|
|||||||
msgstr "Edit email"
|
msgstr "Edit email"
|
||||||
|
|
||||||
#: src/components/dump/create/TitleField.tsx:69
|
#: src/components/dump/create/TitleField.tsx:69
|
||||||
#: src/pages/Dump.tsx:463
|
#: src/pages/Dump.tsx:511
|
||||||
msgid "Edit title"
|
msgid "Edit title"
|
||||||
msgstr "Edit title"
|
msgstr "Edit title"
|
||||||
|
|
||||||
@@ -820,7 +821,7 @@ msgstr "edited"
|
|||||||
#: src/components/chat/ChatMessageItem.tsx:132
|
#: src/components/chat/ChatMessageItem.tsx:132
|
||||||
#: src/components/comment/CommentNode.tsx:205
|
#: src/components/comment/CommentNode.tsx:205
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:95
|
#: src/components/playlist/PlaylistHeader.tsx:95
|
||||||
#: src/pages/Dump.tsx:506
|
#: src/pages/Dump.tsx:554
|
||||||
msgid "edited {0}"
|
msgid "edited {0}"
|
||||||
msgstr "edited {0}"
|
msgstr "edited {0}"
|
||||||
|
|
||||||
@@ -830,7 +831,7 @@ msgstr "edited {0}"
|
|||||||
#: src/components/chat/ChatMessageItem.tsx:128
|
#: src/components/chat/ChatMessageItem.tsx:128
|
||||||
#: src/components/comment/CommentNode.tsx:203
|
#: src/components/comment/CommentNode.tsx:203
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:93
|
#: src/components/playlist/PlaylistHeader.tsx:93
|
||||||
#: src/pages/Dump.tsx:504
|
#: src/pages/Dump.tsx:552
|
||||||
msgid "Edited {0}"
|
msgid "Edited {0}"
|
||||||
msgstr "Edited {0}"
|
msgstr "Edited {0}"
|
||||||
|
|
||||||
@@ -851,7 +852,7 @@ msgstr "Email"
|
|||||||
msgid "Email address"
|
msgid "Email address"
|
||||||
msgstr "Email address"
|
msgstr "Email address"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:204
|
#: src/pages/Search.tsx:230
|
||||||
msgid "Enter a query to search."
|
msgid "Enter a query to search."
|
||||||
msgstr "Enter a query to search."
|
msgstr "Enter a query to search."
|
||||||
|
|
||||||
@@ -942,16 +943,16 @@ msgstr "Follow {targetUsername}"
|
|||||||
msgid "Follow playlist"
|
msgid "Follow playlist"
|
||||||
msgstr "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."
|
msgid "Follow some public playlists to see their dumps here."
|
||||||
msgstr "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."
|
msgid "Follow some users to see their dumps here."
|
||||||
msgstr "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/feed/FeedTabBar.tsx:61
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:140
|
#: src/components/profile/ProfileFollowTab.tsx:144
|
||||||
#: src/pages/Settings.tsx:57
|
#: src/pages/Settings.tsx:57
|
||||||
#: src/pages/UserPublicProfile.tsx:163
|
#: src/pages/UserPublicProfile.tsx:163
|
||||||
msgid "Followed"
|
msgid "Followed"
|
||||||
@@ -971,7 +972,7 @@ msgstr "For you"
|
|||||||
msgid "Forgot password?"
|
msgid "Forgot password?"
|
||||||
msgstr "Forgot password?"
|
msgstr "Forgot password?"
|
||||||
|
|
||||||
#: src/components/feed/FollowedFeed.tsx:137
|
#: src/components/feed/FollowedFeed.tsx:165
|
||||||
msgid "From people"
|
msgid "From people"
|
||||||
msgstr "From people"
|
msgstr "From people"
|
||||||
|
|
||||||
@@ -980,7 +981,7 @@ msgstr "From people"
|
|||||||
msgid "email.digest.network"
|
msgid "email.digest.network"
|
||||||
msgstr "From people you follow"
|
msgstr "From people you follow"
|
||||||
|
|
||||||
#: src/components/feed/FollowedFeed.tsx:138
|
#: src/components/feed/FollowedFeed.tsx:166
|
||||||
msgid "From playlists"
|
msgid "From playlists"
|
||||||
msgstr "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"
|
msgid "image"
|
||||||
msgstr "image"
|
msgstr "image"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:573
|
#: src/pages/Dump.tsx:621
|
||||||
msgid "In collections"
|
msgid "In collections"
|
||||||
msgstr "In collections"
|
msgstr "In collections"
|
||||||
|
|
||||||
@@ -1109,7 +1110,7 @@ msgstr "Link"
|
|||||||
msgid "Link or file"
|
msgid "Link or file"
|
||||||
msgstr "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…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "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"
|
msgid "Load older messages"
|
||||||
msgstr "Load older messages"
|
msgstr "Load older messages"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:306
|
#: src/pages/Dump.tsx:337
|
||||||
#: src/pages/DumpEdit.tsx:210
|
#: src/pages/DumpEdit.tsx:210
|
||||||
msgid "Loading dump…"
|
msgid "Loading dump…"
|
||||||
msgstr "Loading dump…"
|
msgstr "Loading dump…"
|
||||||
@@ -1271,7 +1272,7 @@ msgstr "Next track"
|
|||||||
msgid "No dumps in this playlist yet."
|
msgid "No dumps in this playlist yet."
|
||||||
msgstr "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}\"."
|
msgid "No dumps match \"{q}\"."
|
||||||
msgstr "No dumps match \"{q}\"."
|
msgstr "No dumps match \"{q}\"."
|
||||||
|
|
||||||
@@ -1284,7 +1285,7 @@ msgstr "No dumps yet. Be the first!"
|
|||||||
msgid "No emoji found."
|
msgid "No emoji found."
|
||||||
msgstr "No emoji found."
|
msgstr "No emoji found."
|
||||||
|
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:113
|
#: src/components/profile/ProfileFollowTab.tsx:117
|
||||||
msgid "No followed playlists yet."
|
msgid "No followed playlists yet."
|
||||||
msgstr "No followed playlists yet."
|
msgstr "No followed playlists yet."
|
||||||
|
|
||||||
@@ -1300,21 +1301,21 @@ msgstr "No messages yet. Say hello!"
|
|||||||
msgid "No one yet."
|
msgid "No one yet."
|
||||||
msgstr "No one yet."
|
msgstr "No one yet."
|
||||||
|
|
||||||
#: src/pages/Search.tsx:261
|
#: src/pages/Search.tsx:287
|
||||||
msgid "No playlists match \"{q}\"."
|
msgid "No playlists match \"{q}\"."
|
||||||
msgstr "No playlists match \"{q}\"."
|
msgstr "No playlists match \"{q}\"."
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:185
|
#: src/components/profile/ProfilePlaylistsTab.tsx:178
|
||||||
#: src/pages/Playlists.tsx:118
|
#: src/pages/Playlists.tsx:126
|
||||||
msgid "No playlists yet."
|
msgid "No playlists yet."
|
||||||
msgstr "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"
|
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"
|
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}\"."
|
msgid "No users match \"{q}\"."
|
||||||
msgstr "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/ProfileDumpsTab.tsx:97
|
||||||
#: src/components/profile/ProfileOverviewTab.tsx:47
|
#: src/components/profile/ProfileOverviewTab.tsx:47
|
||||||
#: src/pages/Notifications.tsx:522
|
#: src/pages/Notifications.tsx:513
|
||||||
msgid "Nothing here yet."
|
msgid "Nothing here yet."
|
||||||
msgstr "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:387
|
||||||
#: src/pages/Notifications.tsx:505
|
#: src/pages/Notifications.tsx:496
|
||||||
#: src/pages/Settings.tsx:163
|
#: src/pages/Settings.tsx:163
|
||||||
msgid "Notifications"
|
msgid "Notifications"
|
||||||
msgstr "Notifications"
|
msgstr "Notifications"
|
||||||
|
|
||||||
#: src/components/layout/NotificationBell.tsx:56
|
#: src/components/layout/NotificationBell.tsx:48
|
||||||
msgid "Notifications ({unreadNotificationCount} unread)"
|
msgid "Notifications ({unreadNotificationCount} unread)"
|
||||||
msgstr "Notifications ({unreadNotificationCount} unread)"
|
msgstr "Notifications ({unreadNotificationCount} unread)"
|
||||||
|
|
||||||
@@ -1469,11 +1470,11 @@ msgstr "Playlist categories"
|
|||||||
#: src/components/dump/create/StepIndicator.tsx:21
|
#: src/components/dump/create/StepIndicator.tsx:21
|
||||||
#: src/components/layout/AppHeader.tsx:172
|
#: src/components/layout/AppHeader.tsx:172
|
||||||
#: src/components/layout/UserMenu.tsx:92
|
#: src/components/layout/UserMenu.tsx:92
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:144
|
#: src/components/profile/ProfileFollowTab.tsx:148
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:156
|
#: src/components/profile/ProfilePlaylistsTab.tsx:149
|
||||||
#: src/pages/Playlists.tsx:52
|
#: src/pages/Playlists.tsx:53
|
||||||
#: src/pages/Playlists.tsx:106
|
#: src/pages/Playlists.tsx:114
|
||||||
#: src/pages/Search.tsx:151
|
#: src/pages/Search.tsx:177
|
||||||
#: src/pages/UserPublicProfile.tsx:162
|
#: src/pages/UserPublicProfile.tsx:162
|
||||||
msgid "Playlists"
|
msgid "Playlists"
|
||||||
msgstr "Playlists"
|
msgstr "Playlists"
|
||||||
@@ -1501,7 +1502,7 @@ msgstr "Previous track"
|
|||||||
#: src/components/playlist/PlaylistCard.tsx:106
|
#: src/components/playlist/PlaylistCard.tsx:106
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:77
|
#: src/components/playlist/PlaylistHeader.tsx:77
|
||||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
||||||
#: src/pages/Dump.tsx:512
|
#: src/pages/Dump.tsx:560
|
||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "private"
|
msgstr "private"
|
||||||
|
|
||||||
@@ -1544,7 +1545,7 @@ msgstr "Registering…"
|
|||||||
msgid "Registration failed"
|
msgid "Registration failed"
|
||||||
msgstr "Registration failed"
|
msgstr "Registration failed"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:564
|
#: src/pages/Dump.tsx:612
|
||||||
msgid "Related"
|
msgid "Related"
|
||||||
msgstr "Related"
|
msgstr "Related"
|
||||||
|
|
||||||
@@ -1631,7 +1632,7 @@ msgid "email.reset.title"
|
|||||||
msgstr "Reset your password"
|
msgstr "Reset your password"
|
||||||
|
|
||||||
#: src/components/chat/PendingItem.tsx:86
|
#: src/components/chat/PendingItem.tsx:86
|
||||||
#: src/pages/Dump.tsx:323
|
#: src/pages/Dump.tsx:371
|
||||||
#: src/pages/DumpEdit.tsx:227
|
#: src/pages/DumpEdit.tsx:227
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
msgstr "Retry"
|
msgstr "Retry"
|
||||||
@@ -1646,7 +1647,7 @@ msgstr "Role"
|
|||||||
#: src/components/playlist/PlaylistEditForm.tsx:229
|
#: src/components/playlist/PlaylistEditForm.tsx:229
|
||||||
#: src/components/profile/DescriptionEditor.tsx:89
|
#: src/components/profile/DescriptionEditor.tsx:89
|
||||||
#: src/components/profile/EmailEditor.tsx:75
|
#: src/components/profile/EmailEditor.tsx:75
|
||||||
#: src/pages/Dump.tsx:438
|
#: src/pages/Dump.tsx:486
|
||||||
#: src/pages/DumpEdit.tsx:564
|
#: src/pages/DumpEdit.tsx:564
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Save"
|
msgstr "Save"
|
||||||
@@ -1656,14 +1657,14 @@ msgstr "Save"
|
|||||||
#: src/components/profile/ChangePasswordModal.tsx:119
|
#: src/components/profile/ChangePasswordModal.tsx:119
|
||||||
#: src/components/profile/DescriptionEditor.tsx:86
|
#: src/components/profile/DescriptionEditor.tsx:86
|
||||||
#: src/components/profile/EmailEditor.tsx:72
|
#: src/components/profile/EmailEditor.tsx:72
|
||||||
#: src/pages/Dump.tsx:437
|
#: src/pages/Dump.tsx:485
|
||||||
#: src/pages/ResetPassword.tsx:191
|
#: src/pages/ResetPassword.tsx:191
|
||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Saving…"
|
msgstr "Saving…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:182
|
#: src/components/layout/AppHeader.tsx:182
|
||||||
#: src/components/layout/SearchBar.tsx:83
|
#: src/components/layout/SearchBar.tsx:83
|
||||||
#: src/pages/Search.tsx:87
|
#: src/pages/Search.tsx:90
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr "Search"
|
msgstr "Search"
|
||||||
|
|
||||||
@@ -1679,11 +1680,11 @@ msgstr "Search emoji"
|
|||||||
msgid "Search emoji…"
|
msgid "Search emoji…"
|
||||||
msgstr "Search emoji…"
|
msgstr "Search emoji…"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:215
|
#: src/pages/Search.tsx:241
|
||||||
msgid "Search failed"
|
msgid "Search failed"
|
||||||
msgstr "Search failed"
|
msgstr "Search failed"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:210
|
#: src/pages/Search.tsx:236
|
||||||
msgid "Searching…"
|
msgid "Searching…"
|
||||||
msgstr "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/form/FormError.tsx:31
|
||||||
#: src/components/layout/PageError.tsx:29
|
#: src/components/layout/PageError.tsx:29
|
||||||
#: src/contexts/ws/WSProvider.tsx:732
|
#: src/contexts/ws/WSProvider.tsx:733
|
||||||
#: src/pages/PlaylistDetail.tsx:155
|
#: src/pages/PlaylistDetail.tsx:155
|
||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Something went wrong"
|
msgstr "Something went wrong"
|
||||||
@@ -1799,7 +1800,7 @@ msgstr "the chat"
|
|||||||
msgid "the chat"
|
msgid "the chat"
|
||||||
msgstr "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."
|
msgid "The dump couldn't be removed. Try again."
|
||||||
msgstr "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"
|
msgid "email.digest.monthly.subject"
|
||||||
msgstr "The month on {site}, {range}"
|
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."
|
msgid "The new order couldn't be saved. Try again."
|
||||||
msgstr "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."
|
msgid "This comment couldn't be deleted. Try again."
|
||||||
msgstr "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
|
#: src/pages/UserRegister.tsx:122
|
||||||
msgid "This invite link is missing, expired, or already used."
|
msgid "This invite link is missing, expired, or already used."
|
||||||
msgstr "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"
|
msgid "Username"
|
||||||
msgstr "Username"
|
msgstr "Username"
|
||||||
|
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:143
|
#: src/components/profile/ProfileFollowTab.tsx:147
|
||||||
#: src/pages/Search.tsx:150
|
#: src/pages/Search.tsx:176
|
||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "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."
|
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."
|
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."
|
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."
|
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"
|
msgid "email.welcome.reason"
|
||||||
msgstr "You're getting this email because an account was created on {site} with this address."
|
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"
|
msgid "You're offline — not sent"
|
||||||
msgstr "You're offline — not sent"
|
msgstr "You're offline — not sent"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
119
locales/fr.po
119
locales/fr.po
@@ -60,7 +60,7 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
|
|||||||
msgid "email.digest.totals"
|
msgid "email.digest.totals"
|
||||||
msgstr "{dumps, plural, one {# reco} other {# recos}}, {upvotes, plural, one {# vote} other {# votes}} et {comments, plural, one {# commentaire} other {# commentaires}}."
|
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})"
|
msgid "{label} ({count})"
|
||||||
msgstr "{label} ({count})"
|
msgstr "{label} ({count})"
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ msgstr "{n, plural, one {vote} other {votes}}"
|
|||||||
msgid "email.digest.more"
|
msgid "email.digest.more"
|
||||||
msgstr "{n} de plus dans votre fil Suivi"
|
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}}"
|
msgid "{totalUnread, plural, one {# new} other {# new}}"
|
||||||
msgstr "{totalUnread, plural, one {# nouvelle} other {# nouvelles}}"
|
msgstr "{totalUnread, plural, one {# nouvelle} other {# nouvelles}}"
|
||||||
|
|
||||||
@@ -211,8 +211,9 @@ msgstr "# Bienvenue sur {site} !\n"
|
|||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
msgstr "← Retour"
|
msgstr "← Retour"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:330
|
#: src/pages/Dump.tsx:353
|
||||||
#: src/pages/Dump.tsx:557
|
#: src/pages/Dump.tsx:378
|
||||||
|
#: src/pages/Dump.tsx:605
|
||||||
#: src/pages/DumpEdit.tsx:234
|
#: src/pages/DumpEdit.tsx:234
|
||||||
msgid "← Back to all dumps"
|
msgid "← Back to all dumps"
|
||||||
msgstr "← Retour à toutes les recos"
|
msgstr "← Retour à toutes les recos"
|
||||||
@@ -225,7 +226,7 @@ msgstr "+ Inviter quelqu'un"
|
|||||||
msgid "+ New playlist"
|
msgid "+ New playlist"
|
||||||
msgstr "+ Nouvelle collection"
|
msgstr "+ Nouvelle collection"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:404
|
#: src/pages/Dump.tsx:452
|
||||||
msgid "+ Playlist"
|
msgid "+ Playlist"
|
||||||
msgstr "+ Collection"
|
msgstr "+ Collection"
|
||||||
|
|
||||||
@@ -418,8 +419,8 @@ msgstr "par @{username}"
|
|||||||
msgid "email.digest.playlistMeta"
|
msgid "email.digest.playlistMeta"
|
||||||
msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}"
|
msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}"
|
||||||
|
|
||||||
#: src/contexts/ws/WSProvider.tsx:395
|
#: src/contexts/ws/WSProvider.tsx:376
|
||||||
#: src/contexts/ws/WSProvider.tsx:764
|
#: src/contexts/ws/WSProvider.tsx:765
|
||||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
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."
|
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/profile/EmailEditor.tsx:83
|
||||||
#: src/components/shared/ConfirmModal.tsx:63
|
#: src/components/shared/ConfirmModal.tsx:63
|
||||||
#: src/pages/Authorize.tsx:158
|
#: src/pages/Authorize.tsx:158
|
||||||
#: src/pages/Dump.tsx:446
|
#: src/pages/Dump.tsx:494
|
||||||
#: src/pages/DumpEdit.tsx:561
|
#: src/pages/DumpEdit.tsx:561
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Annuler"
|
msgstr "Annuler"
|
||||||
@@ -554,7 +555,7 @@ msgstr "Confirmer le nouveau mot de passe"
|
|||||||
msgid "Connecting…"
|
msgid "Connecting…"
|
||||||
msgstr "Connexion…"
|
msgstr "Connexion…"
|
||||||
|
|
||||||
#: src/contexts/ws/WSProvider.tsx:757
|
#: src/contexts/ws/WSProvider.tsx:758
|
||||||
msgid "Connection lost — this message may not have been sent"
|
msgid "Connection lost — this message may not have been sent"
|
||||||
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
|
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."
|
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."
|
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
|
#: src/pages/DumpEdit.tsx:555
|
||||||
msgid "Could not delete"
|
msgid "Could not delete"
|
||||||
msgstr "Suppression impossible"
|
msgstr "Suppression impossible"
|
||||||
@@ -680,7 +681,7 @@ msgstr "Échec de la suppression ({0})"
|
|||||||
|
|
||||||
#: src/components/playlist/PlaylistCard.tsx:149
|
#: src/components/playlist/PlaylistCard.tsx:149
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:213
|
#: src/components/playlist/PlaylistEditForm.tsx:213
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:208
|
#: src/components/profile/ProfilePlaylistsTab.tsx:201
|
||||||
#: src/pages/PlaylistDetail.tsx:172
|
#: src/pages/PlaylistDetail.tsx:172
|
||||||
msgid "Delete playlist"
|
msgid "Delete playlist"
|
||||||
msgstr "Supprimer la collection"
|
msgstr "Supprimer la collection"
|
||||||
@@ -697,7 +698,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible."
|
|||||||
msgid "Delete this message?"
|
msgid "Delete this message?"
|
||||||
msgstr "Supprimer ce message ?"
|
msgstr "Supprimer ce message ?"
|
||||||
|
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:207
|
#: src/components/profile/ProfilePlaylistsTab.tsx:200
|
||||||
#: src/pages/PlaylistDetail.tsx:171
|
#: src/pages/PlaylistDetail.tsx:171
|
||||||
msgid "Delete this playlist? This cannot be undone."
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
||||||
@@ -769,7 +770,7 @@ msgid "Dumped!"
|
|||||||
msgstr "Recommandé !"
|
msgstr "Recommandé !"
|
||||||
|
|
||||||
#: src/components/profile/ProfileDumpsTab.tsx:46
|
#: src/components/profile/ProfileDumpsTab.tsx:46
|
||||||
#: src/pages/Search.tsx:148
|
#: src/pages/Search.tsx:174
|
||||||
#: src/pages/UserPublicProfile.tsx:160
|
#: src/pages/UserPublicProfile.tsx:160
|
||||||
msgid "Dumps"
|
msgid "Dumps"
|
||||||
msgstr "Recos"
|
msgstr "Recos"
|
||||||
@@ -788,7 +789,7 @@ msgstr "Plus tôt"
|
|||||||
#: src/components/chat/PendingItem.tsx:93
|
#: src/components/chat/PendingItem.tsx:93
|
||||||
#: src/components/comment/CommentNode.tsx:256
|
#: src/components/comment/CommentNode.tsx:256
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:60
|
#: src/components/playlist/PlaylistHeader.tsx:60
|
||||||
#: src/pages/Dump.tsx:553
|
#: src/pages/Dump.tsx:601
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Modifier"
|
msgstr "Modifier"
|
||||||
|
|
||||||
@@ -806,7 +807,7 @@ msgid "Edit email"
|
|||||||
msgstr "Modifier l'e-mail"
|
msgstr "Modifier l'e-mail"
|
||||||
|
|
||||||
#: src/components/dump/create/TitleField.tsx:69
|
#: src/components/dump/create/TitleField.tsx:69
|
||||||
#: src/pages/Dump.tsx:463
|
#: src/pages/Dump.tsx:511
|
||||||
msgid "Edit title"
|
msgid "Edit title"
|
||||||
msgstr "Modifier le titre"
|
msgstr "Modifier le titre"
|
||||||
|
|
||||||
@@ -820,7 +821,7 @@ msgstr "modifié"
|
|||||||
#: src/components/chat/ChatMessageItem.tsx:132
|
#: src/components/chat/ChatMessageItem.tsx:132
|
||||||
#: src/components/comment/CommentNode.tsx:205
|
#: src/components/comment/CommentNode.tsx:205
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:95
|
#: src/components/playlist/PlaylistHeader.tsx:95
|
||||||
#: src/pages/Dump.tsx:506
|
#: src/pages/Dump.tsx:554
|
||||||
msgid "edited {0}"
|
msgid "edited {0}"
|
||||||
msgstr "modifié {0}"
|
msgstr "modifié {0}"
|
||||||
|
|
||||||
@@ -830,7 +831,7 @@ msgstr "modifié {0}"
|
|||||||
#: src/components/chat/ChatMessageItem.tsx:128
|
#: src/components/chat/ChatMessageItem.tsx:128
|
||||||
#: src/components/comment/CommentNode.tsx:203
|
#: src/components/comment/CommentNode.tsx:203
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:93
|
#: src/components/playlist/PlaylistHeader.tsx:93
|
||||||
#: src/pages/Dump.tsx:504
|
#: src/pages/Dump.tsx:552
|
||||||
msgid "Edited {0}"
|
msgid "Edited {0}"
|
||||||
msgstr "Modifié le {0}"
|
msgstr "Modifié le {0}"
|
||||||
|
|
||||||
@@ -851,7 +852,7 @@ msgstr "E-mail"
|
|||||||
msgid "Email address"
|
msgid "Email address"
|
||||||
msgstr "Adresse e-mail"
|
msgstr "Adresse e-mail"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:204
|
#: src/pages/Search.tsx:230
|
||||||
msgid "Enter a query to search."
|
msgid "Enter a query to search."
|
||||||
msgstr "Saisissez une recherche."
|
msgstr "Saisissez une recherche."
|
||||||
|
|
||||||
@@ -942,16 +943,16 @@ msgstr "Suivre {targetUsername}"
|
|||||||
msgid "Follow playlist"
|
msgid "Follow playlist"
|
||||||
msgstr "Suivre la collection"
|
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."
|
msgid "Follow some public playlists to see their dumps here."
|
||||||
msgstr "Suivez des collections publiques pour voir leurs recos ici."
|
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."
|
msgid "Follow some users to see their dumps here."
|
||||||
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
||||||
|
|
||||||
#: src/components/feed/FeedTabBar.tsx:61
|
#: 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/Settings.tsx:57
|
||||||
#: src/pages/UserPublicProfile.tsx:163
|
#: src/pages/UserPublicProfile.tsx:163
|
||||||
msgid "Followed"
|
msgid "Followed"
|
||||||
@@ -971,7 +972,7 @@ msgstr "Pour vous"
|
|||||||
msgid "Forgot password?"
|
msgid "Forgot password?"
|
||||||
msgstr "Mot de passe oublié ?"
|
msgstr "Mot de passe oublié ?"
|
||||||
|
|
||||||
#: src/components/feed/FollowedFeed.tsx:137
|
#: src/components/feed/FollowedFeed.tsx:165
|
||||||
msgid "From people"
|
msgid "From people"
|
||||||
msgstr "De personnes"
|
msgstr "De personnes"
|
||||||
|
|
||||||
@@ -980,7 +981,7 @@ msgstr "De personnes"
|
|||||||
msgid "email.digest.network"
|
msgid "email.digest.network"
|
||||||
msgstr "De ceux que vous suivez"
|
msgstr "De ceux que vous suivez"
|
||||||
|
|
||||||
#: src/components/feed/FollowedFeed.tsx:138
|
#: src/components/feed/FollowedFeed.tsx:166
|
||||||
msgid "From playlists"
|
msgid "From playlists"
|
||||||
msgstr "De collections"
|
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"
|
msgid "image"
|
||||||
msgstr "image"
|
msgstr "image"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:573
|
#: src/pages/Dump.tsx:621
|
||||||
msgid "In collections"
|
msgid "In collections"
|
||||||
msgstr "Dans les collections"
|
msgstr "Dans les collections"
|
||||||
|
|
||||||
@@ -1109,7 +1110,7 @@ msgstr "Lien"
|
|||||||
msgid "Link or file"
|
msgid "Link or file"
|
||||||
msgstr "Lien ou fichier"
|
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…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||||
|
|
||||||
@@ -1125,7 +1126,7 @@ msgstr "Charger plus"
|
|||||||
msgid "Load older messages"
|
msgid "Load older messages"
|
||||||
msgstr "Charger les messages plus anciens"
|
msgstr "Charger les messages plus anciens"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:306
|
#: src/pages/Dump.tsx:337
|
||||||
#: src/pages/DumpEdit.tsx:210
|
#: src/pages/DumpEdit.tsx:210
|
||||||
msgid "Loading dump…"
|
msgid "Loading dump…"
|
||||||
msgstr "Chargement de la reco…"
|
msgstr "Chargement de la reco…"
|
||||||
@@ -1271,7 +1272,7 @@ msgstr "Piste suivante"
|
|||||||
msgid "No dumps in this playlist yet."
|
msgid "No dumps in this playlist yet."
|
||||||
msgstr "Aucune reco dans cette collection pour l'instant."
|
msgstr "Aucune reco dans cette collection pour l'instant."
|
||||||
|
|
||||||
#: src/pages/Search.tsx:220
|
#: src/pages/Search.tsx:246
|
||||||
msgid "No dumps match \"{q}\"."
|
msgid "No dumps match \"{q}\"."
|
||||||
msgstr "Aucune reco ne correspond à « {q} »."
|
msgstr "Aucune reco ne correspond à « {q} »."
|
||||||
|
|
||||||
@@ -1284,7 +1285,7 @@ msgstr "Pas encore de recos. Soyez le premier !"
|
|||||||
msgid "No emoji found."
|
msgid "No emoji found."
|
||||||
msgstr "Aucun emoji trouvé."
|
msgstr "Aucun emoji trouvé."
|
||||||
|
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:113
|
#: src/components/profile/ProfileFollowTab.tsx:117
|
||||||
msgid "No followed playlists yet."
|
msgid "No followed playlists yet."
|
||||||
msgstr "Pas encore de collections suivies."
|
msgstr "Pas encore de collections suivies."
|
||||||
|
|
||||||
@@ -1300,21 +1301,21 @@ msgstr "Aucun message pour l'instant. Dites bonjour !"
|
|||||||
msgid "No one yet."
|
msgid "No one yet."
|
||||||
msgstr "Personne pour le moment."
|
msgstr "Personne pour le moment."
|
||||||
|
|
||||||
#: src/pages/Search.tsx:261
|
#: src/pages/Search.tsx:287
|
||||||
msgid "No playlists match \"{q}\"."
|
msgid "No playlists match \"{q}\"."
|
||||||
msgstr "Aucune collection ne correspond à « {q} »."
|
msgstr "Aucune collection ne correspond à « {q} »."
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:185
|
#: src/components/profile/ProfilePlaylistsTab.tsx:178
|
||||||
#: src/pages/Playlists.tsx:118
|
#: src/pages/Playlists.tsx:126
|
||||||
msgid "No playlists yet."
|
msgid "No playlists yet."
|
||||||
msgstr "Pas encore de collections."
|
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"
|
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é"
|
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}\"."
|
msgid "No users match \"{q}\"."
|
||||||
msgstr "Aucun utilisateur ne correspond à « {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/ProfileDumpsTab.tsx:97
|
||||||
#: src/components/profile/ProfileOverviewTab.tsx:47
|
#: src/components/profile/ProfileOverviewTab.tsx:47
|
||||||
#: src/pages/Notifications.tsx:522
|
#: src/pages/Notifications.tsx:513
|
||||||
msgid "Nothing here yet."
|
msgid "Nothing here yet."
|
||||||
msgstr "Rien ici pour l'instant."
|
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:387
|
||||||
#: src/pages/Notifications.tsx:505
|
#: src/pages/Notifications.tsx:496
|
||||||
#: src/pages/Settings.tsx:163
|
#: src/pages/Settings.tsx:163
|
||||||
msgid "Notifications"
|
msgid "Notifications"
|
||||||
msgstr "Notifications"
|
msgstr "Notifications"
|
||||||
|
|
||||||
#: src/components/layout/NotificationBell.tsx:56
|
#: src/components/layout/NotificationBell.tsx:48
|
||||||
msgid "Notifications ({unreadNotificationCount} unread)"
|
msgid "Notifications ({unreadNotificationCount} unread)"
|
||||||
msgstr "Notifications ({unreadNotificationCount} non lues)"
|
msgstr "Notifications ({unreadNotificationCount} non lues)"
|
||||||
|
|
||||||
@@ -1469,11 +1470,11 @@ msgstr "Catégories de collections"
|
|||||||
#: src/components/dump/create/StepIndicator.tsx:21
|
#: src/components/dump/create/StepIndicator.tsx:21
|
||||||
#: src/components/layout/AppHeader.tsx:172
|
#: src/components/layout/AppHeader.tsx:172
|
||||||
#: src/components/layout/UserMenu.tsx:92
|
#: src/components/layout/UserMenu.tsx:92
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:144
|
#: src/components/profile/ProfileFollowTab.tsx:148
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:156
|
#: src/components/profile/ProfilePlaylistsTab.tsx:149
|
||||||
#: src/pages/Playlists.tsx:52
|
#: src/pages/Playlists.tsx:53
|
||||||
#: src/pages/Playlists.tsx:106
|
#: src/pages/Playlists.tsx:114
|
||||||
#: src/pages/Search.tsx:151
|
#: src/pages/Search.tsx:177
|
||||||
#: src/pages/UserPublicProfile.tsx:162
|
#: src/pages/UserPublicProfile.tsx:162
|
||||||
msgid "Playlists"
|
msgid "Playlists"
|
||||||
msgstr "Collections"
|
msgstr "Collections"
|
||||||
@@ -1501,7 +1502,7 @@ msgstr "Piste précédente"
|
|||||||
#: src/components/playlist/PlaylistCard.tsx:106
|
#: src/components/playlist/PlaylistCard.tsx:106
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:77
|
#: src/components/playlist/PlaylistHeader.tsx:77
|
||||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
||||||
#: src/pages/Dump.tsx:512
|
#: src/pages/Dump.tsx:560
|
||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "privé"
|
msgstr "privé"
|
||||||
|
|
||||||
@@ -1544,7 +1545,7 @@ msgstr "Inscription…"
|
|||||||
msgid "Registration failed"
|
msgid "Registration failed"
|
||||||
msgstr "Inscription échouée"
|
msgstr "Inscription échouée"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:564
|
#: src/pages/Dump.tsx:612
|
||||||
msgid "Related"
|
msgid "Related"
|
||||||
msgstr "Connexe"
|
msgstr "Connexe"
|
||||||
|
|
||||||
@@ -1631,7 +1632,7 @@ msgid "email.reset.title"
|
|||||||
msgstr "Réinitialiser votre mot de passe"
|
msgstr "Réinitialiser votre mot de passe"
|
||||||
|
|
||||||
#: src/components/chat/PendingItem.tsx:86
|
#: src/components/chat/PendingItem.tsx:86
|
||||||
#: src/pages/Dump.tsx:323
|
#: src/pages/Dump.tsx:371
|
||||||
#: src/pages/DumpEdit.tsx:227
|
#: src/pages/DumpEdit.tsx:227
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
msgstr "Réessayer"
|
msgstr "Réessayer"
|
||||||
@@ -1646,7 +1647,7 @@ msgstr "Rôle"
|
|||||||
#: src/components/playlist/PlaylistEditForm.tsx:229
|
#: src/components/playlist/PlaylistEditForm.tsx:229
|
||||||
#: src/components/profile/DescriptionEditor.tsx:89
|
#: src/components/profile/DescriptionEditor.tsx:89
|
||||||
#: src/components/profile/EmailEditor.tsx:75
|
#: src/components/profile/EmailEditor.tsx:75
|
||||||
#: src/pages/Dump.tsx:438
|
#: src/pages/Dump.tsx:486
|
||||||
#: src/pages/DumpEdit.tsx:564
|
#: src/pages/DumpEdit.tsx:564
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Enregistrer"
|
msgstr "Enregistrer"
|
||||||
@@ -1656,14 +1657,14 @@ msgstr "Enregistrer"
|
|||||||
#: src/components/profile/ChangePasswordModal.tsx:119
|
#: src/components/profile/ChangePasswordModal.tsx:119
|
||||||
#: src/components/profile/DescriptionEditor.tsx:86
|
#: src/components/profile/DescriptionEditor.tsx:86
|
||||||
#: src/components/profile/EmailEditor.tsx:72
|
#: src/components/profile/EmailEditor.tsx:72
|
||||||
#: src/pages/Dump.tsx:437
|
#: src/pages/Dump.tsx:485
|
||||||
#: src/pages/ResetPassword.tsx:191
|
#: src/pages/ResetPassword.tsx:191
|
||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Enregistrement…"
|
msgstr "Enregistrement…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:182
|
#: src/components/layout/AppHeader.tsx:182
|
||||||
#: src/components/layout/SearchBar.tsx:83
|
#: src/components/layout/SearchBar.tsx:83
|
||||||
#: src/pages/Search.tsx:87
|
#: src/pages/Search.tsx:90
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr "Rechercher"
|
msgstr "Rechercher"
|
||||||
|
|
||||||
@@ -1679,11 +1680,11 @@ msgstr "Rechercher un emoji"
|
|||||||
msgid "Search emoji…"
|
msgid "Search emoji…"
|
||||||
msgstr "Rechercher un emoji…"
|
msgstr "Rechercher un emoji…"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:215
|
#: src/pages/Search.tsx:241
|
||||||
msgid "Search failed"
|
msgid "Search failed"
|
||||||
msgstr "Recherche échouée"
|
msgstr "Recherche échouée"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:210
|
#: src/pages/Search.tsx:236
|
||||||
msgid "Searching…"
|
msgid "Searching…"
|
||||||
msgstr "Recherche…"
|
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/form/FormError.tsx:31
|
||||||
#: src/components/layout/PageError.tsx:29
|
#: src/components/layout/PageError.tsx:29
|
||||||
#: src/contexts/ws/WSProvider.tsx:732
|
#: src/contexts/ws/WSProvider.tsx:733
|
||||||
#: src/pages/PlaylistDetail.tsx:155
|
#: src/pages/PlaylistDetail.tsx:155
|
||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Une erreur est survenue"
|
msgstr "Une erreur est survenue"
|
||||||
@@ -1799,7 +1800,7 @@ msgstr "la tribune"
|
|||||||
msgid "the chat"
|
msgid "the chat"
|
||||||
msgstr "la tribune"
|
msgstr "la tribune"
|
||||||
|
|
||||||
#: src/hooks/playlist/usePlaylist.ts:182
|
#: src/hooks/playlist/usePlaylist.ts:177
|
||||||
msgid "The dump couldn't be removed. Try again."
|
msgid "The dump couldn't be removed. Try again."
|
||||||
msgstr "Le dump n'a pas pu être retiré. Réessayez."
|
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"
|
msgid "email.digest.monthly.subject"
|
||||||
msgstr "Le mois sur {site}, {range}"
|
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."
|
msgid "The new order couldn't be saved. Try again."
|
||||||
msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
|
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."
|
msgid "This comment couldn't be deleted. Try again."
|
||||||
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
|
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
|
#: src/pages/UserRegister.tsx:122
|
||||||
msgid "This invite link is missing, expired, or already used."
|
msgid "This invite link is missing, expired, or already used."
|
||||||
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
|
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
|
||||||
@@ -2035,8 +2040,8 @@ msgstr "Menu utilisateur"
|
|||||||
msgid "Username"
|
msgid "Username"
|
||||||
msgstr "Nom d'utilisateur"
|
msgstr "Nom d'utilisateur"
|
||||||
|
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:143
|
#: src/components/profile/ProfileFollowTab.tsx:147
|
||||||
#: src/pages/Search.tsx:150
|
#: src/pages/Search.tsx:176
|
||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Utilisateurs"
|
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."
|
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."
|
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."
|
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."
|
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"
|
msgid "email.welcome.reason"
|
||||||
msgstr "Vous recevez cet e-mail car un compte {site} a été créé avec cette adresse."
|
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"
|
msgid "You're offline — not sent"
|
||||||
msgstr "Hors ligne — message non envoyé"
|
msgstr "Hors ligne — message non envoyé"
|
||||||
|
|
||||||
|
|||||||
59
shared/ws.ts
59
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
|
* 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
|
* public, else to its owner's. It also goes to everyone but the owner when a
|
||||||
* as `lastPlaylistEvent`, as it does the three messages below.
|
* 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 {
|
export interface PlaylistCreatedMessage {
|
||||||
type: "playlist_created";
|
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
|
* 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'
|
* else to its owner's, as when it has just gone private.
|
||||||
* views.
|
|
||||||
*/
|
*/
|
||||||
export interface PlaylistUpdatedMessage {
|
export interface PlaylistUpdatedMessage {
|
||||||
type: "playlist_updated";
|
type: "playlist_updated";
|
||||||
playlist: Playlist;
|
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 {
|
export interface PlaylistDeletedMessage {
|
||||||
type: "playlist_deleted";
|
type: "playlist_deleted";
|
||||||
playlistId: string;
|
playlistId: string;
|
||||||
@@ -310,7 +314,8 @@ export interface PlaylistDeletedMessage {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sent by the server when dumps are added to, removed from or reordered in a
|
* 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 {
|
export interface PlaylistDumpsUpdatedMessage {
|
||||||
type: "playlist_dumps_updated";
|
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
|
* Sent by the server to every socket after a profile edit, a rename, a role
|
||||||
* avatar upload. `WSProvider` refreshes the user's avatar wherever it shows.
|
* 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 {
|
export interface UserUpdatedMessage {
|
||||||
type: "user_updated";
|
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
|
* Sent by the server when a comment is posted, to every socket if its dump is
|
||||||
* dump. Comments on a private dump are never broadcast. `WSProvider` passes it
|
* public, else to the dump's poster's. `WSProvider` passes it on to the dump's
|
||||||
* on to the dump's page as `lastCommentEvent`, as it does the two messages
|
* page and the dump lists as a `comment` event, as it does the two messages
|
||||||
* below.
|
* below.
|
||||||
*/
|
*/
|
||||||
export interface CommentCreatedMessage {
|
export interface CommentCreatedMessage {
|
||||||
@@ -344,18 +351,13 @@ export interface CommentCreatedMessage {
|
|||||||
comment: Comment;
|
comment: Comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Sent by the server when a comment is edited, as when one is posted. */
|
||||||
* Sent by the server to every socket when a comment on a public dump is edited.
|
|
||||||
*/
|
|
||||||
export interface CommentUpdatedMessage {
|
export interface CommentUpdatedMessage {
|
||||||
type: "comment_updated";
|
type: "comment_updated";
|
||||||
comment: Comment;
|
comment: Comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Sent by the server when a comment is deleted, as when one is posted. */
|
||||||
* Sent by the server to every socket when a comment on a public dump is
|
|
||||||
* deleted.
|
|
||||||
*/
|
|
||||||
export interface CommentDeletedMessage {
|
export interface CommentDeletedMessage {
|
||||||
type: "comment_deleted";
|
type: "comment_deleted";
|
||||||
commentId: string;
|
commentId: string;
|
||||||
@@ -372,6 +374,23 @@ export interface NotificationCreatedMessage {
|
|||||||
notification: Notification;
|
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
|
* Sent by the server to the socket whose message failed. With `dumpId` or
|
||||||
* `commentId`, `WSProvider` undoes that optimistic vote or like at once rather
|
* `commentId`, `WSProvider` undoes that optimistic vote or like at once rather
|
||||||
@@ -478,6 +497,8 @@ export type ServerToClientMessage =
|
|||||||
| CommentUpdatedMessage
|
| CommentUpdatedMessage
|
||||||
| CommentDeletedMessage
|
| CommentDeletedMessage
|
||||||
| NotificationCreatedMessage
|
| NotificationCreatedMessage
|
||||||
|
| NotificationsReadMessage
|
||||||
|
| CategoriesChangedMessage
|
||||||
| ErrorMessage
|
| ErrorMessage
|
||||||
| ChatMessageMessage
|
| ChatMessageMessage
|
||||||
| ChatMessageUpdatedMessage
|
| ChatMessageUpdatedMessage
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { CommentNode } from "./CommentNode.tsx";
|
|||||||
/**
|
/**
|
||||||
* Passed to {@link CommentThread} by the `Dump` page, which holds the comments.
|
* 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
|
* 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.
|
* both ways and must be applied once.
|
||||||
*/
|
*/
|
||||||
export interface CommentThreadProps {
|
export interface CommentThreadProps {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import type { DumpFeedState } from "./DumpFeed.tsx";
|
|||||||
import { API_URL } from "../../config/api.ts";
|
import { API_URL } from "../../config/api.ts";
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
import { useDumpListSync } from "../../hooks/dump/useDumpListSync.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 { useFeedCache } from "../../hooks/feed/useFeedCache.ts";
|
||||||
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
|
||||||
import { usePagedList } from "../../hooks/feed/usePagedList.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=<view>`, and the call
|
* Returns one view's feed, from `GET /api/dumps?follow=<view>`, and the call
|
||||||
* that loads its next page. A view cached like the index's feed starts loaded
|
* 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(
|
function useFollowedView(
|
||||||
section: FollowView,
|
section: FollowView,
|
||||||
@@ -48,14 +55,35 @@ function useFollowedView(
|
|||||||
`feed:followed-${section}:${categorySlug ?? "all"}`,
|
`feed:followed-${section}:${categorySlug ?? "all"}`,
|
||||||
hydrateDump,
|
hydrateDump,
|
||||||
);
|
);
|
||||||
const { state, setItems, loadMore } = usePagedList(
|
const { state, setItems, loadMore, resync } = usePagedList(
|
||||||
user ? `${url}:${user.id}` : undefined,
|
user ? `${url}:${user.id}` : undefined,
|
||||||
(page, signal) =>
|
(page, signal) =>
|
||||||
authFetch(pageUrl(url, page), { signal })
|
authFetch(pageUrl(url, page), { signal })
|
||||||
.then((res) => readPage(res, deserializeDump)),
|
.then((res) => readPage(res, deserializeDump)),
|
||||||
cached,
|
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
|
// Only the list is kept here, since `Index` saves and restores the page's
|
||||||
// scroll for every tab, this one included.
|
// scroll for every tab, this one included.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { t } from "@lingui/core/macro";
|
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";
|
import { IconBell } from "./NavIcons.tsx";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +17,7 @@ import { IconBell } from "./NavIcons.tsx";
|
|||||||
* a new one.
|
* a new one.
|
||||||
*/
|
*/
|
||||||
export function NotificationBell(): React.JSX.Element {
|
export function NotificationBell(): React.JSX.Element {
|
||||||
const { unreadNotificationCount, lastNotification } = useWS();
|
const { unreadNotificationCount } = useWS();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [ringing, setRinging] = useState(false);
|
const [ringing, setRinging] = useState(false);
|
||||||
// The ring's timers outlive the effect that starts them: a notification
|
// 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<ReturnType<typeof setTimeout>[]>([]);
|
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
const animatingRef = useRef(false);
|
const animatingRef = useRef(false);
|
||||||
useEffect(() => () => timersRef.current.forEach(clearTimeout), []);
|
useEffect(() => () => timersRef.current.forEach(clearTimeout), []);
|
||||||
// Each page mounts its own header, so a notification already in context
|
useWSEvent("notification", () => {
|
||||||
// must not ring the bell again after a navigation.
|
|
||||||
const lastSeenIdRef = useRef<string | null>(lastNotification?.id ?? null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lastNotification) return;
|
|
||||||
if (lastNotification.id === lastSeenIdRef.current) return;
|
|
||||||
lastSeenIdRef.current = lastNotification.id;
|
|
||||||
|
|
||||||
if (animatingRef.current) return;
|
if (animatingRef.current) return;
|
||||||
animatingRef.current = true;
|
animatingRef.current = true;
|
||||||
timersRef.current = [
|
timersRef.current = [
|
||||||
@@ -43,7 +35,7 @@ export function NotificationBell(): React.JSX.Element {
|
|||||||
animatingRef.current = false;
|
animatingRef.current = false;
|
||||||
}, 700),
|
}, 700),
|
||||||
];
|
];
|
||||||
}, [lastNotification]);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface ProfileFollowTabProps {
|
|||||||
* `GET /api/users/:userId/follow/<view>`, and the sentinel that loads the
|
* `GET /api/users/:userId/follow/<view>`, and the sentinel that loads the
|
||||||
* next.
|
* next.
|
||||||
*/
|
*/
|
||||||
function useFollowList<T>(
|
function useFollowList<T extends { id: string }>(
|
||||||
userId: string,
|
userId: string,
|
||||||
view: FollowView,
|
view: FollowView,
|
||||||
deserialize: (wire: Wire<T>) => T,
|
deserialize: (wire: Wire<T>) => T,
|
||||||
@@ -103,7 +103,11 @@ function FollowedPlaylists({ userId }: { userId: string }) {
|
|||||||
"playlists",
|
"playlists",
|
||||||
deserializePlaylist,
|
deserializePlaylist,
|
||||||
);
|
);
|
||||||
usePlaylistListSync(setItems, { noNewEntries: true });
|
usePlaylistListSync(
|
||||||
|
state.status === "loaded" ? state.data.items : [],
|
||||||
|
setItems,
|
||||||
|
{ noNewEntries: true },
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<ListState
|
<ListState
|
||||||
state={state}
|
state={state}
|
||||||
|
|||||||
@@ -25,11 +25,10 @@ import {
|
|||||||
hydratePlaylist,
|
hydratePlaylist,
|
||||||
} from "../../utils/shared/deserialize.ts";
|
} from "../../utils/shared/deserialize.ts";
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
import { useWS } from "../../hooks/ws/useWS.ts";
|
import { useOnReconnect } from "../../hooks/ws/useWS.ts";
|
||||||
import { useFeedCache } from "../../hooks/feed/useFeedCache.ts";
|
import { useFeedCache } from "../../hooks/feed/useFeedCache.ts";
|
||||||
import { usePagedList } from "../../hooks/feed/usePagedList.ts";
|
import { usePagedList } from "../../hooks/feed/usePagedList.ts";
|
||||||
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
|
||||||
import { usePositionAwareSync } from "../../hooks/feed/usePositionAwareSync.ts";
|
|
||||||
import {
|
import {
|
||||||
useScrollRestore,
|
useScrollRestore,
|
||||||
useScrollSave,
|
useScrollSave,
|
||||||
@@ -86,7 +85,6 @@ function PlaylistList(
|
|||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
const [, setParams] = useSearchParams();
|
const [, setParams] = useSearchParams();
|
||||||
const { user: me, authFetch } = useAuth();
|
const { user: me, authFetch } = useAuth();
|
||||||
const { lastPlaylistEvent } = useWS();
|
|
||||||
const { categories } = useCategories("playlist");
|
const { categories } = useCategories("playlist");
|
||||||
const { cached, saveState } = useFeedCache<Playlist>(
|
const { cached, saveState } = useFeedCache<Playlist>(
|
||||||
`feed:profile-playlists:${user.id}${
|
`feed:profile-playlists:${user.id}${
|
||||||
@@ -97,7 +95,9 @@ function PlaylistList(
|
|||||||
const url = `${userApiUrl(user, "playlists")}${
|
const url = `${userApiUrl(user, "playlists")}${
|
||||||
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
||||||
}`;
|
}`;
|
||||||
const { state, setItems: setAllItems, loadMore } = usePagedList<Playlist>(
|
const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
|
||||||
|
Playlist
|
||||||
|
>(
|
||||||
`${url}:${me?.id ?? "guest"}`,
|
`${url}:${me?.id ?? "guest"}`,
|
||||||
useCallback(
|
useCallback(
|
||||||
(page: number, signal?: AbortSignal) =>
|
(page: number, signal?: AbortSignal) =>
|
||||||
@@ -113,18 +113,11 @@ function PlaylistList(
|
|||||||
categories.find((c) => c.slug === categorySlug)?.id;
|
categories.find((c) => c.slug === categorySlug)?.id;
|
||||||
const setItems = useCategoryFilter(setAllItems, categoryId || undefined);
|
const setItems = useCategoryFilter(setAllItems, categoryId || undefined);
|
||||||
|
|
||||||
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
|
usePlaylistListSync(loaded?.items ?? [], setItems, {
|
||||||
? (lastPlaylistEvent.playlist ?? null)
|
isOwner: isOwnProfile,
|
||||||
: null;
|
ownerId: user.id,
|
||||||
// Before usePlaylistListSync: it saves a playlist's place before the sync
|
});
|
||||||
// takes it out, and puts it back there when it turns public again.
|
useOnReconnect(resync);
|
||||||
usePositionAwareSync(
|
|
||||||
loaded?.items ?? [],
|
|
||||||
setItems,
|
|
||||||
lastPlaylistItem,
|
|
||||||
(p) => p.isPublic && p.userId === user.id,
|
|
||||||
);
|
|
||||||
usePlaylistListSync(setItems, { isOwner: isOwnProfile, ownerId: user.id });
|
|
||||||
|
|
||||||
const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded));
|
const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded));
|
||||||
useScrollSave(
|
useScrollSave(
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Spares every page fetching the site's categories: it loads both kinds once
|
* Spares every page fetching the site's categories: it loads both kinds once
|
||||||
* for the whole app, and again when the category manager changes them. `App`
|
* for the whole app, and again whenever the category manager changes them,
|
||||||
* mounts it.
|
* here or in another browser, and after the socket reconnects. `App` mounts
|
||||||
|
* it inside `WSProvider`.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
@@ -22,6 +23,7 @@ import type { Category } from "../../../shared/category.ts";
|
|||||||
import { deserializeCategory } from "../../utils/shared/deserialize.ts";
|
import { deserializeCategory } from "../../utils/shared/deserialize.ts";
|
||||||
import type { Wire } from "../../../shared/api.ts";
|
import type { Wire } from "../../../shared/api.ts";
|
||||||
import { expectOk } from "../../utils/shared/api.ts";
|
import { expectOk } from "../../utils/shared/api.ts";
|
||||||
|
import { useOnReconnect, useWSEvent } from "../../hooks/ws/useWS.ts";
|
||||||
|
|
||||||
// A failed first fetch waits this long before it is tried again.
|
// A failed first fetch waits this long before it is tried again.
|
||||||
const RETRY_MS = 10_000;
|
const RETRY_MS = 10_000;
|
||||||
@@ -81,6 +83,10 @@ export function CategoriesProvider(
|
|||||||
}, [load, attempt]);
|
}, [load, attempt]);
|
||||||
|
|
||||||
const refresh = useCallback(() => load().catch(() => {}), [load]);
|
const refresh = useCallback(() => load().catch(() => {}), [load]);
|
||||||
|
// `categories_changed` reaches the manager's own tab too, which refreshed
|
||||||
|
// already: one fetch more.
|
||||||
|
useWSEvent("categories", refresh);
|
||||||
|
useOnReconnect(refresh);
|
||||||
|
|
||||||
const value: CategoriesContextValue = useMemo(
|
const value: CategoriesContextValue = useMemo(
|
||||||
() => ({ categories, isLoaded, refresh }),
|
() => ({ categories, isLoaded, refresh }),
|
||||||
|
|||||||
@@ -29,14 +29,16 @@ const RETRY_MS = 10_000;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides {@link FollowContext}. It fetches the follows whenever the token
|
* Provides {@link FollowContext}. It fetches the follows whenever the token
|
||||||
* changes, and again when `follows_changed` says another tab changed them. It
|
* changes, again when `follows_changed` says another tab changed them, and
|
||||||
* empties them on sign-out.
|
* after the socket reconnects. It empties them on sign-out.
|
||||||
*/
|
*/
|
||||||
export function FollowProvider(
|
export function FollowProvider(
|
||||||
{ children }: FollowProviderProps,
|
{ children }: FollowProviderProps,
|
||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
const { token, authFetch } = useAuth();
|
const { token, authFetch } = useAuth();
|
||||||
const { followsVersion } = useWS();
|
// A reconnection fetches them again too: a `follows_changed` may have been
|
||||||
|
// missed while the socket was down.
|
||||||
|
const { followsVersion, connectionEpoch } = useWS();
|
||||||
const [followedUserIds, setFollowedUserIds] = useState<Set<string>>(
|
const [followedUserIds, setFollowedUserIds] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
@@ -82,7 +84,7 @@ export function FollowProvider(
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
clearTimeout(retry);
|
clearTimeout(retry);
|
||||||
};
|
};
|
||||||
}, [token, followsVersion, attempt]);
|
}, [token, followsVersion, connectionEpoch, attempt]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds or removes `id` in a set at once, sends the request, and undoes the
|
* Adds or removes `id` in a set at once, sends the request, and undoes the
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Live updates from the server reach the pages through the three contexts
|
* Live updates from the server reach the pages through the contexts declared
|
||||||
* declared here: votes and likes, dumps, playlists, comments, profiles and
|
* here. Three hold state: votes, likes, deletions and counts in one, the chat
|
||||||
* notifications in one, the chat in another, who is online in the third. They
|
* in another, who is online in the third, split so that a feed doesn't
|
||||||
* are split so that a feed doesn't re-render with every chat message or
|
* re-render with every chat message or presence change. The fourth passes on
|
||||||
* presence change. `WSProvider` fills them from its WebSocket, and components
|
* events, each change to a dump, playlist, comment or profile and each
|
||||||
* read them with `useWS`, `useWSChat` and `useWSPresence`.
|
* notification, to the components that subscribe to their kind. `WSProvider`
|
||||||
|
* fills them all from its WebSocket, and components read them with `useWS`,
|
||||||
|
* `useWSChat`, `useWSPresence` and `useWSEvent`.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
@@ -20,7 +22,7 @@ import type { PublicUser } from "../../../shared/user.ts";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps a user's upvoted list live, on their profile's overview and tab.
|
* Keeps a user's upvoted list live, on their profile's overview and tab.
|
||||||
* `WSProvider` sets it as `lastVoteEvent` on each `votes_update`, and
|
* `WSProvider` emits it as a `vote` event on each `votes_update`, and
|
||||||
* `useUpvotedList` adds or drops the dump.
|
* `useUpvotedList` adds or drops the dump.
|
||||||
*/
|
*/
|
||||||
export interface VoteEvent {
|
export interface VoteEvent {
|
||||||
@@ -31,7 +33,7 @@ export interface VoteEvent {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps the playlists on screen up to date, in lists, on a playlist's page and
|
* Keeps the playlists on screen up to date, in lists, on a playlist's page and
|
||||||
* on a dump's page. `WSProvider` sets it as `lastPlaylistEvent` on each
|
* on a dump's page. `WSProvider` emits it as a `playlist` event on each
|
||||||
* `playlist_*` message; lists apply it through `usePlaylistListSync`, a
|
* `playlist_*` message; lists apply it through `usePlaylistListSync`, a
|
||||||
* playlist's page through `usePlaylist`. Which fields are set depends on
|
* playlist's page through `usePlaylist`. Which fields are set depends on
|
||||||
* `type`.
|
* `type`.
|
||||||
@@ -53,9 +55,9 @@ export interface PlaylistEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps comment threads and comment counts live. `WSProvider` sets it as
|
* Keeps comment threads and comment counts live. `WSProvider` emits it as a
|
||||||
* `lastCommentEvent` on each `comment_*` message; the `Dump` page applies it
|
* `comment` event on each `comment_*` message; the `Dump` page applies it to
|
||||||
* to its thread, and `useDumpListSync` to the counts on dump cards.
|
* its thread, and `useDumpListSync` to the counts on dump cards.
|
||||||
*/
|
*/
|
||||||
export interface CommentEvent {
|
export interface CommentEvent {
|
||||||
type: "created" | "deleted" | "updated";
|
type: "created" | "deleted" | "updated";
|
||||||
@@ -67,14 +69,76 @@ export interface CommentEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps a profile page live while its user edits their profile. `WSProvider`
|
* Keeps every dump on screen up to date. `WSProvider` emits it as a `dump`
|
||||||
* sets it as `lastUserEvent` on each `user_updated`, which the server sends on
|
* event on each `dump_created` and `dump_updated`; `useDumpListSync` applies
|
||||||
* any change to a profile, and `useProfile` applies it.
|
* it to lists, and the `Dump` page to its dump. A dump that turns public
|
||||||
|
* comes `created` to those who couldn't see it.
|
||||||
|
*/
|
||||||
|
export interface DumpEvent {
|
||||||
|
type: "created" | "updated";
|
||||||
|
dump: Dump;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a user's profile, and their name on what they posted, up to date.
|
||||||
|
* `WSProvider` emits it as a `user` event on each `user_updated`, which the
|
||||||
|
* server sends on any change to a profile; `useProfile` applies it to the
|
||||||
|
* profile page, and the list sync hooks to the names on cards.
|
||||||
*/
|
*/
|
||||||
export interface UserEvent {
|
export interface UserEvent {
|
||||||
user: PublicUser;
|
user: PublicUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events `WSProvider` passes on, by kind, to the components that
|
||||||
|
* subscribe with `useWSEvent`. Unlike the state of `WSContext`, every event
|
||||||
|
* reaches every subscriber, however many arrive before the next render.
|
||||||
|
* `categories` says the categories changed, and carries nothing.
|
||||||
|
*/
|
||||||
|
export interface WSEvents {
|
||||||
|
vote: VoteEvent;
|
||||||
|
dump: DumpEvent;
|
||||||
|
playlist: PlaylistEvent;
|
||||||
|
comment: CommentEvent;
|
||||||
|
user: UserEvent;
|
||||||
|
notification: Notification;
|
||||||
|
categories: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delivers {@link WSEvents} to their subscribers. `WSProvider` creates one
|
||||||
|
* with {@link createWSEventBus} and emits on it as messages arrive; components
|
||||||
|
* subscribe through {@link WSEventsContext}, and tests emit on their own.
|
||||||
|
*/
|
||||||
|
export interface WSEventBus {
|
||||||
|
/** Calls `handler` with each event of `kind`, until the returned call. */
|
||||||
|
subscribe: <K extends keyof WSEvents>(
|
||||||
|
kind: K,
|
||||||
|
handler: (event: WSEvents[K]) => void,
|
||||||
|
) => () => void;
|
||||||
|
/** Calls the handlers of `kind` now, in the order they subscribed. */
|
||||||
|
emit: <K extends keyof WSEvents>(kind: K, event: WSEvents[K]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a bus with no subscribers. */
|
||||||
|
export function createWSEventBus(): WSEventBus {
|
||||||
|
const handlers = new Map<keyof WSEvents, Set<(event: never) => void>>();
|
||||||
|
return {
|
||||||
|
subscribe(kind, handler) {
|
||||||
|
let set = handlers.get(kind);
|
||||||
|
if (!set) handlers.set(kind, set = new Set());
|
||||||
|
set.add(handler);
|
||||||
|
return () => set.delete(handler);
|
||||||
|
},
|
||||||
|
emit(kind, event) {
|
||||||
|
// A copy: a handler may unsubscribe another, as a navigation does.
|
||||||
|
for (const handler of [...handlers.get(kind) ?? []]) {
|
||||||
|
(handler as (event: WSEvents[typeof kind]) => void)(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quotes the message a chat message replies to. `ChatModal` shows it in the
|
* Quotes the message a chat message replies to. `ChatModal` shows it in the
|
||||||
* reply bar once the user picks it, and it stays on the pending message so
|
* reply bar once the user picks it, and it stays on the pending message so
|
||||||
@@ -120,12 +184,11 @@ export interface ChatError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Carries everything live but the chat and presence, for pages to lay over
|
* Carries the live state but the chat and presence, for pages to lay over
|
||||||
* what they fetched. `WSProvider` provides it through {@link WSContext}, and
|
* what they fetched. `WSProvider` provides it through {@link WSContext}, and
|
||||||
* components read it with `useWS`. Each `last*Event` field holds only the
|
* components read it with `useWS`. The changes to single items come as events
|
||||||
* latest event of its kind: a consumer reacts in an effect when one changes,
|
* instead, through {@link WSEventsContext}. After a reconnect,
|
||||||
* and an event that another replaces within the same render is missed. After a
|
* `connectionEpoch` tells pages to fetch what they missed.
|
||||||
* reconnect, `connectionEpoch` tells pages to fetch what they missed.
|
|
||||||
*/
|
*/
|
||||||
export interface WSContextValue {
|
export interface WSContextValue {
|
||||||
wsStatus: "connecting" | "connected" | "disconnected";
|
wsStatus: "connecting" | "connected" | "disconnected";
|
||||||
@@ -159,27 +222,16 @@ export interface WSContextValue {
|
|||||||
* it.
|
* it.
|
||||||
*/
|
*/
|
||||||
deletedDumpIds: Set<string>;
|
deletedDumpIds: Set<string>;
|
||||||
lastVoteEvent: VoteEvent | null;
|
|
||||||
/**
|
/**
|
||||||
* Keeps a dump up to date in every list that shows it. It holds the dump as
|
* Takes deleted playlists off every list at once, as `deletedDumpIds` does
|
||||||
* its latest `dump_created` or `dump_updated` has it, and a list updates it
|
* dumps.
|
||||||
* in place, or puts it back where it was.
|
|
||||||
*/
|
*/
|
||||||
lastDumpEvent: Dump | null;
|
|
||||||
lastPlaylistEvent: PlaylistEvent | null;
|
|
||||||
deletedPlaylistIds: Set<string>;
|
deletedPlaylistIds: Set<string>;
|
||||||
lastCommentEvent: CommentEvent | null;
|
|
||||||
lastUserEvent: UserEvent | null;
|
|
||||||
/**
|
/**
|
||||||
* Drives the bell's badge. It comes from the `welcome`, then each new
|
* Drives the bell's badge. It comes from the `welcome`, then each new
|
||||||
* notification counts it up.
|
* notification counts it up, and `notifications_read` zeroes it.
|
||||||
*/
|
*/
|
||||||
unreadNotificationCount: number;
|
unreadNotificationCount: number;
|
||||||
/**
|
|
||||||
* Makes the bell ring, and puts the notification on the notifications page
|
|
||||||
* as it arrives.
|
|
||||||
*/
|
|
||||||
lastNotification: Notification | null;
|
|
||||||
/**
|
/**
|
||||||
* Tells pages to fetch again what they missed while the socket was down. It
|
* Tells pages to fetch again what they missed while the socket was down. It
|
||||||
* goes up on each reconnect, never on the first connection.
|
* goes up on each reconnect, never on the first connection.
|
||||||
@@ -318,14 +370,8 @@ export const defaultWSValue: WSContextValue = {
|
|||||||
myLikes: new Set(),
|
myLikes: new Set(),
|
||||||
recentDumps: [],
|
recentDumps: [],
|
||||||
deletedDumpIds: new Set(),
|
deletedDumpIds: new Set(),
|
||||||
lastVoteEvent: null,
|
|
||||||
lastDumpEvent: null,
|
|
||||||
lastPlaylistEvent: null,
|
|
||||||
deletedPlaylistIds: new Set(),
|
deletedPlaylistIds: new Set(),
|
||||||
lastCommentEvent: null,
|
|
||||||
lastUserEvent: null,
|
|
||||||
unreadNotificationCount: 0,
|
unreadNotificationCount: 0,
|
||||||
lastNotification: null,
|
|
||||||
connectionEpoch: 0,
|
connectionEpoch: 0,
|
||||||
followsVersion: 0,
|
followsVersion: 0,
|
||||||
castVote: () => {},
|
castVote: () => {},
|
||||||
@@ -382,3 +428,11 @@ export const WSPresenceContext: React.Context<WSPresenceContextValue> =
|
|||||||
>(
|
>(
|
||||||
defaultWSPresenceValue,
|
defaultWSPresenceValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries the bus `WSProvider` emits its events on, for `useWSEvent`. Outside
|
||||||
|
* `WSProvider`, it is a bus no one emits on.
|
||||||
|
*/
|
||||||
|
export const WSEventsContext: React.Context<
|
||||||
|
Pick<WSEventBus, "subscribe">
|
||||||
|
> = createContext<Pick<WSEventBus, "subscribe">>(createWSEventBus());
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Brings the server's live updates into the app. It holds the one WebSocket to
|
* Brings the server's live updates into the app. It holds the one WebSocket to
|
||||||
* `/ws` and turns its messages into the state of `WSContext`, `WSChatContext`,
|
* `/ws` and turns its messages into the state of `WSContext`, `WSChatContext`,
|
||||||
* `WSPresenceContext` and `AvatarOverrideContext`. `App` mounts it around every
|
* `WSPresenceContext` and `AvatarOverrideContext`, and into the events of
|
||||||
* page. The protocol is `shared/ws.ts`, and the server's side is
|
* `WSEventsContext`. `App` mounts it around every page. The protocol is
|
||||||
* `api/routes/ws.ts`.
|
* `shared/ws.ts`, and the server's side is `api/routes/ws.ts`.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
@@ -20,15 +20,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
type ChatError,
|
type ChatError,
|
||||||
type ChatReplyTarget,
|
type ChatReplyTarget,
|
||||||
type CommentEvent,
|
createWSEventBus,
|
||||||
type PendingChatMessage,
|
type PendingChatMessage,
|
||||||
type PlaylistEvent,
|
|
||||||
type UserEvent,
|
|
||||||
type VoteEvent,
|
|
||||||
WSChatContext,
|
WSChatContext,
|
||||||
type WSChatContextValue,
|
type WSChatContextValue,
|
||||||
WSContext,
|
WSContext,
|
||||||
type WSContextValue,
|
type WSContextValue,
|
||||||
|
WSEventsContext,
|
||||||
WSPresenceContext,
|
WSPresenceContext,
|
||||||
type WSPresenceContextValue,
|
type WSPresenceContextValue,
|
||||||
} from "./WSContext.ts";
|
} from "./WSContext.ts";
|
||||||
@@ -45,7 +43,6 @@ import type {
|
|||||||
OnlineUser,
|
OnlineUser,
|
||||||
ServerToClientMessage,
|
ServerToClientMessage,
|
||||||
} from "../../../shared/ws.ts";
|
} from "../../../shared/ws.ts";
|
||||||
import type { Notification } from "../../../shared/notification.ts";
|
|
||||||
import {
|
import {
|
||||||
deserializeChatMessage,
|
deserializeChatMessage,
|
||||||
deserializeComment,
|
deserializeComment,
|
||||||
@@ -215,9 +212,10 @@ function parseWSMessage(data: string): Wire<ServerToClientMessage> | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides {@link WSContext}, {@link WSChatContext}, {@link WSPresenceContext}
|
* Provides {@link WSContext}, {@link WSChatContext}, {@link WSPresenceContext},
|
||||||
* and {@link AvatarOverrideContext}. It connects with the session's token, or
|
* {@link AvatarOverrideContext} and {@link WSEventsContext}. It connects with
|
||||||
* anonymously, and reconnects with a backoff doubling up to 30 s. A new token
|
* the session's token, or anonymously, and reconnects with a backoff doubling
|
||||||
|
* up to 30 s. A new token
|
||||||
* (sign-in, sign-out, another account) clears the live state and reconnects.
|
* (sign-in, sign-out, another account) clears the live state and reconnects.
|
||||||
* A new notification or chat message chimes, and the unread counts go in the
|
* A new notification or chat message chimes, and the unread counts go in the
|
||||||
* tab title.
|
* tab title.
|
||||||
@@ -240,22 +238,12 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
const [myLikes, setMyLikes] = useState<Set<string>>(new Set());
|
const [myLikes, setMyLikes] = useState<Set<string>>(new Set());
|
||||||
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
|
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
|
||||||
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
|
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
|
||||||
const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null);
|
|
||||||
const [lastDumpEvent, setLastDumpEvent] = useState<Dump | null>(null);
|
|
||||||
const [lastPlaylistEvent, setLastPlaylistEvent] = useState<
|
|
||||||
PlaylistEvent | null
|
|
||||||
>(null);
|
|
||||||
const [deletedPlaylistIds, setDeletedPlaylistIds] = useState<Set<string>>(
|
const [deletedPlaylistIds, setDeletedPlaylistIds] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [lastUserEvent, setLastUserEvent] = useState<UserEvent | null>(null);
|
|
||||||
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
|
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
|
||||||
const [lastNotification, setLastNotification] = useState<Notification | null>(
|
// Never replaced: the subscribers keep it for as long as they are mounted.
|
||||||
null,
|
const [events] = useState(createWSEventBus);
|
||||||
);
|
|
||||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||||
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
|
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
|
||||||
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
||||||
@@ -287,14 +275,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
setMyLikes(new Set());
|
setMyLikes(new Set());
|
||||||
setRecentDumps([]);
|
setRecentDumps([]);
|
||||||
setDeletedDumpIds(new Set());
|
setDeletedDumpIds(new Set());
|
||||||
setLastVoteEvent(null);
|
|
||||||
setLastDumpEvent(null);
|
|
||||||
setLastPlaylistEvent(null);
|
|
||||||
setDeletedPlaylistIds(new Set());
|
setDeletedPlaylistIds(new Set());
|
||||||
setLastCommentEvent(null);
|
|
||||||
setLastUserEvent(null);
|
|
||||||
setUnreadNotificationCount(0);
|
setUnreadNotificationCount(0);
|
||||||
setLastNotification(null);
|
|
||||||
setChatMessages([]);
|
setChatMessages([]);
|
||||||
setDeletedChatIds(new Set());
|
setDeletedChatIds(new Set());
|
||||||
setUnreadChatCount(0);
|
setUnreadChatCount(0);
|
||||||
@@ -480,7 +462,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
|
|
||||||
case "votes_update": {
|
case "votes_update": {
|
||||||
const { dumpId, voteCount, voterId, action } = msg;
|
const { dumpId, voteCount, voterId, action } = msg;
|
||||||
setLastVoteEvent({ dumpId, voterId, action });
|
events.emit("vote", { dumpId, voterId, action });
|
||||||
// While this tab has votes on it in flight, their acks settle it.
|
// While this tab has votes on it in flight, their acks settle it.
|
||||||
if (votes.pending(dumpId)) break;
|
if (votes.pending(dumpId)) break;
|
||||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||||
@@ -531,13 +513,13 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
// The profiles put a dump back where it was when it turns public.
|
// The profiles put a dump back where it was when it turns public.
|
||||||
setLastDumpEvent(dump);
|
events.emit("dump", { type: "created", dump });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "dump_updated": {
|
case "dump_updated": {
|
||||||
const dump = deserializeDump(msg.dump);
|
const dump = deserializeDump(msg.dump);
|
||||||
setLastDumpEvent(dump);
|
events.emit("dump", { type: "updated", dump });
|
||||||
// An edit is not an arrival: it must not pull an old dump to the
|
// An edit is not an arrival: it must not pull an old dump to the
|
||||||
// top of the feeds.
|
// top of the feeds.
|
||||||
setRecentDumps((prev) =>
|
setRecentDumps((prev) =>
|
||||||
@@ -586,7 +568,17 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
case "playlist_created":
|
case "playlist_created":
|
||||||
case "playlist_updated": {
|
case "playlist_updated": {
|
||||||
const playlist = deserializePlaylist(msg.playlist);
|
const playlist = deserializePlaylist(msg.playlist);
|
||||||
setLastPlaylistEvent({
|
// Also sent when a playlist goes public, which may undo its
|
||||||
|
// `playlist_deleted` from going private.
|
||||||
|
if (msg.type === "playlist_created") {
|
||||||
|
setDeletedPlaylistIds((prev) => {
|
||||||
|
if (!prev.has(playlist.id)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(playlist.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
events.emit("playlist", {
|
||||||
type: msg.type === "playlist_created" ? "created" : "updated",
|
type: msg.type === "playlist_created" ? "created" : "updated",
|
||||||
playlistId: playlist.id,
|
playlistId: playlist.id,
|
||||||
playlist,
|
playlist,
|
||||||
@@ -596,14 +588,15 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
|
|
||||||
case "playlist_deleted": {
|
case "playlist_deleted": {
|
||||||
const { playlistId, userId } = msg;
|
const { playlistId, userId } = msg;
|
||||||
|
// Also sent to others when someone's playlist goes private.
|
||||||
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
||||||
setLastPlaylistEvent({ type: "deleted", playlistId, userId });
|
events.emit("playlist", { type: "deleted", playlistId, userId });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "playlist_dumps_updated": {
|
case "playlist_dumps_updated": {
|
||||||
const { playlistId, dumpIds, dumpCount } = msg;
|
const { playlistId, dumpIds, dumpCount } = msg;
|
||||||
setLastPlaylistEvent({
|
events.emit("playlist", {
|
||||||
type: "dumps_updated",
|
type: "dumps_updated",
|
||||||
playlistId,
|
playlistId,
|
||||||
dumpIds,
|
dumpIds,
|
||||||
@@ -614,7 +607,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
|
|
||||||
case "user_updated": {
|
case "user_updated": {
|
||||||
const user = deserializePublicUser(msg.user);
|
const user = deserializePublicUser(msg.user);
|
||||||
setLastUserEvent({ user });
|
events.emit("user", { user });
|
||||||
setAvatarOverrides((prev) => ({
|
setAvatarOverrides((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[user.id]: {
|
[user.id]: {
|
||||||
@@ -627,7 +620,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
|
|
||||||
case "comment_created": {
|
case "comment_created": {
|
||||||
const comment = deserializeComment(msg.comment);
|
const comment = deserializeComment(msg.comment);
|
||||||
setLastCommentEvent({
|
events.emit("comment", {
|
||||||
type: "created",
|
type: "created",
|
||||||
dumpId: comment.dumpId,
|
dumpId: comment.dumpId,
|
||||||
comment,
|
comment,
|
||||||
@@ -637,13 +630,13 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
|
|
||||||
case "comment_deleted": {
|
case "comment_deleted": {
|
||||||
const { commentId, dumpId } = msg;
|
const { commentId, dumpId } = msg;
|
||||||
setLastCommentEvent({ type: "deleted", dumpId, commentId });
|
events.emit("comment", { type: "deleted", dumpId, commentId });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "comment_updated": {
|
case "comment_updated": {
|
||||||
const comment = deserializeComment(msg.comment);
|
const comment = deserializeComment(msg.comment);
|
||||||
setLastCommentEvent({
|
events.emit("comment", {
|
||||||
type: "updated",
|
type: "updated",
|
||||||
dumpId: comment.dumpId,
|
dumpId: comment.dumpId,
|
||||||
comment,
|
comment,
|
||||||
@@ -653,12 +646,21 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
|
|
||||||
case "notification_created": {
|
case "notification_created": {
|
||||||
const notification = deserializeNotification(msg.notification);
|
const notification = deserializeNotification(msg.notification);
|
||||||
setLastNotification(notification);
|
events.emit("notification", notification);
|
||||||
setUnreadNotificationCount((prev) => prev + 1);
|
setUnreadNotificationCount((prev) => prev + 1);
|
||||||
playChime("notify", notification.id);
|
playChime("notify", notification.id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "notifications_read":
|
||||||
|
// Sent to every socket of the user, whichever tab read them.
|
||||||
|
setUnreadNotificationCount(0);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "categories_changed":
|
||||||
|
events.emit("categories", null);
|
||||||
|
break;
|
||||||
|
|
||||||
case "chat_message": {
|
case "chat_message": {
|
||||||
const message = deserializeChatMessage(msg.message);
|
const message = deserializeChatMessage(msg.message);
|
||||||
setChatMessages((prev) => {
|
setChatMessages((prev) => {
|
||||||
@@ -788,6 +790,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
}, [
|
}, [
|
||||||
votes,
|
votes,
|
||||||
likes,
|
likes,
|
||||||
|
events,
|
||||||
failPendingChat,
|
failPendingChat,
|
||||||
isViewingChat,
|
isViewingChat,
|
||||||
retirePendingChat,
|
retirePendingChat,
|
||||||
@@ -1125,14 +1128,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
myLikes,
|
myLikes,
|
||||||
recentDumps,
|
recentDumps,
|
||||||
deletedDumpIds,
|
deletedDumpIds,
|
||||||
lastVoteEvent,
|
|
||||||
lastDumpEvent,
|
|
||||||
lastPlaylistEvent,
|
|
||||||
deletedPlaylistIds,
|
deletedPlaylistIds,
|
||||||
lastCommentEvent,
|
|
||||||
lastUserEvent,
|
|
||||||
unreadNotificationCount,
|
unreadNotificationCount,
|
||||||
lastNotification,
|
|
||||||
connectionEpoch,
|
connectionEpoch,
|
||||||
followsVersion,
|
followsVersion,
|
||||||
castVote,
|
castVote,
|
||||||
@@ -1150,14 +1147,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
myLikes,
|
myLikes,
|
||||||
recentDumps,
|
recentDumps,
|
||||||
deletedDumpIds,
|
deletedDumpIds,
|
||||||
lastVoteEvent,
|
|
||||||
lastDumpEvent,
|
|
||||||
lastPlaylistEvent,
|
|
||||||
deletedPlaylistIds,
|
deletedPlaylistIds,
|
||||||
lastCommentEvent,
|
|
||||||
lastUserEvent,
|
|
||||||
unreadNotificationCount,
|
unreadNotificationCount,
|
||||||
lastNotification,
|
|
||||||
connectionEpoch,
|
connectionEpoch,
|
||||||
followsVersion,
|
followsVersion,
|
||||||
castVote,
|
castVote,
|
||||||
@@ -1213,13 +1204,15 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<WSContext.Provider value={value}>
|
<WSContext.Provider value={value}>
|
||||||
<WSChatContext.Provider value={chatValue}>
|
<WSEventsContext.Provider value={events}>
|
||||||
<WSPresenceContext.Provider value={presenceValue}>
|
<WSChatContext.Provider value={chatValue}>
|
||||||
<AvatarOverrideContext.Provider value={avatarOverrides}>
|
<WSPresenceContext.Provider value={presenceValue}>
|
||||||
{children}
|
<AvatarOverrideContext.Provider value={avatarOverrides}>
|
||||||
</AvatarOverrideContext.Provider>
|
{children}
|
||||||
</WSPresenceContext.Provider>
|
</AvatarOverrideContext.Provider>
|
||||||
</WSChatContext.Provider>
|
</WSPresenceContext.Provider>
|
||||||
|
</WSChatContext.Provider>
|
||||||
|
</WSEventsContext.Provider>
|
||||||
</WSContext.Provider>
|
</WSContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,75 @@
|
|||||||
/**
|
/**
|
||||||
* Keeps a page's list of dumps in step with the dump and comment events that
|
* Keeps a page's list of dumps in step with the socket: the dumps deleted,
|
||||||
* `WSContext` receives.
|
* edited and posted, their comment counts, and their posters' names.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import type { Dump } from "../../../shared/dump.ts";
|
import type { Dump } from "../../../shared/dump.ts";
|
||||||
import { useWS } from "../ws/useWS.ts";
|
import { useWS, useWSEvent } from "../ws/useWS.ts";
|
||||||
|
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Passed to {@link useDumpListSync} by a profile's own list of dumps, and by
|
* Passed to {@link useDumpListSync} by the lists that need more than edits and
|
||||||
* `usePlaylist` for the playlist's owner. The feeds pass none.
|
* deletions. The index's feed adds no dump through it, since it merges
|
||||||
|
* `WSContext`'s `recentDumps` itself.
|
||||||
*/
|
*/
|
||||||
export interface DumpListSyncOptions {
|
export interface DumpListSyncOptions {
|
||||||
/** Keeps an edited dump that is now private, for its owner's own list. */
|
/**
|
||||||
|
* The list holds the viewer's own private dumps, as their profile, the
|
||||||
|
* index and the search do, so that one they make private stays. Only its
|
||||||
|
* poster hears of a private dump's edits.
|
||||||
|
*/
|
||||||
isOwner?: boolean;
|
isOwner?: boolean;
|
||||||
|
/**
|
||||||
|
* Which dumps the list may hold. A dump that left it, as by turning
|
||||||
|
* private, comes back where it was when it returns and this accepts it.
|
||||||
|
* Without it, the list never gains a dump.
|
||||||
|
*/
|
||||||
|
belongs?: (dump: Dump) => boolean;
|
||||||
|
/**
|
||||||
|
* Also adds a dump `belongs` accepts that the list never held, where the
|
||||||
|
* newest-first order puts it among the dumps loaded: a new one first. One
|
||||||
|
* older than all of them is left to the next pages. For the lists sorted by
|
||||||
|
* `createdAt`, as a profile's dumps.
|
||||||
|
*/
|
||||||
|
addNew?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the socket's dump and comment events to the list that `setDumps`
|
* Returns `list` with `dump` before the first dump posted earlier, or as is
|
||||||
* updates. A deleted dump is removed, an edited one is replaced in place, and
|
* when none was: the dump belongs to a later page.
|
||||||
* a new or deleted comment moves its dump's `commentCount`. It never adds a
|
*/
|
||||||
* dump: the feeds merge `WSContext`'s `recentDumps` themselves, and the
|
function insertByDate(list: Dump[], dump: Dump): Dump[] {
|
||||||
* profiles and `usePlaylist` put back a dump that returns with
|
if (list.length === 0) return [dump];
|
||||||
* `usePositionAwareSync`.
|
const idx = list.findIndex((d) =>
|
||||||
|
d.createdAt.getTime() < dump.createdAt.getTime()
|
||||||
|
);
|
||||||
|
return idx === -1 ? list : insertAt(list, dump, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the socket's dump, comment and user events to `items`, the loaded
|
||||||
|
* list, which `setDumps` updates. A deleted dump is removed, an edited one is
|
||||||
|
* replaced in place, one gone private leaves unless `isOwner`, and a new or
|
||||||
|
* returning one is added as `belongs` and `addNew` say. A new or deleted
|
||||||
|
* comment moves its dump's `commentCount`, and a renamed user's dumps take
|
||||||
|
* their new name.
|
||||||
*/
|
*/
|
||||||
export function useDumpListSync(
|
export function useDumpListSync(
|
||||||
|
items: Dump[],
|
||||||
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
|
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
|
||||||
options?: DumpListSyncOptions,
|
options?: DumpListSyncOptions,
|
||||||
): void {
|
): void {
|
||||||
const { deletedDumpIds, lastDumpEvent, lastCommentEvent } = useWS();
|
const { deletedDumpIds } = useWS();
|
||||||
|
const takePosition = usePositionMemory(items);
|
||||||
|
|
||||||
// Refs, so that the effects run on new events only, not on new callbacks.
|
// A ref, so that the effect runs on new deletions only, not on new
|
||||||
|
// callbacks.
|
||||||
const setDumpsRef = useRef(setDumps);
|
const setDumpsRef = useRef(setDumps);
|
||||||
const optionsRef = useRef(options);
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setDumpsRef.current = setDumps;
|
setDumpsRef.current = setDumps;
|
||||||
optionsRef.current = options;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,31 +79,33 @@ export function useDumpListSync(
|
|||||||
);
|
);
|
||||||
}, [deletedDumpIds]);
|
}, [deletedDumpIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useWSEvent("dump", ({ dump }) => {
|
||||||
if (!lastDumpEvent) return;
|
const { isOwner, belongs, addNew } = options ?? {};
|
||||||
const isOwner = optionsRef.current?.isOwner;
|
const accepted = !!belongs?.(dump);
|
||||||
const dump = lastDumpEvent;
|
// Read here, not in the updater, which React may call twice.
|
||||||
|
const oldIdx = accepted ? takePosition(dump.id) : undefined;
|
||||||
setDumpsRef.current((prev) => {
|
setDumps((prev) => {
|
||||||
const idx = prev.findIndex((d) => d.id === dump.id);
|
const idx = prev.findIndex((d) => d.id === dump.id);
|
||||||
if (idx === -1) return prev;
|
if (idx !== -1) {
|
||||||
if (dump.isPrivate && !isOwner) {
|
if (dump.isPrivate && !isOwner) {
|
||||||
return prev.filter((d) => d.id !== dump.id);
|
return prev.filter((d) => d.id !== dump.id);
|
||||||
|
}
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = dump;
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
const next = [...prev];
|
if (!accepted) return prev;
|
||||||
next[idx] = dump;
|
if (oldIdx !== undefined) return insertAt(prev, dump, oldIdx);
|
||||||
return next;
|
return addNew ? insertByDate(prev, dump) : prev;
|
||||||
});
|
});
|
||||||
}, [lastDumpEvent]);
|
});
|
||||||
|
|
||||||
// The server sends comment events for public dumps only, so a private
|
// The server sends a private dump's comment events to its owner alone, who
|
||||||
// dump's count stays as fetched.
|
// alone lists it.
|
||||||
useEffect(() => {
|
useWSEvent("comment", ({ type, dumpId }) => {
|
||||||
if (!lastCommentEvent) return;
|
|
||||||
const { type, dumpId } = lastCommentEvent;
|
|
||||||
if (type !== "created" && type !== "deleted") return;
|
if (type !== "created" && type !== "deleted") return;
|
||||||
const delta = type === "created" ? 1 : -1;
|
const delta = type === "created" ? 1 : -1;
|
||||||
setDumpsRef.current((prev) => {
|
setDumps((prev) => {
|
||||||
const idx = prev.findIndex((d) => d.id === dumpId);
|
const idx = prev.findIndex((d) => d.id === dumpId);
|
||||||
if (idx === -1) return prev;
|
if (idx === -1) return prev;
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
@@ -81,5 +115,17 @@ export function useDumpListSync(
|
|||||||
};
|
};
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [lastCommentEvent]);
|
});
|
||||||
|
|
||||||
|
useWSEvent("user", ({ user }) => {
|
||||||
|
setDumps((prev) =>
|
||||||
|
prev.some((d) =>
|
||||||
|
d.userId === user.id && d.ownerUsername !== user.username
|
||||||
|
)
|
||||||
|
? prev.map((d) =>
|
||||||
|
d.userId === user.id ? { ...d, ownerUsername: user.username } : d
|
||||||
|
)
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { Wire } from "../../../shared/api.ts";
|
|||||||
import { expectOk } from "../../utils/shared/api.ts";
|
import { expectOk } from "../../utils/shared/api.ts";
|
||||||
import { deserializeDump } from "../../utils/shared/deserialize.ts";
|
import { deserializeDump } from "../../utils/shared/deserialize.ts";
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
import { useAuth } from "../auth/useAuth.ts";
|
||||||
import { useWS } from "../ws/useWS.ts";
|
import { useWS, useWSEvent } from "../ws/useWS.ts";
|
||||||
import { fadeClassName, useFading } from "../feed/useFading.ts";
|
import { fadeClassName, useFading } from "../feed/useFading.ts";
|
||||||
|
|
||||||
/** Passed to {@link useUpvotedList} by `useUserDumpFeed`, for an upvoted list. */
|
/** Passed to {@link useUpvotedList} by `useUserDumpFeed`, for an upvoted list. */
|
||||||
@@ -37,7 +37,7 @@ export interface UseUpvotedListResult {
|
|||||||
cardClassName: (dump: Dump) => string | undefined;
|
cardClassName: (dump: Dump) => string | undefined;
|
||||||
/**
|
/**
|
||||||
* Checks whether the vote on dump `id` was withdrawn since the list loaded.
|
* Checks whether the vote on dump `id` was withdrawn since the list loaded.
|
||||||
* The profile's `usePositionAwareSync` keeps such a dump from coming back.
|
* The profile's `useDumpListSync` keeps such a dump from coming back.
|
||||||
*/
|
*/
|
||||||
isWithdrawn: (id: string) => boolean;
|
isWithdrawn: (id: string) => boolean;
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ export function useUpvotedList(
|
|||||||
{ profileUserId, setItems, source }: UseUpvotedListOptions,
|
{ profileUserId, setItems, source }: UseUpvotedListOptions,
|
||||||
): UseUpvotedListResult {
|
): UseUpvotedListResult {
|
||||||
const { user: me, token } = useAuth();
|
const { user: me, token } = useAuth();
|
||||||
const { myVotes, lastVoteEvent } = useWS();
|
const { myVotes } = useWS();
|
||||||
const isOwn = !!profileUserId && me?.id === profileUserId;
|
const isOwn = !!profileUserId && me?.id === profileUserId;
|
||||||
|
|
||||||
const withdrawnRef = useRef<Set<string>>(new Set());
|
const withdrawnRef = useRef<Set<string>>(new Set());
|
||||||
@@ -106,10 +106,8 @@ export function useUpvotedList(
|
|||||||
});
|
});
|
||||||
}, [myVotes, isOwn, withdraw, recast, setItems]);
|
}, [myVotes, isOwn, withdraw, recast, setItems]);
|
||||||
|
|
||||||
useEffect(() => {
|
useWSEvent("vote", ({ dumpId, voterId, action }) => {
|
||||||
if (!lastVoteEvent || !profileUserId) return;
|
if (!profileUserId || voterId !== profileUserId) return;
|
||||||
const { dumpId, voterId, action } = lastVoteEvent;
|
|
||||||
if (voterId !== profileUserId) return;
|
|
||||||
if (action === "remove") {
|
if (action === "remove") {
|
||||||
withdraw(dumpId);
|
withdraw(dumpId);
|
||||||
return;
|
return;
|
||||||
@@ -130,7 +128,7 @@ export function useUpvotedList(
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [lastVoteEvent, profileUserId, token, withdraw, recast, setItems]);
|
});
|
||||||
|
|
||||||
const cardClassName = useCallback(
|
const cardClassName = useCallback(
|
||||||
(dump: Dump) => fadeClassName(fading[dump.id]),
|
(dump: Dump) => fadeClassName(fading[dump.id]),
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ import {
|
|||||||
import { useAuth } from "../auth/useAuth.ts";
|
import { useAuth } from "../auth/useAuth.ts";
|
||||||
import { useCategories } from "../category/useCategories.ts";
|
import { useCategories } from "../category/useCategories.ts";
|
||||||
import { useCategoryFilter } from "../category/useCategoryFilter.ts";
|
import { useCategoryFilter } from "../category/useCategoryFilter.ts";
|
||||||
import { useWS } from "../ws/useWS.ts";
|
import { useOnReconnect } from "../ws/useWS.ts";
|
||||||
import { useFeedCache } from "../feed/useFeedCache.ts";
|
import { useFeedCache } from "../feed/useFeedCache.ts";
|
||||||
import { usePagedList, type UsePagedListResult } from "../feed/usePagedList.ts";
|
import { usePagedList, type UsePagedListResult } from "../feed/usePagedList.ts";
|
||||||
import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts";
|
import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts";
|
||||||
import { usePositionAwareSync } from "../feed/usePositionAwareSync.ts";
|
|
||||||
import { useDumpListSync } from "./useDumpListSync.ts";
|
import { useDumpListSync } from "./useDumpListSync.ts";
|
||||||
import { useUpvotedList } from "./useUpvotedList.ts";
|
import { useUpvotedList } from "./useUpvotedList.ts";
|
||||||
|
|
||||||
@@ -51,12 +50,13 @@ export interface UseUserDumpFeedOptions {
|
|||||||
* Returns `list` of user `userId`, from `GET /api/users/:userId/<list>`, fetched
|
* Returns `list` of user `userId`, from `GET /api/users/:userId/<list>`, fetched
|
||||||
* signed in so users see their own private dumps, with the call that loads its
|
* signed in so users see their own private dumps, with the call that loads its
|
||||||
* next page; with `categorySlug`, only the dumps in that category. The socket
|
* next page; with `categorySlug`, only the dumps in that category. The socket
|
||||||
* keeps it live: a dump that goes private and public again comes back at its
|
* keeps it live: the user's new dumps come in, a dump that goes private and
|
||||||
* place, one edited out of the category leaves, and the upvoted list follows
|
* public again comes back at its place, one edited out of the category
|
||||||
* the user's votes, `source` holding dumps at hand that a vote of theirs puts
|
* leaves, one edited into it comes in, and the upvoted list follows the
|
||||||
* first at once. The list and the scroll position are saved as the page is
|
* user's votes, `source` holding dumps at hand that a vote of theirs puts
|
||||||
* left, under a key the overview and the unfiltered tab share, and going back
|
* first at once. A reconnection fetches the pages loaded again. The list and
|
||||||
* restores them.
|
* the scroll position are saved as the page is left, under a key the overview
|
||||||
|
* and the unfiltered tab share, and going back restores them.
|
||||||
*/
|
*/
|
||||||
export function useUserDumpFeed(
|
export function useUserDumpFeed(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -64,7 +64,6 @@ export function useUserDumpFeed(
|
|||||||
{ isOwner, source, categorySlug }: UseUserDumpFeedOptions,
|
{ isOwner, source, categorySlug }: UseUserDumpFeedOptions,
|
||||||
): UseUserDumpFeedResult {
|
): UseUserDumpFeedResult {
|
||||||
const { user, authFetch } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
const { lastDumpEvent } = useWS();
|
|
||||||
const { categories } = useCategories("dump");
|
const { categories } = useCategories("dump");
|
||||||
const { cached, saveState } = useFeedCache<Dump>(
|
const { cached, saveState } = useFeedCache<Dump>(
|
||||||
`feed:profile-${list}:${userId}${categorySlug ? `:${categorySlug}` : ""}`,
|
`feed:profile-${list}:${userId}${categorySlug ? `:${categorySlug}` : ""}`,
|
||||||
@@ -99,15 +98,18 @@ export function useUserDumpFeed(
|
|||||||
setItems,
|
setItems,
|
||||||
source,
|
source,
|
||||||
});
|
});
|
||||||
usePositionAwareSync(
|
useDumpListSync(
|
||||||
items,
|
items,
|
||||||
setItems,
|
setItems,
|
||||||
lastDumpEvent,
|
|
||||||
list === "dumps"
|
list === "dumps"
|
||||||
? (d) => !d.isPrivate && d.userId === userId
|
? {
|
||||||
: (d) => !d.isPrivate && !upvoted.isWithdrawn(d.id),
|
isOwner,
|
||||||
|
belongs: (d) => d.userId === userId && (isOwner || !d.isPrivate),
|
||||||
|
addNew: true,
|
||||||
|
}
|
||||||
|
: { belongs: (d) => !d.isPrivate && !upvoted.isWithdrawn(d.id) },
|
||||||
);
|
);
|
||||||
useDumpListSync(setItems, { isOwner: list === "dumps" && isOwner });
|
useOnReconnect(paged.resync);
|
||||||
|
|
||||||
useScrollSave(
|
useScrollSave(
|
||||||
state.status === "loaded",
|
state.status === "loaded",
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export interface UsePagedListResult<T> {
|
|||||||
setItems: (fn: (items: T[]) => T[]) => void;
|
setItems: (fn: (items: T[]) => T[]) => void;
|
||||||
/** It goes to `useInfiniteScroll` or to a `LoadMoreButton`. */
|
/** It goes to `useInfiniteScroll` or to a `LoadMoreButton`. */
|
||||||
loadMore: () => void;
|
loadMore: () => void;
|
||||||
|
/**
|
||||||
|
* Fetches again every page loaded so far and puts them in the list's
|
||||||
|
* place, keeping its length and so the scroll. The live lists call it on
|
||||||
|
* `useOnReconnect`, for what changed while the socket was down. A page that
|
||||||
|
* fails leaves the list as it was.
|
||||||
|
*/
|
||||||
|
resync: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,7 +87,7 @@ export interface UsePagedListResult<T> {
|
|||||||
* loading. The index's feeds, the profile's lists, the notifications and the
|
* loading. The index's feeds, the profile's lists, the notifications and the
|
||||||
* popover of voters and likers use it.
|
* popover of voters and likers use it.
|
||||||
*/
|
*/
|
||||||
export function usePagedList<T>(
|
export function usePagedList<T extends { id: string }>(
|
||||||
key: string | undefined,
|
key: string | undefined,
|
||||||
fetchPage: FetchPage<T>,
|
fetchPage: FetchPage<T>,
|
||||||
cached?: Omit<FeedCacheEntry<T>, "savedAt"> | null,
|
cached?: Omit<FeedCacheEntry<T>, "savedAt"> | null,
|
||||||
@@ -97,10 +104,34 @@ export function usePagedList<T>(
|
|||||||
(fn: (items: T[]) => T[]) => setData(mapItems(fn)),
|
(fn: (items: T[]) => T[]) => setData(mapItems(fn)),
|
||||||
[setData],
|
[setData],
|
||||||
);
|
);
|
||||||
const loadMore = useLoadMore(
|
const list = state.status === "loaded" ? state.data : null;
|
||||||
state.status === "loaded" ? state.data : null,
|
const loadMore = useLoadMore(list, setData, fetchPage);
|
||||||
setData,
|
|
||||||
fetchPage,
|
const latestRef = useRef({ list, fetchPage });
|
||||||
);
|
useLayoutEffect(() => {
|
||||||
return { state, setItems, loadMore };
|
latestRef.current = { list, fetchPage };
|
||||||
|
});
|
||||||
|
const resync = useCallback(() => {
|
||||||
|
const { list, fetchPage } = latestRef.current;
|
||||||
|
if (!list) return;
|
||||||
|
const pages = Array.from({ length: list.page }, (_, i) => i + 1);
|
||||||
|
Promise.all(pages.map((page) => fetchPage(page)))
|
||||||
|
.then((results) => {
|
||||||
|
// An item added between two pages' fetches shifts the later page by
|
||||||
|
// one, which would list an item twice.
|
||||||
|
const byId = new Map<string, T>();
|
||||||
|
for (const item of results.flatMap((r) => r.items)) {
|
||||||
|
if (!byId.has(item.id)) byId.set(item.id, item);
|
||||||
|
}
|
||||||
|
const items = [...byId.values()];
|
||||||
|
setData((l) => ({
|
||||||
|
...l,
|
||||||
|
items,
|
||||||
|
hasMore: results[results.length - 1].hasMore,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [setData]);
|
||||||
|
|
||||||
|
return { state, setItems, loadMore, resync };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
/**
|
|
||||||
* Puts an item that left a list, as by turning private, back at its old
|
|
||||||
* position when it turns public again.
|
|
||||||
*
|
|
||||||
* @module
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remembers the index of every item that leaves `items`, whatever took it
|
|
||||||
* out, and puts it back there when `lastEvent` shows it again and
|
|
||||||
* `shouldReinsert` accepts it. The profile pages and `usePlaylist` use it with
|
|
||||||
* `WSContext`'s latest dump or playlist event, beside `useDumpListSync` or
|
|
||||||
* `usePlaylistListSync`, which never re-add an item themselves. `items` is the
|
|
||||||
* loaded list, or `[]` before it loads, and `setItems` its setter.
|
|
||||||
*/
|
|
||||||
export function usePositionAwareSync<T extends { id: string }>(
|
|
||||||
items: T[],
|
|
||||||
setItems: (fn: (prev: T[]) => T[]) => void,
|
|
||||||
lastEvent: T | null,
|
|
||||||
shouldReinsert: (item: T) => boolean,
|
|
||||||
): void {
|
|
||||||
const setItemsRef = useRef(setItems);
|
|
||||||
const shouldReinsertRef = useRef(shouldReinsert);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
setItemsRef.current = setItems;
|
|
||||||
shouldReinsertRef.current = shouldReinsert;
|
|
||||||
});
|
|
||||||
|
|
||||||
const previousRef = useRef<T[]>(items);
|
|
||||||
const removedPositionsRef = useRef<Map<string, number>>(new Map());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const present = new Set(items.map((item) => item.id));
|
|
||||||
previousRef.current.forEach((item, idx) => {
|
|
||||||
if (!present.has(item.id)) removedPositionsRef.current.set(item.id, idx);
|
|
||||||
});
|
|
||||||
for (const id of present) removedPositionsRef.current.delete(id);
|
|
||||||
previousRef.current = items;
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lastEvent || !shouldReinsertRef.current(lastEvent)) return;
|
|
||||||
const savedIdx = removedPositionsRef.current.get(lastEvent.id);
|
|
||||||
if (savedIdx === undefined) return;
|
|
||||||
removedPositionsRef.current.delete(lastEvent.id);
|
|
||||||
const item = lastEvent;
|
|
||||||
setItemsRef.current((prev) => {
|
|
||||||
if (prev.some((i) => i.id === item.id)) return prev;
|
|
||||||
const next = [...prev];
|
|
||||||
// The list may have shrunk since.
|
|
||||||
next.splice(Math.min(savedIdx, next.length), 0, item);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [lastEvent]);
|
|
||||||
}
|
|
||||||
52
src/hooks/feed/usePositionMemory.ts
Normal file
52
src/hooks/feed/usePositionMemory.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Lets a live list put an item that left it, as by turning private, back at
|
||||||
|
* its old position when it returns.
|
||||||
|
*
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers the index of every item that leaves `items`, whatever took it
|
||||||
|
* out, for `useDumpListSync` and `usePlaylistListSync`, which own the lists'
|
||||||
|
* live changes. `items` is the loaded list, or `[]` before it loads. The
|
||||||
|
* returned call gives an item's old index, once, and forgets it; it is
|
||||||
|
* undefined for an item that never left.
|
||||||
|
*/
|
||||||
|
export function usePositionMemory<T extends { id: string }>(
|
||||||
|
items: T[],
|
||||||
|
): (id: string) => number | undefined {
|
||||||
|
const previousRef = useRef<T[]>(items);
|
||||||
|
const removedRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const present = new Set(items.map((item) => item.id));
|
||||||
|
previousRef.current.forEach((item, idx) => {
|
||||||
|
if (!present.has(item.id)) removedRef.current.set(item.id, idx);
|
||||||
|
});
|
||||||
|
for (const id of present) removedRef.current.delete(id);
|
||||||
|
previousRef.current = items;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
return useCallback((id: string) => {
|
||||||
|
const idx = removedRef.current.get(id);
|
||||||
|
removedRef.current.delete(id);
|
||||||
|
return idx;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `list` with `item` at `idx`, or at the end when the list has since
|
||||||
|
* shrunk below it, unless the list holds it already.
|
||||||
|
*/
|
||||||
|
export function insertAt<T extends { id: string }>(
|
||||||
|
list: T[],
|
||||||
|
item: T,
|
||||||
|
idx: number,
|
||||||
|
): T[] {
|
||||||
|
if (list.some((i) => i.id === item.id)) return list;
|
||||||
|
const next = [...list];
|
||||||
|
next.splice(Math.min(idx, next.length), 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -31,10 +31,9 @@ import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
|
|||||||
import { isAt, playlistUrl, userApiUrl } from "../../utils/shared/urls.ts";
|
import { isAt, playlistUrl, userApiUrl } from "../../utils/shared/urls.ts";
|
||||||
import { useCanonicalPath } from "../shared/useCanonicalPath.ts";
|
import { useCanonicalPath } from "../shared/useCanonicalPath.ts";
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
import { useAuth } from "../auth/useAuth.ts";
|
||||||
import { useWS } from "../ws/useWS.ts";
|
import { useOnReconnect, useWSEvent } from "../ws/useWS.ts";
|
||||||
import { type FetchState, useFetchState } from "../shared/useFetchState.ts";
|
import { type FetchState, useFetchState } from "../shared/useFetchState.ts";
|
||||||
import { type FadeState, useFading } from "../feed/useFading.ts";
|
import { type FadeState, useFading } from "../feed/useFading.ts";
|
||||||
import { usePositionAwareSync } from "../feed/usePositionAwareSync.ts";
|
|
||||||
import { useDumpListSync } from "../dump/useDumpListSync.ts";
|
import { useDumpListSync } from "../dump/useDumpListSync.ts";
|
||||||
|
|
||||||
/** Returned by {@link usePlaylist} to `PlaylistDetail`. */
|
/** Returned by {@link usePlaylist} to `PlaylistDetail`. */
|
||||||
@@ -98,9 +97,10 @@ function withDetails(
|
|||||||
* Returns the playlist at `/@username/playlists/slug` from
|
* Returns the playlist at `/@username/playlists/slug` from
|
||||||
* `GET /api/users/@:username/playlists/:slug`, with the dumps the viewer may
|
* `GET /api/users/@:username/playlists/:slug`, with the dumps the viewer may
|
||||||
* see in it. The socket's events keep its details and dumps live: a dump
|
* see in it. The socket's events keep its details and dumps live: a dump
|
||||||
* another client removes fades out, one it adds is fetched, a new order is
|
* another client removes, or that turns private, fades out, one it adds, or
|
||||||
* applied, one that goes private leaves and comes back at its place, and a
|
* that turns public, is fetched, a new order is applied, and a playlist
|
||||||
* deleted playlist sends the page home. A new slug replaces the URL, keeping
|
* deleted, or turned private by someone else, sends the page home. A
|
||||||
|
* reconnection loads it again. A new slug replaces the URL, keeping
|
||||||
* the page's `view`, and a path that names the playlist on screen, as that
|
* the page's `view`, and a path that names the playlist on screen, as that
|
||||||
* one does, fetches nothing.
|
* one does, fetches nothing.
|
||||||
*/
|
*/
|
||||||
@@ -111,7 +111,6 @@ export function usePlaylist(
|
|||||||
): UsePlaylistResult {
|
): UsePlaylistResult {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, authFetch } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
const { lastDumpEvent, lastPlaylistEvent } = useWS();
|
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
// The path last fetched. The URL may move on to the same playlist's current
|
// The path last fetched. The URL may move on to the same playlist's current
|
||||||
@@ -121,7 +120,7 @@ export function usePlaylist(
|
|||||||
// The server's last order of the dumps this viewer may see, which a refused
|
// The server's last order of the dumps this viewer may see, which a refused
|
||||||
// reorder goes back to.
|
// reorder goes back to.
|
||||||
const serverOrderRef = useRef<string[]>([]);
|
const serverOrderRef = useRef<string[]>([]);
|
||||||
const { state, setData } = useFetchState(
|
const { state, setData, reload } = useFetchState(
|
||||||
target.username && target.slug &&
|
target.username && target.slug &&
|
||||||
`${target.username}/${target.slug}:${user?.id ?? "guest"}`,
|
`${target.username}/${target.slug}:${user?.id ?? "guest"}`,
|
||||||
(signal) =>
|
(signal) =>
|
||||||
@@ -158,9 +157,6 @@ export function usePlaylist(
|
|||||||
// This viewer's removals whose fade hasn't ended, with the dump and its
|
// This viewer's removals whose fade hasn't ended, with the dump and its
|
||||||
// place, to put it back if the server refuses.
|
// place, to put it back if the server refuses.
|
||||||
const pendingRef = useRef(new Map<string, { dump: Dump; index: number }>());
|
const pendingRef = useRef(new Map<string, { dump: Dump; index: number }>());
|
||||||
// Every dump taken out of the playlist, by anyone, which mustn't come back
|
|
||||||
// when it next changes, as one that went private does.
|
|
||||||
const removedRef = useRef(new Set<string>());
|
|
||||||
|
|
||||||
const sendRemoval = useCallback((dumpId: string) => {
|
const sendRemoval = useCallback((dumpId: string) => {
|
||||||
const pending = pendingRef.current.get(dumpId);
|
const pending = pendingRef.current.get(dumpId);
|
||||||
@@ -172,7 +168,6 @@ export function usePlaylist(
|
|||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
removedRef.current.delete(dumpId);
|
|
||||||
setDumps((dumps) => {
|
setDumps((dumps) => {
|
||||||
if (dumps.some((d) => d.id === dumpId)) return dumps;
|
if (dumps.some((d) => d.id === dumpId)) return dumps;
|
||||||
const next = [...dumps];
|
const next = [...dumps];
|
||||||
@@ -224,9 +219,8 @@ export function usePlaylist(
|
|||||||
setData((p) => withDetails(p, updated));
|
setData((p) => withDetails(p, updated));
|
||||||
}, [setData]);
|
}, [setData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useWSEvent("playlist", (ev) => {
|
||||||
const ev = lastPlaylistEvent;
|
if (!id || ev.playlistId !== id) return;
|
||||||
if (!ev || !id || ev.playlistId !== id) return;
|
|
||||||
if (ev.type === "updated" && ev.playlist) {
|
if (ev.type === "updated" && ev.playlist) {
|
||||||
applyDetails(ev.playlist);
|
applyDetails(ev.playlist);
|
||||||
} else if (ev.type === "deleted") {
|
} else if (ev.type === "deleted") {
|
||||||
@@ -239,13 +233,11 @@ export function usePlaylist(
|
|||||||
const shown = new Set(playlist?.dumps.map((d) => d.id));
|
const shown = new Set(playlist?.dumps.map((d) => d.id));
|
||||||
for (const dumpId of shown) {
|
for (const dumpId of shown) {
|
||||||
if (listed.has(dumpId)) continue;
|
if (listed.has(dumpId)) continue;
|
||||||
removedRef.current.add(dumpId);
|
|
||||||
startFading(dumpId);
|
startFading(dumpId);
|
||||||
}
|
}
|
||||||
for (const dumpId of ids) {
|
for (const dumpId of ids) {
|
||||||
// This viewer's own removal, which the server hasn't had yet.
|
// This viewer's own removal, which the server hasn't had yet.
|
||||||
if (pendingRef.current.has(dumpId)) continue;
|
if (pendingRef.current.has(dumpId)) continue;
|
||||||
removedRef.current.delete(dumpId);
|
|
||||||
if (shown.has(dumpId)) {
|
if (shown.has(dumpId)) {
|
||||||
// Put back while it was fading out.
|
// Put back while it was fading out.
|
||||||
cancelFading(dumpId);
|
cancelFading(dumpId);
|
||||||
@@ -265,34 +257,34 @@ export function usePlaylist(
|
|||||||
}
|
}
|
||||||
setDumps((dumps) => inOrder(dumps, ids));
|
setDumps((dumps) => inOrder(dumps, ids));
|
||||||
}
|
}
|
||||||
}, [lastPlaylistEvent, id, applyDetails, navigate, authFetch, setDumps]);
|
});
|
||||||
|
|
||||||
|
useWSEvent("user", ({ user }) => {
|
||||||
|
setData((p) =>
|
||||||
|
p.userId === user.id && p.ownerUsername !== user.username
|
||||||
|
? { ...p, ownerUsername: user.username }
|
||||||
|
: p
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Before `useDumpListSync`: it notes a dump's place before the sync takes
|
|
||||||
// it out, as when it goes private.
|
|
||||||
usePositionAwareSync(
|
|
||||||
playlist?.dumps ?? [],
|
|
||||||
setDumps,
|
|
||||||
lastDumpEvent,
|
|
||||||
(d) => !d.isPrivate && !removedRef.current.has(d.id),
|
|
||||||
);
|
|
||||||
// A private dump is listed only to the playlist's owner, and only their own,
|
// A private dump is listed only to the playlist's owner, and only their own,
|
||||||
// which is the only private dump whose changes reach them.
|
// which is the only private dump whose changes reach them. Which dumps come
|
||||||
useDumpListSync(setDumps, {
|
// and go is the playlist's `dumps_updated`, above.
|
||||||
|
useDumpListSync(playlist?.dumps ?? [], setDumps, {
|
||||||
isOwner: !!user && user.id === playlist?.userId,
|
isOwner: !!user && user.id === playlist?.userId,
|
||||||
});
|
});
|
||||||
|
useOnReconnect(reload);
|
||||||
|
|
||||||
const remove = useCallback((dumpId: string) => {
|
const remove = useCallback((dumpId: string) => {
|
||||||
const dumps = latestRef.current.playlist?.dumps ?? [];
|
const dumps = latestRef.current.playlist?.dumps ?? [];
|
||||||
const index = dumps.findIndex((d) => d.id === dumpId);
|
const index = dumps.findIndex((d) => d.id === dumpId);
|
||||||
if (index === -1) return;
|
if (index === -1) return;
|
||||||
pendingRef.current.set(dumpId, { dump: dumps[index], index });
|
pendingRef.current.set(dumpId, { dump: dumps[index], index });
|
||||||
removedRef.current.add(dumpId);
|
|
||||||
startFading(dumpId);
|
startFading(dumpId);
|
||||||
}, [startFading]);
|
}, [startFading]);
|
||||||
|
|
||||||
const undoRemove = useCallback((dumpId: string) => {
|
const undoRemove = useCallback((dumpId: string) => {
|
||||||
pendingRef.current.delete(dumpId);
|
pendingRef.current.delete(dumpId);
|
||||||
removedRef.current.delete(dumpId);
|
|
||||||
cancelFading(dumpId);
|
cancelFading(dumpId);
|
||||||
}, [cancelFading]);
|
}, [cancelFading]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Keeps a page's list of playlists in step with the socket's `playlist_*`
|
* Keeps a page's list of playlists in step with the socket's `playlist_*`
|
||||||
* messages. The user's profile and playlists pages call it.
|
* messages, and their owners' names. The user's profile, the playlists page
|
||||||
|
* and the search call it.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import type { Playlist } from "../../../shared/playlist.ts";
|
import type { Playlist } from "../../../shared/playlist.ts";
|
||||||
import { useWS } from "../ws/useWS.ts";
|
import { useWS, useWSEvent } from "../ws/useWS.ts";
|
||||||
|
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
|
||||||
|
|
||||||
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
|
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
|
||||||
export interface PlaylistListSyncOptions {
|
export interface PlaylistListSyncOptions {
|
||||||
@@ -19,58 +21,59 @@ export interface PlaylistListSyncOptions {
|
|||||||
*/
|
*/
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
/**
|
/**
|
||||||
* Adds no playlist, for a list of followed playlists, since the socket
|
* Adds no new playlist, for a list of followed playlists, since the socket
|
||||||
* doesn't say what the user follows. Playlists already listed still change
|
* doesn't say what the user follows, or of search results. Playlists
|
||||||
* and go.
|
* already listed still change, go, and come back where they were.
|
||||||
*/
|
*/
|
||||||
noNewEntries?: boolean;
|
noNewEntries?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the socket's playlist events to the list set through
|
* Applies the socket's playlist events to `items`, the loaded list, which
|
||||||
* `setPlaylists`. A created playlist goes first, an updated one changes in
|
* `setPlaylists` updates. A created playlist goes first, an updated one
|
||||||
* place or leaves when it turns private (unless `isOwner`), a deleted one
|
* changes in place or leaves when it turns private (unless `isOwner`), one
|
||||||
* leaves, and a playlist's `dumpCount` follows its dumps. An update never
|
* that left comes back where it was when it returns, a deleted one leaves,
|
||||||
* adds a playlist the list doesn't hold.
|
* and a playlist's `dumpCount` follows its dumps. A renamed user's playlists
|
||||||
|
* take their new name.
|
||||||
*/
|
*/
|
||||||
export function usePlaylistListSync(
|
export function usePlaylistListSync(
|
||||||
|
items: Playlist[],
|
||||||
setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void,
|
setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void,
|
||||||
options?: PlaylistListSyncOptions,
|
options?: PlaylistListSyncOptions,
|
||||||
): void {
|
): void {
|
||||||
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
|
const { deletedPlaylistIds } = useWS();
|
||||||
|
const takePosition = usePositionMemory(items);
|
||||||
|
|
||||||
|
// A ref, so that the effect runs on new deletions only, not on new
|
||||||
|
// callbacks.
|
||||||
const setPlaylistsRef = useRef(setPlaylists);
|
const setPlaylistsRef = useRef(setPlaylists);
|
||||||
const optionsRef = useRef(options);
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setPlaylistsRef.current = setPlaylists;
|
setPlaylistsRef.current = setPlaylists;
|
||||||
optionsRef.current = options;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useWSEvent("playlist", (ev) => {
|
||||||
if (!lastPlaylistEvent) return;
|
const { isOwner, ownerId, noNewEntries } = options ?? {};
|
||||||
const { isOwner, ownerId, noNewEntries } = optionsRef.current ?? {};
|
const shown = ev.playlist &&
|
||||||
const ev = lastPlaylistEvent;
|
(ev.playlist.isPublic || isOwner) &&
|
||||||
|
(!ownerId || ev.playlist.userId === ownerId);
|
||||||
|
// Read here, not in the updater, which React may call twice.
|
||||||
|
const oldIdx = shown ? takePosition(ev.playlistId) : undefined;
|
||||||
|
|
||||||
setPlaylistsRef.current((prev) => {
|
setPlaylists((prev) => {
|
||||||
if (ev.type === "created" && ev.playlist) {
|
if ((ev.type === "created" || ev.type === "updated") && ev.playlist) {
|
||||||
if (noNewEntries) return prev;
|
const playlist = ev.playlist;
|
||||||
if (ownerId && ev.playlist.userId !== ownerId) return prev;
|
const idx = prev.findIndex((p) => p.id === playlist.id);
|
||||||
if (!ev.playlist.isPublic && !isOwner) return prev;
|
if (idx !== -1) {
|
||||||
if (prev.some((p) => p.id === ev.playlist!.id)) return prev;
|
if (!shown) return prev.filter((p) => p.id !== playlist.id);
|
||||||
return [ev.playlist, ...prev];
|
const next = [...prev];
|
||||||
}
|
next[idx] = playlist;
|
||||||
|
return next;
|
||||||
if (ev.type === "updated" && ev.playlist) {
|
|
||||||
const idx = prev.findIndex((p) => p.id === ev.playlist!.id);
|
|
||||||
// One that left when it turned private is put back by
|
|
||||||
// `usePositionAwareSync`, where it was.
|
|
||||||
if (idx === -1) return prev;
|
|
||||||
if (!ev.playlist.isPublic && !isOwner) {
|
|
||||||
return prev.filter((p) => p.id !== ev.playlist!.id);
|
|
||||||
}
|
}
|
||||||
const next = [...prev];
|
if (!shown) return prev;
|
||||||
next[idx] = ev.playlist;
|
if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx);
|
||||||
return next;
|
// An update never adds a playlist the list doesn't hold.
|
||||||
|
if (ev.type === "updated" || noNewEntries) return prev;
|
||||||
|
return [playlist, ...prev];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ev.type === "deleted") {
|
if (ev.type === "deleted") {
|
||||||
@@ -90,7 +93,19 @@ export function usePlaylistListSync(
|
|||||||
|
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}, [lastPlaylistEvent]);
|
});
|
||||||
|
|
||||||
|
useWSEvent("user", ({ user }) => {
|
||||||
|
setPlaylists((prev) =>
|
||||||
|
prev.some((p) =>
|
||||||
|
p.userId === user.id && p.ownerUsername !== user.username
|
||||||
|
)
|
||||||
|
? prev.map((p) =>
|
||||||
|
p.userId === user.id ? { ...p, ownerUsername: user.username } : p
|
||||||
|
)
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deletedPlaylistIds.size === 0) return;
|
if (deletedPlaylistIds.size === 0) return;
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { PublicUser } from "../../../shared/user.ts";
|
import type { PublicUser } from "../../../shared/user.ts";
|
||||||
import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts";
|
import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts";
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
import { useAuth } from "../auth/useAuth.ts";
|
||||||
import { useWS } from "../ws/useWS.ts";
|
import { useOnReconnect, useWSEvent } from "../ws/useWS.ts";
|
||||||
import { type FetchState, useFetchState } from "../shared/useFetchState.ts";
|
import { type FetchState, useFetchState } from "../shared/useFetchState.ts";
|
||||||
|
|
||||||
/** Returned by {@link useProfile} to `UserPublicProfile`. */
|
/** Returned by {@link useProfile} to `UserPublicProfile`. */
|
||||||
@@ -27,15 +27,15 @@ export interface UseProfileResult {
|
|||||||
* Returns the user named `username`, from `GET /api/users/@:username`, which
|
* Returns the user named `username`, from `GET /api/users/@:username`, which
|
||||||
* answers a former name or another case too. The profile then puts the
|
* answers a former name or another case too. The profile then puts the
|
||||||
* current name in its URL, and that path, naming the user on screen, fetches
|
* current name in its URL, and that path, naming the user on screen, fetches
|
||||||
* nothing; another user's does. The socket's `user_updated` keeps them live.
|
* nothing; another user's does. The socket's `user_updated` keeps them live,
|
||||||
|
* and a reconnection fetches them again.
|
||||||
*/
|
*/
|
||||||
export function useProfile(username: string | undefined): UseProfileResult {
|
export function useProfile(username: string | undefined): UseProfileResult {
|
||||||
const { user: me } = useAuth();
|
const { user: me } = useAuth();
|
||||||
const { lastUserEvent } = useWS();
|
|
||||||
|
|
||||||
// The name last fetched, which the URL may leave for the current one.
|
// The name last fetched, which the URL may leave for the current one.
|
||||||
const [target, setTarget] = useState(username);
|
const [target, setTarget] = useState(username);
|
||||||
const { state, setData } = useFetchState(
|
const { state, setData, reload } = useFetchState(
|
||||||
target,
|
target,
|
||||||
(signal) => fetchPublicUser(target!, signal),
|
(signal) => fetchPublicUser(target!, signal),
|
||||||
);
|
);
|
||||||
@@ -47,10 +47,10 @@ export function useProfile(username: string | undefined): UseProfileResult {
|
|||||||
setTarget(username);
|
setTarget(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useWSEvent("user", ({ user }) => {
|
||||||
if (!lastUserEvent) return;
|
setData((u) => u.id === user.id ? user : u);
|
||||||
setData((u) => u.id === lastUserEvent.user.id ? lastUserEvent.user : u);
|
});
|
||||||
}, [lastUserEvent, setData]);
|
useOnReconnect(reload);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export interface UseFetchStateResult<T> {
|
|||||||
* loaded.
|
* loaded.
|
||||||
*/
|
*/
|
||||||
setData: (fn: (data: T) => T) => void;
|
setData: (fn: (data: T) => T) => void;
|
||||||
|
/**
|
||||||
|
* Loads the key's data again, and replaces the data loaded with it, as the
|
||||||
|
* live pages do on `useOnReconnect`. Until it arrives, the data shown stays,
|
||||||
|
* and so it does if the load fails. It does nothing until loaded.
|
||||||
|
*/
|
||||||
|
reload: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,5 +114,22 @@ export function useFetchState<T>(
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { state, setData };
|
const loadedRef = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
loadedRef.current = state.status === "loaded";
|
||||||
|
});
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
if (!loadedRef.current) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
// A new key's load aborts it, as it does the first load.
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
controllerRef.current = controller;
|
||||||
|
loadRef.current(controller.signal)
|
||||||
|
.then((data) => {
|
||||||
|
if (!controller.signal.aborted) setState({ status: "loaded", data });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, setData, reload };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Gives components the server's live updates. Each hook reads one of the three
|
* Gives components the server's live updates. Each hook reads one of the
|
||||||
* contexts `WSProvider` fills, so that a component re-renders only with the
|
* contexts `WSProvider` fills, so that a component re-renders only with the
|
||||||
* part it uses. Outside `WSProvider`, they return inert defaults.
|
* part it uses. Outside `WSProvider`, they return inert defaults.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useContext } from "react";
|
import { useContext, useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
WSChatContext,
|
WSChatContext,
|
||||||
type WSChatContextValue,
|
type WSChatContextValue,
|
||||||
WSContext,
|
WSContext,
|
||||||
type WSContextValue,
|
type WSContextValue,
|
||||||
|
type WSEvents,
|
||||||
|
WSEventsContext,
|
||||||
WSPresenceContext,
|
WSPresenceContext,
|
||||||
type WSPresenceContextValue,
|
type WSPresenceContextValue,
|
||||||
} from "../../contexts/ws/WSContext.ts";
|
} from "../../contexts/ws/WSContext.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the connection's status, the live votes, likes, dumps, playlists,
|
* Returns the connection's status, the live votes, likes, new and deleted
|
||||||
* comments and notifications, and the functions that vote and like. A
|
* dumps and playlists, the unread count, and the functions that vote and like.
|
||||||
* component using it re-renders on every one of those events.
|
* A component using it re-renders on every change to any of them.
|
||||||
*/
|
*/
|
||||||
export function useWS(): WSContextValue {
|
export function useWS(): WSContextValue {
|
||||||
return useContext(WSContext);
|
return useContext(WSContext);
|
||||||
@@ -38,3 +40,44 @@ export function useWSChat(): WSChatContextValue {
|
|||||||
export function useWSPresence(): WSPresenceContextValue {
|
export function useWSPresence(): WSPresenceContextValue {
|
||||||
return useContext(WSPresenceContext);
|
return useContext(WSPresenceContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `handler` with every event of `kind` the socket brings, as it
|
||||||
|
* arrives, for as long as the component is mounted. The handler applies it to
|
||||||
|
* what the component fetched, through state setters: it runs outside render,
|
||||||
|
* and always as the latest render defined it. The list sync hooks, the dump
|
||||||
|
* and playlist pages, the profile, the bell and the categories use it.
|
||||||
|
*/
|
||||||
|
export function useWSEvent<K extends keyof WSEvents>(
|
||||||
|
kind: K,
|
||||||
|
handler: (event: WSEvents[K]) => void,
|
||||||
|
): void {
|
||||||
|
const { subscribe } = useContext(WSEventsContext);
|
||||||
|
const handlerRef = useRef(handler);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
handlerRef.current = handler;
|
||||||
|
});
|
||||||
|
useEffect(
|
||||||
|
() => subscribe(kind, (event) => handlerRef.current(event)),
|
||||||
|
[subscribe, kind],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `callback` after each reconnection of the socket, never on the first
|
||||||
|
* connection, for the component to fetch again what it may have missed while
|
||||||
|
* the socket was down. The live lists and pages use it.
|
||||||
|
*/
|
||||||
|
export function useOnReconnect(callback: () => void): void {
|
||||||
|
const { connectionEpoch } = useWS();
|
||||||
|
const callbackRef = useRef(callback);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
callbackRef.current = callback;
|
||||||
|
});
|
||||||
|
const seenRef = useRef(connectionEpoch);
|
||||||
|
useEffect(() => {
|
||||||
|
if (connectionEpoch === seenRef.current) return;
|
||||||
|
seenRef.current = connectionEpoch;
|
||||||
|
callbackRef.current();
|
||||||
|
}, [connectionEpoch]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ import { useAuth } from "../hooks/auth/useAuth.ts";
|
|||||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||||
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
||||||
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
|
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
|
||||||
import { useWS } from "../hooks/ws/useWS.ts";
|
import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts";
|
||||||
|
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
||||||
import { Avatar } from "../components/shared/Avatar.tsx";
|
import { Avatar } from "../components/shared/Avatar.tsx";
|
||||||
import { RichContentCard } from "../components/dump/RichContentCard.tsx";
|
import { RichContentCard } from "../components/dump/RichContentCard.tsx";
|
||||||
import { FilePreview } from "../components/dump/FilePreview.tsx";
|
import { FilePreview } from "../components/dump/FilePreview.tsx";
|
||||||
@@ -65,9 +66,11 @@ type DumpState =
|
|||||||
* `GET /api/users/@:username/dumps/:slug`, or from the navigation's state when
|
* `GET /api/users/@:username/dumps/:slug`, or from the navigation's state when
|
||||||
* `DumpEdit` or a title edit here just saved it. Reached by a former name or
|
* `DumpEdit` or a title edit here just saved it. Reached by a former name or
|
||||||
* slug, the URL takes the current ones. Its comments, related dumps and
|
* slug, the URL takes the current ones. Its comments, related dumps and
|
||||||
* playlists come from their own endpoints once it is loaded, and
|
* playlists come from their own endpoints once it is loaded, and the socket's
|
||||||
* `WSContext`'s events keep them current. The poster and moderators can edit
|
* events keep them current; a reconnection fetches the dump and its comments
|
||||||
* the title in place, and get a link to `DumpEdit`.
|
* again. A dump deleted, or made private by its poster, while someone else
|
||||||
|
* views it gives way to a notice. The poster and moderators can edit the
|
||||||
|
* title in place, and get a link to `DumpEdit`.
|
||||||
*/
|
*/
|
||||||
export function Dump(): React.JSX.Element {
|
export function Dump(): React.JSX.Element {
|
||||||
const { handle, slug } = useParams();
|
const { handle, slug } = useParams();
|
||||||
@@ -126,9 +129,7 @@ export function Dump(): React.JSX.Element {
|
|||||||
myVotes,
|
myVotes,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
lastDumpEvent,
|
deletedDumpIds,
|
||||||
lastCommentEvent,
|
|
||||||
lastPlaylistEvent,
|
|
||||||
connectionEpoch,
|
connectionEpoch,
|
||||||
} = useWS();
|
} = useWS();
|
||||||
|
|
||||||
@@ -177,20 +178,67 @@ export function Dump(): React.JSX.Element {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [username, slug, preloaded, authFetch]);
|
}, [username, slug, preloaded, authFetch]);
|
||||||
|
|
||||||
useEffect(() => {
|
const replaceDump = (dump: Dump) =>
|
||||||
if (!lastDumpEvent) return;
|
setDumpState((prev) =>
|
||||||
// Mirrors the socket's edits, which only an effect can observe.
|
prev.status === "loaded" && prev.dump.id === dump.id
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
? { status: "loaded", dump }
|
||||||
setDumpState((prev) => {
|
: prev
|
||||||
if (prev.status !== "loaded" || prev.dump.id !== lastDumpEvent.id) {
|
);
|
||||||
return prev;
|
useWSEvent("dump", ({ dump }) => replaceDump(dump));
|
||||||
}
|
|
||||||
return { status: "loaded", dump: lastDumpEvent };
|
|
||||||
});
|
|
||||||
}, [lastDumpEvent]);
|
|
||||||
|
|
||||||
const loadedDumpId = loadedDump?.id ?? null;
|
const loadedDumpId = loadedDump?.id ?? null;
|
||||||
|
|
||||||
|
// The dump found gone on a reconnection. One deleted, or made private, live
|
||||||
|
// is in `deletedDumpIds`, which it leaves if made public again, and shows
|
||||||
|
// again.
|
||||||
|
const [vanishedId, setVanishedId] = useState<string | null>(null);
|
||||||
|
const vanished = !!loadedDumpId &&
|
||||||
|
(deletedDumpIds.has(loadedDumpId) || vanishedId === loadedDumpId);
|
||||||
|
|
||||||
|
// An edit or a deletion missed while the socket was down.
|
||||||
|
useOnReconnect(() => {
|
||||||
|
if (!loadedDumpId) return;
|
||||||
|
authFetch(`${API_URL}/api/dumps/${loadedDumpId}`, { cache: "no-store" })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.status === 404) return setVanishedId(loadedDumpId);
|
||||||
|
const body = parseAPIResponse<Wire<Dump>>(await res.json());
|
||||||
|
if (body.success) {
|
||||||
|
setVanishedId(null);
|
||||||
|
replaceDump(deserializeDump(body.data));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
useWSEvent("user", ({ user }) => {
|
||||||
|
setOp((op) => op?.id === user.id ? user : op);
|
||||||
|
setComments((prev) =>
|
||||||
|
prev.some((c) =>
|
||||||
|
c.userId === user.id && c.authorUsername !== user.username
|
||||||
|
)
|
||||||
|
? prev.map((c) =>
|
||||||
|
c.userId === user.id ? { ...c, authorUsername: user.username } : c
|
||||||
|
)
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
setDumpState((prev) =>
|
||||||
|
prev.status === "loaded" && prev.dump.userId === user.id &&
|
||||||
|
prev.dump.ownerUsername !== user.username
|
||||||
|
? {
|
||||||
|
status: "loaded",
|
||||||
|
dump: { ...prev.dump, ownerUsername: user.username },
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
useDumpListSync(relatedDumps, setRelatedDumps);
|
||||||
|
|
||||||
|
// Counts the playlist events, each of which may change the playlists
|
||||||
|
// holding the dump.
|
||||||
|
const [playlistsVersion, setPlaylistsVersion] = useState(0);
|
||||||
|
useWSEvent("playlist", () => setPlaylistsVersion((n) => n + 1));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadedDumpId) return;
|
if (!loadedDumpId) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -244,8 +292,8 @@ export function Dump(): React.JSX.Element {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
// Any playlist event may change the list, and they are rare enough to
|
// Any playlist event may change the list, and they are rare enough to
|
||||||
// refetch on each.
|
// refetch on each, as on a reconnection.
|
||||||
}, [loadedDumpId, authFetch, lastPlaylistEvent]);
|
}, [loadedDumpId, authFetch, playlistsVersion, connectionEpoch]);
|
||||||
|
|
||||||
// A notification links to `#comment-<id>`. Rerun when the comments arrive,
|
// A notification links to `#comment-<id>`. Rerun when the comments arrive,
|
||||||
// since the element doesn't exist before.
|
// since the element doesn't exist before.
|
||||||
@@ -263,41 +311,25 @@ export function Dump(): React.JSX.Element {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [comments, location.hash]);
|
}, [comments, location.hash]);
|
||||||
|
|
||||||
useEffect(() => {
|
useWSEvent("comment", (ev) => {
|
||||||
if (
|
if (!loadedDumpId || ev.dumpId !== loadedDumpId) return;
|
||||||
!lastCommentEvent || !loadedDumpId ||
|
|
||||||
lastCommentEvent.dumpId !== loadedDumpId
|
|
||||||
) return;
|
|
||||||
// A comment of this user's arrives both from the socket and from
|
// A comment of this user's arrives both from the socket and from
|
||||||
// CommentThread's callbacks below, hence the duplicate checks.
|
// CommentThread's callbacks below, hence the duplicate checks.
|
||||||
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
|
const { comment, commentId } = ev;
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
if (ev.type === "created" && comment) {
|
||||||
setComments((prev) => {
|
setComments((prev) =>
|
||||||
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) {
|
prev.some((c) => c.id === comment.id) ? prev : [...prev, comment]
|
||||||
return prev;
|
);
|
||||||
}
|
} else if (ev.type === "deleted" && commentId) {
|
||||||
return [...prev, lastCommentEvent.comment!];
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
lastCommentEvent.type === "deleted" && lastCommentEvent.commentId
|
|
||||||
) {
|
|
||||||
setComments((prev) =>
|
setComments((prev) =>
|
||||||
prev.map((c) =>
|
prev.map((c) =>
|
||||||
c.id === lastCommentEvent.commentId
|
c.id === commentId ? { ...c, deleted: true, body: "" } : c
|
||||||
? { ...c, deleted: true, body: "" }
|
|
||||||
: c
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
lastCommentEvent.type === "updated" && lastCommentEvent.comment
|
|
||||||
) {
|
|
||||||
setComments((prev) =>
|
|
||||||
prev.map((c) =>
|
|
||||||
c.id === lastCommentEvent.comment!.id ? lastCommentEvent.comment! : c
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} else if (ev.type === "updated" && comment) {
|
||||||
|
setComments((prev) => prev.map((c) => c.id === comment.id ? comment : c));
|
||||||
}
|
}
|
||||||
}, [lastCommentEvent, loadedDumpId]);
|
});
|
||||||
|
|
||||||
if (dumpState.status === "loading") {
|
if (dumpState.status === "loading") {
|
||||||
return (
|
return (
|
||||||
@@ -309,6 +341,23 @@ export function Dump(): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (vanished) {
|
||||||
|
return (
|
||||||
|
<PageError
|
||||||
|
message={t`This dump was deleted, or made private.`}
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
className="btn-border"
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
<Trans>← Back to all dumps</Trans>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (dumpState.status === "error") {
|
if (dumpState.status === "error") {
|
||||||
return (
|
return (
|
||||||
<PageError
|
<PageError
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { Navigate, useLocation } from "react-router";
|
import { Navigate, useLocation } from "react-router";
|
||||||
|
|
||||||
import { AppHeader } from "../components/layout/AppHeader.tsx";
|
import { AppHeader } from "../components/layout/AppHeader.tsx";
|
||||||
@@ -27,7 +27,8 @@ import {
|
|||||||
} from "../hooks/feed/useScrollSave.ts";
|
} from "../hooks/feed/useScrollSave.ts";
|
||||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||||
import { useWS } from "../hooks/ws/useWS.ts";
|
import { useOnReconnect, useWS } from "../hooks/ws/useWS.ts";
|
||||||
|
import { useCategoryFilter } from "../hooks/category/useCategoryFilter.ts";
|
||||||
import { useCategories } from "../hooks/category/useCategories.ts";
|
import { useCategories } from "../hooks/category/useCategories.ts";
|
||||||
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
|
||||||
@@ -67,7 +68,7 @@ export function Index(
|
|||||||
?.deletedDumpId;
|
?.deletedDumpId;
|
||||||
|
|
||||||
const { user, authFetch } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
const { recentDumps, deletedDumpIds, connectionEpoch } = useWS();
|
const { recentDumps, deletedDumpIds } = useWS();
|
||||||
|
|
||||||
const { categories, isLoaded: categoriesLoaded } = useCategories("dump");
|
const { categories, isLoaded: categoriesLoaded } = useCategories("dump");
|
||||||
const category = categorySlug
|
const category = categorySlug
|
||||||
@@ -96,12 +97,13 @@ export function Index(
|
|||||||
`feed:index:${categorySlug ?? "all"}`,
|
`feed:index:${categorySlug ?? "all"}`,
|
||||||
hydrateDump,
|
hydrateDump,
|
||||||
);
|
);
|
||||||
const { state: dumpsState, setItems, loadMore } = usePagedList(
|
const { state: dumpsState, setItems, loadMore, resync } = usePagedList(
|
||||||
`${feedUrl}:${user?.id ?? "guest"}`,
|
`${feedUrl}:${user?.id ?? "guest"}`,
|
||||||
fetchPage,
|
fetchPage,
|
||||||
cached,
|
cached,
|
||||||
);
|
);
|
||||||
useDumpListSync(setItems);
|
// A dump edited out of the category leaves its feed.
|
||||||
|
const setScopedItems = useCategoryFilter(setItems, category?.id);
|
||||||
|
|
||||||
// The Web Share Target: Android's share sheet opens `/?share_url=…`. The
|
// The Web Share Target: Android's share sheet opens `/?share_url=…`. The
|
||||||
// query is dropped from the URL at once, so a reload doesn't share the link
|
// query is dropped from the URL at once, so a reload doesn't share the link
|
||||||
@@ -115,25 +117,9 @@ export function Index(
|
|||||||
globalThis.history.replaceState({}, "", location.pathname);
|
globalThis.history.replaceState({}, "", location.pathname);
|
||||||
}, [shareUrl, location.pathname]);
|
}, [shareUrl, location.pathname]);
|
||||||
|
|
||||||
// Dumps posted while the socket was down never arrived. On reconnect, the
|
// What changed while the socket was down never arrived: the pages loaded
|
||||||
// first page is fetched again and whatever is new goes on top, keeping the
|
// are fetched again, keeping the scroll position.
|
||||||
// pages loaded and the scroll position.
|
useOnReconnect(resync);
|
||||||
const backfillEpochRef = useRef(connectionEpoch);
|
|
||||||
useEffect(() => {
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const sentinelRef = useInfiniteScroll(
|
const sentinelRef = useInfiniteScroll(
|
||||||
loadMore,
|
loadMore,
|
||||||
@@ -153,6 +139,8 @@ export function Index(
|
|||||||
() => dumpsState.status === "loaded" ? dumpsState.data.items : [],
|
() => dumpsState.status === "loaded" ? dumpsState.data.items : [],
|
||||||
[dumpsState],
|
[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.
|
// A category's feed takes only the new dumps filed under it.
|
||||||
const categoryId = category?.id;
|
const categoryId = category?.id;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
|||||||
import { ListState } from "../components/shared/ListState.tsx";
|
import { ListState } from "../components/shared/ListState.tsx";
|
||||||
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
||||||
import { FeedFooter } from "../components/feed/FeedFooter.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 {
|
import type {
|
||||||
CommentLikedData,
|
CommentLikedData,
|
||||||
DumpCommentedData,
|
DumpCommentedData,
|
||||||
@@ -386,11 +386,7 @@ function NotificationItem({ n }: { n: Notification }) {
|
|||||||
export function Notifications(): React.JSX.Element {
|
export function Notifications(): React.JSX.Element {
|
||||||
useDocumentTitle(t`Notifications`);
|
useDocumentTitle(t`Notifications`);
|
||||||
const { user, authFetch } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
const {
|
const { clearUnreadNotifications, connectionEpoch } = useWS();
|
||||||
clearUnreadNotifications,
|
|
||||||
lastNotification,
|
|
||||||
connectionEpoch,
|
|
||||||
} = useWS();
|
|
||||||
const { cached, saveState } = useFeedCache<Notification>(
|
const { cached, saveState } = useFeedCache<Notification>(
|
||||||
"feed:notifications",
|
"feed:notifications",
|
||||||
hydrateNotification,
|
hydrateNotification,
|
||||||
@@ -414,17 +410,12 @@ export function Notifications(): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
const loaded = state.status === "loaded" ? state.data : null;
|
const loaded = state.status === "loaded" ? state.data : null;
|
||||||
|
|
||||||
// What arrives while the page is open goes on top, newest first. Each new
|
// What arrives while the page is open goes on top, newest first. The list
|
||||||
// `lastNotification` is kept once, during render rather than in an effect,
|
// may predate it or not.
|
||||||
// so it shows in the same frame. The list may predate it or not.
|
|
||||||
const [arrived, setArrived] = useState<Notification[]>([]);
|
const [arrived, setArrived] = useState<Notification[]>([]);
|
||||||
const [prevLastNotification, setPrevLastNotification] = useState(
|
useWSEvent("notification", (notification) => {
|
||||||
lastNotification,
|
setArrived((a) => newestFirst([notification], a));
|
||||||
);
|
});
|
||||||
if (prevLastNotification !== lastNotification && lastNotification !== null) {
|
|
||||||
setPrevLastNotification(lastNotification);
|
|
||||||
setArrived((a) => newestFirst([lastNotification], a));
|
|
||||||
}
|
|
||||||
|
|
||||||
// What came while the page wasn't listening, on a reconnect and on 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
|
// restored list, is fetched as the first page again and arrives with the
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
useScrollSave,
|
useScrollSave,
|
||||||
} from "../hooks/feed/useScrollSave.ts";
|
} from "../hooks/feed/useScrollSave.ts";
|
||||||
import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
|
import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
|
||||||
|
import { useOnReconnect } from "../hooks/ws/useWS.ts";
|
||||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||||
import { PageHeader } from "../components/layout/PageHeader.tsx";
|
import { PageHeader } from "../components/layout/PageHeader.tsx";
|
||||||
import { CategorySwitcher } from "../components/category/CategorySwitcher.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
|
* Renders the public playlists, from `GET /api/playlists`, a page at a time
|
||||||
* as the list scrolls; with `categorySlug`, only those in that playlist
|
* as the list scrolls; with `categorySlug`, only those in that playlist
|
||||||
* category, which the menu in the title row switches. The socket keeps them
|
* 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(
|
function PlaylistsList(
|
||||||
{ categorySlug, categoryId }: { categorySlug?: string; categoryId?: string },
|
{ categorySlug, categoryId }: { categorySlug?: string; categoryId?: string },
|
||||||
@@ -79,7 +81,9 @@ function PlaylistsList(
|
|||||||
const url = `${API_URL}/api/playlists${
|
const url = `${API_URL}/api/playlists${
|
||||||
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
||||||
}`;
|
}`;
|
||||||
const { state, setItems: setAllItems, loadMore } = usePagedList<Playlist>(
|
const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
|
||||||
|
Playlist
|
||||||
|
>(
|
||||||
`${url}:${user?.id ?? "guest"}`,
|
`${url}:${user?.id ?? "guest"}`,
|
||||||
useCallback(
|
useCallback(
|
||||||
(page: number, signal?: AbortSignal) =>
|
(page: number, signal?: AbortSignal) =>
|
||||||
@@ -90,7 +94,11 @@ function PlaylistsList(
|
|||||||
cached,
|
cached,
|
||||||
);
|
);
|
||||||
const loaded = state.status === "loaded" ? state.data : null;
|
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));
|
const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded));
|
||||||
useScrollSave(
|
useScrollSave(
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
} from "../utils/feed/pagedList.ts";
|
} from "../utils/feed/pagedList.ts";
|
||||||
import { useFetchState } from "../hooks/shared/useFetchState.ts";
|
import { useFetchState } from "../hooks/shared/useFetchState.ts";
|
||||||
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
|
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
|
||||||
|
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
||||||
|
import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
|
||||||
import {
|
import {
|
||||||
SEARCH_TABS,
|
SEARCH_TABS,
|
||||||
searchPath,
|
searchPath,
|
||||||
@@ -77,7 +79,8 @@ const TAB_CATEGORIES: Partial<
|
|||||||
* Renders `/search?q=` and `/search/:searchTab?q=`, for anyone. The results come
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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,
|
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(
|
const sentinelRef = useInfiniteScroll(
|
||||||
loadMore,
|
loadMore,
|
||||||
tab === "dumps" && canLoadMore(dumps),
|
tab === "dumps" && canLoadMore(dumps),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { userEvent } from "@testing-library/user-event";
|
|||||||
import { NotificationBell } from "../../../components/layout/NotificationBell.tsx";
|
import { NotificationBell } from "../../../components/layout/NotificationBell.tsx";
|
||||||
import { makeNotification } from "../../fixtures.ts";
|
import { makeNotification } from "../../fixtures.ts";
|
||||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||||
import { defaultWSValue, WSContext } from "../../../contexts/ws/WSContext.ts";
|
import { createWSEventBus } from "../../../contexts/ws/WSContext.ts";
|
||||||
|
|
||||||
describe("NotificationBell", () => {
|
describe("NotificationBell", () => {
|
||||||
test("the bell shows unread notifications and goes to them", async () => {
|
test("the bell shows unread notifications and goes to them", async () => {
|
||||||
@@ -20,33 +20,28 @@ describe("NotificationBell", () => {
|
|||||||
expect(currentPath()).toBe("/notifications");
|
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();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
const n1 = makeNotification({ id: "n1" });
|
const events = createWSEventBus();
|
||||||
const withLast = (n: ReturnType<typeof makeNotification>) => (
|
renderWithContexts(<NotificationBell />, { events });
|
||||||
<WSContext.Provider
|
const ring = (id: string) =>
|
||||||
value={{ ...defaultWSValue, lastNotification: n }}
|
act(() => events.emit("notification", makeNotification({ id })));
|
||||||
>
|
|
||||||
<NotificationBell />
|
|
||||||
</WSContext.Provider>
|
|
||||||
);
|
|
||||||
const { rerender } = renderWithContexts(withLast(n1));
|
|
||||||
const bell = () => screen.getByRole("button", { name: "Notifications" });
|
const bell = () => screen.getByRole("button", { name: "Notifications" });
|
||||||
act(() => vi.advanceTimersByTime(10));
|
act(() => vi.advanceTimersByTime(10));
|
||||||
expect(bell()).not.toHaveClass("notification-bell--ringing");
|
expect(bell()).not.toHaveClass("notification-bell--ringing");
|
||||||
rerender(withLast(makeNotification({ id: "n2" })));
|
ring("n2");
|
||||||
act(() => vi.advanceTimersByTime(10));
|
act(() => vi.advanceTimersByTime(10));
|
||||||
expect(bell()).toHaveClass("notification-bell--ringing");
|
expect(bell()).toHaveClass("notification-bell--ringing");
|
||||||
act(() => vi.advanceTimersByTime(700));
|
act(() => vi.advanceTimersByTime(700));
|
||||||
expect(bell()).not.toHaveClass("notification-bell--ringing");
|
expect(bell()).not.toHaveClass("notification-bell--ringing");
|
||||||
// Another in the middle of a ring doesn't leave the bell rung.
|
// Another in the middle of a ring doesn't leave the bell rung.
|
||||||
rerender(withLast(makeNotification({ id: "n3" })));
|
ring("n3");
|
||||||
act(() => vi.advanceTimersByTime(100));
|
act(() => vi.advanceTimersByTime(100));
|
||||||
rerender(withLast(makeNotification({ id: "n4" })));
|
ring("n4");
|
||||||
act(() => vi.advanceTimersByTime(700));
|
act(() => vi.advanceTimersByTime(700));
|
||||||
expect(bell()).not.toHaveClass("notification-bell--ringing");
|
expect(bell()).not.toHaveClass("notification-bell--ringing");
|
||||||
rerender(withLast(makeNotification({ id: "n5" })));
|
ring("n5");
|
||||||
act(() => vi.advanceTimersByTime(10));
|
act(() => vi.advanceTimersByTime(10));
|
||||||
expect(bell()).toHaveClass("notification-bell--ringing");
|
expect(bell()).toHaveClass("notification-bell--ringing");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CategoriesProvider } from "../../../contexts/category/CategoriesProvide
|
|||||||
import { useCategories } from "../../../hooks/category/useCategories.ts";
|
import { useCategories } from "../../../hooks/category/useCategories.ts";
|
||||||
import { fail, route } from "../../fetch.ts";
|
import { fail, route } from "../../fetch.ts";
|
||||||
import { makeCategory, wire } from "../../fixtures.ts";
|
import { makeCategory, wire } from "../../fixtures.ts";
|
||||||
|
import { liveWS } from "../../live.tsx";
|
||||||
|
|
||||||
describe("CategoriesProvider", () => {
|
describe("CategoriesProvider", () => {
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
@@ -64,4 +65,21 @@ describe("CategoriesProvider", () => {
|
|||||||
vi.useRealTimers();
|
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 }) => (
|
||||||
|
<ws.Wrapper>
|
||||||
|
<CategoriesProvider>{children}</CategoriesProvider>
|
||||||
|
</ws.Wrapper>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { act, renderHook } from "@testing-library/react";
|
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 { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
|
||||||
import { WSProvider } from "../../../contexts/ws/WSProvider.tsx";
|
import { WSProvider } from "../../../contexts/ws/WSProvider.tsx";
|
||||||
import { AvatarOverrideContext } from "../../../contexts/ws/AvatarOverrideContext.ts";
|
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 { useAuth } from "../../../hooks/auth/useAuth.ts";
|
||||||
import { FakeWebSocket } from "../../ws.ts";
|
import { FakeWebSocket } from "../../ws.ts";
|
||||||
import { defaultRoutes } from "../../render.tsx";
|
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<K extends keyof WSEvents>(kind: K): WSEvents[K][] {
|
||||||
|
// One array for the hook's life, which the subscription fills.
|
||||||
|
const [received] = useState<WSEvents[K][]>(() => []);
|
||||||
|
useWSEvent(kind, (event) => received.push(event));
|
||||||
|
return received;
|
||||||
|
}
|
||||||
|
|
||||||
function setup({ signedIn = true } = {}) {
|
function setup({ signedIn = true } = {}) {
|
||||||
if (signedIn) storeSession(me);
|
if (signedIn) storeSession(me);
|
||||||
defaultRoutes({ session: signedIn ? me : null });
|
defaultRoutes({ session: signedIn ? me : null });
|
||||||
@@ -38,11 +52,25 @@ function setup({ signedIn = true } = {}) {
|
|||||||
ws: { ...useWS(), ...useWSChat(), ...useWSPresence() },
|
ws: { ...useWS(), ...useWSChat(), ...useWSPresence() },
|
||||||
auth: useAuth(),
|
auth: useAuth(),
|
||||||
avatars: useContext(AvatarOverrideContext),
|
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 },
|
{ wrapper },
|
||||||
);
|
);
|
||||||
const socket = FakeWebSocket.last;
|
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(() =>
|
beforeEach(() =>
|
||||||
@@ -227,7 +255,7 @@ describe("votes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("votes_update from another tab of mine syncs myVotes", () => {
|
test("votes_update from another tab of mine syncs myVotes", () => {
|
||||||
const { socket, ws } = setup();
|
const { socket, ws, events } = setup();
|
||||||
socket.welcome();
|
socket.welcome();
|
||||||
socket.receive({
|
socket.receive({
|
||||||
type: "votes_update",
|
type: "votes_update",
|
||||||
@@ -237,7 +265,7 @@ describe("votes", () => {
|
|||||||
action: "cast",
|
action: "cast",
|
||||||
});
|
});
|
||||||
expect(ws().myVotes.has("d9")).toBe(true);
|
expect(ws().myVotes.has("d9")).toBe(true);
|
||||||
expect(ws().lastVoteEvent).toEqual({
|
expect(events().vote.at(-1)).toEqual({
|
||||||
dumpId: "d9",
|
dumpId: "d9",
|
||||||
voterId: "user-me",
|
voterId: "user-me",
|
||||||
action: "cast",
|
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", () => {
|
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.welcome();
|
||||||
socket.receive({
|
socket.receive({
|
||||||
type: "dump_created",
|
type: "dump_created",
|
||||||
@@ -415,7 +443,11 @@ describe("dumps, playlists, comments, users", () => {
|
|||||||
"a",
|
"a",
|
||||||
"two",
|
"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", () => {
|
test("deletion removes from the live buffer", () => {
|
||||||
@@ -435,23 +467,23 @@ describe("dumps, playlists, comments, users", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("playlist events", () => {
|
test("playlist events", () => {
|
||||||
const { socket, ws } = setup();
|
const { socket, ws, events } = setup();
|
||||||
socket.welcome();
|
socket.welcome();
|
||||||
const p = makePlaylist({ id: "p" });
|
const p = makePlaylist({ id: "p" });
|
||||||
socket.receive({ type: "playlist_created", playlist: wire(p) });
|
socket.receive({ type: "playlist_created", playlist: wire(p) });
|
||||||
expect(ws().lastPlaylistEvent).toMatchObject({
|
expect(events().playlist.at(-1)).toMatchObject({
|
||||||
type: "created",
|
type: "created",
|
||||||
playlistId: "p",
|
playlistId: "p",
|
||||||
});
|
});
|
||||||
socket.receive({ type: "playlist_updated", playlist: wire(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({
|
socket.receive({
|
||||||
type: "playlist_dumps_updated",
|
type: "playlist_dumps_updated",
|
||||||
playlistId: "p",
|
playlistId: "p",
|
||||||
dumpIds: ["x"],
|
dumpIds: ["x"],
|
||||||
dumpCount: 2,
|
dumpCount: 2,
|
||||||
});
|
});
|
||||||
expect(ws().lastPlaylistEvent).toEqual({
|
expect(events().playlist.at(-1)).toEqual({
|
||||||
type: "dumps_updated",
|
type: "dumps_updated",
|
||||||
playlistId: "p",
|
playlistId: "p",
|
||||||
dumpIds: ["x"],
|
dumpIds: ["x"],
|
||||||
@@ -459,27 +491,50 @@ describe("dumps, playlists, comments, users", () => {
|
|||||||
});
|
});
|
||||||
socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" });
|
socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" });
|
||||||
expect(ws().deletedPlaylistIds.has("p")).toBe(true);
|
expect(ws().deletedPlaylistIds.has("p")).toBe(true);
|
||||||
expect(ws().lastPlaylistEvent).toEqual({
|
expect(events().playlist.at(-1)).toEqual({
|
||||||
type: "deleted",
|
type: "deleted",
|
||||||
playlistId: "p",
|
playlistId: "p",
|
||||||
userId: "u",
|
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", () => {
|
test("comment events", () => {
|
||||||
const { socket, ws } = setup();
|
const { socket, events } = setup();
|
||||||
socket.welcome();
|
socket.welcome();
|
||||||
const c = makeComment({ id: "c", dumpId: "d" });
|
const c = makeComment({ id: "c", dumpId: "d" });
|
||||||
socket.receive({ type: "comment_created", comment: wire(c) });
|
socket.receive({ type: "comment_created", comment: wire(c) });
|
||||||
expect(ws().lastCommentEvent).toMatchObject({
|
expect(events().comment.at(-1)).toMatchObject({
|
||||||
type: "created",
|
type: "created",
|
||||||
dumpId: "d",
|
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) });
|
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" });
|
socket.receive({ type: "comment_deleted", commentId: "c", dumpId: "d" });
|
||||||
expect(ws().lastCommentEvent).toEqual({
|
expect(events().comment.at(-1)).toEqual({
|
||||||
type: "deleted",
|
type: "deleted",
|
||||||
dumpId: "d",
|
dumpId: "d",
|
||||||
commentId: "c",
|
commentId: "c",
|
||||||
@@ -487,7 +542,7 @@ describe("dumps, playlists, comments, users", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("user_updated overrides that user's avatar everywhere", () => {
|
test("user_updated overrides that user's avatar everywhere", () => {
|
||||||
const { socket, hook } = setup();
|
const { socket, hook, events } = setup();
|
||||||
socket.welcome();
|
socket.welcome();
|
||||||
const u = makeUser({
|
const u = makeUser({
|
||||||
id: "u1",
|
id: "u1",
|
||||||
@@ -499,7 +554,7 @@ describe("dumps, playlists, comments, users", () => {
|
|||||||
hasAvatar: true,
|
hasAvatar: true,
|
||||||
version: 5000,
|
version: 5000,
|
||||||
});
|
});
|
||||||
expect(hook.result.current.ws.lastUserEvent?.user.id).toBe("u1");
|
expect(events().user.at(-1)?.user.id).toBe("u1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("presence updates", () => {
|
test("presence updates", () => {
|
||||||
@@ -514,18 +569,32 @@ describe("dumps, playlists, comments, users", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("notifications", () => {
|
describe("notifications", () => {
|
||||||
test("a new one bumps the count and is remembered", () => {
|
test("a new one bumps the count and is passed on", () => {
|
||||||
const { socket, ws } = setup();
|
const { socket, ws, events } = setup();
|
||||||
socket.welcome({ unreadNotificationCount: 1 });
|
socket.welcome({ unreadNotificationCount: 1 });
|
||||||
socket.receive({
|
socket.receive({
|
||||||
type: "notification_created",
|
type: "notification_created",
|
||||||
notification: wire(makeNotification({ id: "n" })),
|
notification: wire(makeNotification({ id: "n" })),
|
||||||
});
|
});
|
||||||
expect(ws().unreadNotificationCount).toBe(2);
|
expect(ws().unreadNotificationCount).toBe(2);
|
||||||
expect(ws().lastNotification?.id).toBe("n");
|
expect(events().notification.map((n) => n.id)).toEqual(["n"]);
|
||||||
act(() => ws().clearUnreadNotifications());
|
act(() => ws().clearUnreadNotifications());
|
||||||
expect(ws().unreadNotificationCount).toBe(0);
|
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", () => {
|
describe("chat", () => {
|
||||||
|
|||||||
@@ -1,93 +1,144 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { type ReactNode, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
|
||||||
defaultWSValue,
|
|
||||||
WSContext,
|
|
||||||
type WSContextValue,
|
|
||||||
} from "../../../contexts/ws/WSContext.ts";
|
|
||||||
import type { Dump } from "../../../../shared/dump.ts";
|
import type { Dump } from "../../../../shared/dump.ts";
|
||||||
import { useDumpListSync } from "../../../hooks/dump/useDumpListSync.ts";
|
import {
|
||||||
import { makeDump } from "../../fixtures.ts";
|
type DumpListSyncOptions,
|
||||||
|
useDumpListSync,
|
||||||
/** A WS context whose value a test can push new events into. */
|
} from "../../../hooks/dump/useDumpListSync.ts";
|
||||||
function liveWS() {
|
import { makeDump, makeUser } from "../../fixtures.ts";
|
||||||
let push: (patch: Partial<WSContextValue>) => void = () => {};
|
import { liveWS } from "../../live.tsx";
|
||||||
const base = defaultWSValue;
|
|
||||||
function Wrapper({ children }: { children: ReactNode }) {
|
|
||||||
const [value, setValue] = useState(base);
|
|
||||||
push = (patch) => setValue((v) => ({ ...v, ...patch }));
|
|
||||||
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
Wrapper,
|
|
||||||
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useList<T>(initial: T[]) {
|
|
||||||
const [items, setItems] = useState(initial);
|
|
||||||
return { items, setItems };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
|
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", () => {
|
describe("useDumpListSync", () => {
|
||||||
function setup(
|
function setup(initial: Dump[], opts?: DumpListSyncOptions) {
|
||||||
initial: Dump[],
|
|
||||||
opts?: Parameters<typeof useDumpListSync>[1],
|
|
||||||
) {
|
|
||||||
const ws = liveWS();
|
const ws = liveWS();
|
||||||
const hook = renderHook(() => {
|
const hook = renderHook(() => {
|
||||||
const list = useList(initial);
|
const [items, setItems] = useState(initial);
|
||||||
useDumpListSync(list.setItems, opts);
|
useDumpListSync(items, setItems, opts);
|
||||||
return list;
|
return { items, setItems };
|
||||||
}, { wrapper: ws.Wrapper });
|
}, { 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", () => {
|
test("drops deleted dumps", () => {
|
||||||
const { hook, emit } = setup([
|
const { set, items } = setup([
|
||||||
makeDump({ id: "a" }),
|
makeDump({ id: "a" }),
|
||||||
makeDump({ id: "b" }),
|
makeDump({ id: "b" }),
|
||||||
]);
|
]);
|
||||||
emit({ deletedDumpIds: new Set(["a"]) });
|
set({ deletedDumpIds: new Set(["a"]) });
|
||||||
expect(ids(hook.result.current.items)).toEqual(["b"]);
|
expect(ids(items())).toEqual(["b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("updates a dump in place", () => {
|
test("updates a dump in place", () => {
|
||||||
const { hook, emit } = setup([makeDump({ id: "a", title: "old" })]);
|
const { update, items } = setup([makeDump({ id: "a", title: "old" })]);
|
||||||
emit({ lastDumpEvent: makeDump({ id: "a", title: "new" }) });
|
update(makeDump({ id: "a", title: "new" }));
|
||||||
expect(hook.result.current.items[0].title).toBe("new");
|
expect(items()[0].title).toBe("new");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a dump gone private leaves others' lists but not the owner's", () => {
|
test("a dump gone private leaves others' lists but not the owner's", () => {
|
||||||
const a = makeDump({ id: "a" });
|
const a = makeDump({ id: "a" });
|
||||||
const viewer = setup([a]);
|
const viewer = setup([a]);
|
||||||
viewer.emit({ lastDumpEvent: { ...a, isPrivate: true } });
|
viewer.update({ ...a, isPrivate: true });
|
||||||
expect(viewer.hook.result.current.items).toEqual([]);
|
expect(viewer.items()).toEqual([]);
|
||||||
|
|
||||||
const owner = setup([a], { isOwner: true });
|
const owner = setup([a], { isOwner: true });
|
||||||
owner.emit({ lastDumpEvent: { ...a, isPrivate: true } });
|
owner.update({ ...a, isPrivate: true });
|
||||||
expect(owner.hook.result.current.items[0].isPrivate).toBe(true);
|
expect(owner.items()[0].isPrivate).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("never adds a dump the list doesn't hold", () => {
|
test("without `belongs`, never adds a dump the list doesn't hold", () => {
|
||||||
const { hook, emit } = setup([]);
|
const { create, items } = setup([]);
|
||||||
emit({ lastDumpEvent: makeDump({ id: "m", userId: "me" }) });
|
create(makeDump({ id: "m", userId: "me" }));
|
||||||
expect(hook.result.current.items).toEqual([]);
|
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", () => {
|
test("comment events move the count, never below zero; edits don't", () => {
|
||||||
const { hook, emit } = setup([makeDump({ id: "a", commentCount: 0 })]);
|
const { emit, items } = setup([makeDump({ id: "a", commentCount: 0 })]);
|
||||||
emit({ lastCommentEvent: { type: "created", dumpId: "a" } });
|
emit("comment", { type: "created", dumpId: "a" });
|
||||||
expect(hook.result.current.items[0].commentCount).toBe(1);
|
expect(items()[0].commentCount).toBe(1);
|
||||||
emit({ lastCommentEvent: { type: "updated", dumpId: "a" } });
|
emit("comment", { type: "updated", dumpId: "a" });
|
||||||
expect(hook.result.current.items[0].commentCount).toBe(1);
|
expect(items()[0].commentCount).toBe(1);
|
||||||
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
|
emit("comment", { type: "deleted", dumpId: "a" });
|
||||||
emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
|
emit("comment", { type: "deleted", dumpId: "a" });
|
||||||
expect(hook.result.current.items[0].commentCount).toBe(0);
|
expect(items()[0].commentCount).toBe(0);
|
||||||
emit({ lastCommentEvent: { type: "created", dumpId: "zzz" } });
|
emit("comment", { type: "created", dumpId: "zzz" });
|
||||||
expect(hook.result.current.items).toHaveLength(1);
|
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"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
|||||||
import { usePagedList } from "../../../hooks/feed/usePagedList.ts";
|
import { usePagedList } from "../../../hooks/feed/usePagedList.ts";
|
||||||
import { deferred } from "../../fetch.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", () => {
|
describe("usePagedList", () => {
|
||||||
test("loads the first page, then appends the next ones", async () => {
|
test("loads the first page, then appends the next ones", async () => {
|
||||||
const fetchPage = vi.fn((page: number) =>
|
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));
|
const { result } = renderHook(() => usePagedList("k", fetchPage));
|
||||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||||
@@ -14,7 +18,12 @@ describe("usePagedList", () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.state).toEqual({
|
expect(result.current.state).toEqual({
|
||||||
status: "loaded",
|
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());
|
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 () => {
|
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) =>
|
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));
|
const { result } = renderHook(() => usePagedList("k", fetchPage));
|
||||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||||
@@ -36,29 +47,62 @@ describe("usePagedList", () => {
|
|||||||
await next.promise.catch(() => {});
|
await next.promise.catch(() => {});
|
||||||
});
|
});
|
||||||
expect(result.current.state).toMatchObject({
|
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 () => {
|
test("a cached list shows as it was, and carries on from its page", async () => {
|
||||||
const fetchPage = vi.fn((page: number) =>
|
const fetchPage = vi.fn((page: number) =>
|
||||||
Promise.resolve({ items: [page], hasMore: false })
|
Promise.resolve({ items: items(page), hasMore: false })
|
||||||
);
|
);
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
usePagedList("k", fetchPage, {
|
usePagedList("k", fetchPage, {
|
||||||
items: [1, 2],
|
items: items(1, 2),
|
||||||
page: 2,
|
page: 2,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
scrollY: 0,
|
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());
|
act(() => result.current.loadMore());
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.state).toMatchObject({
|
expect(result.current.state).toMatchObject({
|
||||||
data: { items: [1, 2, 3] },
|
data: { items: items(1, 2, 3) },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
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]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<WSContextValue>) => void = () => {};
|
|
||||||
const base = defaultWSValue;
|
|
||||||
function Wrapper({ children }: { children: ReactNode }) {
|
|
||||||
const [value, setValue] = useState(base);
|
|
||||||
push = (patch) => setValue((v) => ({ ...v, ...patch }));
|
|
||||||
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
Wrapper,
|
|
||||||
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useList<T>(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<Dump | null>(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<Playlist | null>(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"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,155 +1,120 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { renderHook } from "@testing-library/react";
|
||||||
import { type ReactNode, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
|
||||||
defaultWSValue,
|
|
||||||
WSContext,
|
|
||||||
type WSContextValue,
|
|
||||||
} from "../../../contexts/ws/WSContext.ts";
|
|
||||||
import type { Playlist } from "../../../../shared/playlist.ts";
|
import type { Playlist } from "../../../../shared/playlist.ts";
|
||||||
import { usePlaylistListSync } from "../../../hooks/playlist/usePlaylistListSync.ts";
|
import {
|
||||||
import { makePlaylist } from "../../fixtures.ts";
|
type PlaylistListSyncOptions,
|
||||||
|
usePlaylistListSync,
|
||||||
/** A WS context whose value a test can push new events into. */
|
} from "../../../hooks/playlist/usePlaylistListSync.ts";
|
||||||
function liveWS() {
|
import { makePlaylist, makeUser } from "../../fixtures.ts";
|
||||||
let push: (patch: Partial<WSContextValue>) => void = () => {};
|
import { liveWS } from "../../live.tsx";
|
||||||
const base = defaultWSValue;
|
|
||||||
function Wrapper({ children }: { children: ReactNode }) {
|
|
||||||
const [value, setValue] = useState(base);
|
|
||||||
push = (patch) => setValue((v) => ({ ...v, ...patch }));
|
|
||||||
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
Wrapper,
|
|
||||||
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useList<T>(initial: T[]) {
|
|
||||||
const [items, setItems] = useState(initial);
|
|
||||||
return { items, setItems };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
|
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
|
||||||
|
|
||||||
describe("usePlaylistListSync", () => {
|
describe("usePlaylistListSync", () => {
|
||||||
function setup(
|
function setup(initial: Playlist[], opts?: PlaylistListSyncOptions) {
|
||||||
initial: Playlist[],
|
|
||||||
opts?: Parameters<typeof usePlaylistListSync>[1],
|
|
||||||
) {
|
|
||||||
const ws = liveWS();
|
const ws = liveWS();
|
||||||
const hook = renderHook(() => {
|
const hook = renderHook(() => {
|
||||||
const list = useList(initial);
|
const [items, setItems] = useState(initial);
|
||||||
usePlaylistListSync(list.setItems, opts);
|
usePlaylistListSync(items, setItems, opts);
|
||||||
return list;
|
return items;
|
||||||
}, { wrapper: ws.Wrapper });
|
}, { 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", () => {
|
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" });
|
const p = makePlaylist({ id: "b" });
|
||||||
emit({
|
send("created", p);
|
||||||
lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p },
|
send("created", { ...p });
|
||||||
});
|
expect(ids(items())).toEqual(["b", "a"]);
|
||||||
emit({
|
|
||||||
lastPlaylistEvent: {
|
|
||||||
type: "created",
|
|
||||||
playlistId: "b",
|
|
||||||
playlist: { ...p },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("created: filtered by owner, privacy and noNewEntries", () => {
|
test("created: filtered by owner, privacy and noNewEntries", () => {
|
||||||
const p = makePlaylist({ id: "p", userId: "them" });
|
const p = makePlaylist({ id: "p", userId: "them" });
|
||||||
const byOwner = setup([], { ownerId: "me" });
|
const byOwner = setup([], { ownerId: "me" });
|
||||||
byOwner.emit({
|
byOwner.send("created", p);
|
||||||
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
|
expect(byOwner.items()).toEqual([]);
|
||||||
});
|
|
||||||
expect(byOwner.hook.result.current.items).toEqual([]);
|
|
||||||
|
|
||||||
const priv = setup([]);
|
const priv = setup([]);
|
||||||
priv.emit({
|
priv.send("created", { ...p, isPublic: false });
|
||||||
lastPlaylistEvent: {
|
expect(priv.items()).toEqual([]);
|
||||||
type: "created",
|
|
||||||
playlistId: "p",
|
|
||||||
playlist: { ...p, isPublic: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(priv.hook.result.current.items).toEqual([]);
|
|
||||||
|
|
||||||
const followed = setup([], { noNewEntries: true });
|
const followed = setup([], { noNewEntries: true });
|
||||||
followed.emit({
|
followed.send("created", p);
|
||||||
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
|
expect(followed.items()).toEqual([]);
|
||||||
});
|
|
||||||
expect(followed.hook.result.current.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 p = makePlaylist({ id: "p", title: "one" });
|
||||||
const { hook, emit } = setup([p]);
|
const other = makePlaylist({ id: "q" });
|
||||||
emit({
|
const { send, items } = setup([p]);
|
||||||
lastPlaylistEvent: {
|
send("updated", { ...p, title: "two" });
|
||||||
type: "updated",
|
expect(items()[0].title).toBe("two");
|
||||||
playlistId: "p",
|
send("updated", { ...p, isPublic: false });
|
||||||
playlist: { ...p, title: "two" },
|
expect(items()).toEqual([]);
|
||||||
},
|
send("updated", other);
|
||||||
});
|
expect(items()).toEqual([]);
|
||||||
expect(hook.result.current.items[0].title).toBe("two");
|
});
|
||||||
emit({
|
|
||||||
lastPlaylistEvent: {
|
test("one that left comes back where it was, even to a followed list", () => {
|
||||||
type: "updated",
|
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
|
||||||
playlistId: "p",
|
const { set, send, items } = setup(initial, { noNewEntries: true });
|
||||||
playlist: { ...p, isPublic: false },
|
// Gone private for others, as a deletion.
|
||||||
},
|
set({ deletedPlaylistIds: new Set(["b"]) });
|
||||||
});
|
expect(ids(items())).toEqual(["a", "c"]);
|
||||||
expect(hook.result.current.items).toEqual([]);
|
set({ deletedPlaylistIds: new Set() });
|
||||||
emit({
|
send("created", initial[1]);
|
||||||
lastPlaylistEvent: {
|
expect(ids(items())).toEqual(["a", "b", "c"]);
|
||||||
type: "updated",
|
});
|
||||||
playlistId: "p",
|
|
||||||
playlist: { ...p, title: "back" },
|
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" });
|
||||||
// usePositionAwareSync puts it back, where it was.
|
send("updated", { ...p, isPublic: false });
|
||||||
expect(hook.result.current.items).toEqual([]);
|
expect(items()[0].isPublic).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("deleted and deletedPlaylistIds both remove", () => {
|
test("deleted and deletedPlaylistIds both remove", () => {
|
||||||
const { hook, emit } = setup([
|
const { emit, set, items } = setup([
|
||||||
makePlaylist({ id: "a" }),
|
makePlaylist({ id: "a" }),
|
||||||
makePlaylist({ id: "b" }),
|
makePlaylist({ id: "b" }),
|
||||||
makePlaylist({ id: "c" }),
|
makePlaylist({ id: "c" }),
|
||||||
]);
|
]);
|
||||||
emit({ lastPlaylistEvent: { type: "deleted", playlistId: "a" } });
|
emit("playlist", { type: "deleted", playlistId: "a" });
|
||||||
emit({ deletedPlaylistIds: new Set(["b"]) });
|
set({ deletedPlaylistIds: new Set(["b"]) });
|
||||||
expect(ids(hook.result.current.items)).toEqual(["c"]);
|
expect(ids(items())).toEqual(["c"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("dumps_updated sets the count", () => {
|
test("dumps_updated sets the count", () => {
|
||||||
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
|
const { emit, items } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
|
||||||
emit({
|
emit("playlist", {
|
||||||
lastPlaylistEvent: {
|
type: "dumps_updated",
|
||||||
type: "dumps_updated",
|
playlistId: "a",
|
||||||
playlistId: "a",
|
dumpIds: ["x", "y", "z"],
|
||||||
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", () => {
|
test("dumps_updated counts the members this viewer can't see too", () => {
|
||||||
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
|
const { emit, items } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
|
||||||
emit({
|
emit("playlist", {
|
||||||
lastPlaylistEvent: {
|
type: "dumps_updated",
|
||||||
type: "dumps_updated",
|
playlistId: "a",
|
||||||
playlistId: "a",
|
dumpIds: ["x", "y"],
|
||||||
dumpIds: ["x", "y"],
|
dumpCount: 4,
|
||||||
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,4 +93,25 @@ describe("useFetchState", () => {
|
|||||||
expect(result.current.state).toEqual({ status: "loaded", data: 2 })
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
38
src/test/live.tsx
Normal file
38
src/test/live.tsx
Normal file
@@ -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<WSContextValue>) => void = () => {};
|
||||||
|
const events = createWSEventBus();
|
||||||
|
function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
const [value, setValue] = useState(defaultWSValue);
|
||||||
|
push = (patch) => setValue((v) => ({ ...v, ...patch }));
|
||||||
|
return (
|
||||||
|
<WSContext.Provider value={value}>
|
||||||
|
<WSEventsContext.Provider value={events}>
|
||||||
|
{children}
|
||||||
|
</WSEventsContext.Provider>
|
||||||
|
</WSContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
Wrapper,
|
||||||
|
events,
|
||||||
|
set: (patch: Partial<WSContextValue>) => act(() => push(patch)),
|
||||||
|
emit: <K extends keyof WSEvents>(kind: K, event: WSEvents[K]) =>
|
||||||
|
act(() => events.emit(kind, event)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
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 { userEvent } from "@testing-library/user-event";
|
||||||
import { Route } from "react-router";
|
import { Route } from "react-router";
|
||||||
import { Dump } from "../../pages/Dump.tsx";
|
import { Dump } from "../../pages/Dump.tsx";
|
||||||
@@ -237,6 +237,69 @@ describe("Dump page", () => {
|
|||||||
.toBeInTheDocument();
|
.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 () => {
|
test("adding to a playlist", async () => {
|
||||||
dumpRoutes();
|
dumpRoutes();
|
||||||
route("GET /api/me/playlists", () => [
|
route("GET /api/me/playlists", () => [
|
||||||
|
|||||||
@@ -248,6 +248,22 @@ describe("Index feeds", () => {
|
|||||||
expect(screen.getByText("Another song")).toBeInTheDocument();
|
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 () => {
|
test("an unknown category is a 404", async () => {
|
||||||
route("GET /api/dumps", () => fail("nope", 404));
|
route("GET /api/dumps", () => fail("nope", 404));
|
||||||
setup("/nonsense/new");
|
setup("/nonsense/new");
|
||||||
@@ -306,6 +322,74 @@ describe("Followed feed", () => {
|
|||||||
expect(currentPath()).toBe("/follow");
|
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 () => {
|
test("its view is in the path, a category's too", async () => {
|
||||||
route("GET /api/dumps", () => page([]));
|
route("GET /api/dumps", () => page([]));
|
||||||
setup("/music/follow/playlists", { session: makeSession() });
|
setup("/music/follow/playlists", { session: makeSession() });
|
||||||
|
|||||||
@@ -440,18 +440,47 @@ describe("PlaylistDetail", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("a dump that goes private leaves, and comes back at its place", async () => {
|
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 });
|
const { socket } = open({ session: visitor });
|
||||||
await screen.findByText("Bravo");
|
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: "dump_deleted", dumpId: "b" });
|
||||||
|
socket.receive({
|
||||||
|
type: "playlist_dumps_updated",
|
||||||
|
playlistId: "p1",
|
||||||
|
dumpIds: ["a", "c"],
|
||||||
|
dumpCount: 3,
|
||||||
|
});
|
||||||
expect(titles()).toEqual(["Alpha", "Charlie"]);
|
expect(titles()).toEqual(["Alpha", "Charlie"]);
|
||||||
socket.receive({
|
socket.receive({
|
||||||
type: "dump_created",
|
type: "dump_created",
|
||||||
dump: wire(makeDump({ id: "b", title: "Bravo" })),
|
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(await screen.findByText("Bravo")).toBeInTheDocument();
|
||||||
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
|
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 () => {
|
test("deleting the playlist asks, then goes home", async () => {
|
||||||
route(
|
route(
|
||||||
"DELETE /api/playlists/:id",
|
"DELETE /api/playlists/:id",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "../fixtures.ts";
|
} from "../fixtures.ts";
|
||||||
import { currentPath, renderApp } from "../render.tsx";
|
import { currentPath, renderApp } from "../render.tsx";
|
||||||
import { FakeIntersectionObserver } from "../setup.ts";
|
import { FakeIntersectionObserver } from "../setup.ts";
|
||||||
|
import { FakeWebSocket } from "../ws.ts";
|
||||||
|
|
||||||
function results(
|
function results(
|
||||||
{
|
{
|
||||||
@@ -209,4 +210,32 @@ describe("Search", () => {
|
|||||||
await screen.findByText("About two");
|
await screen.findByText("About two");
|
||||||
expect(screen.queryByText("About one")).not.toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -177,6 +177,38 @@ describe("UserPublicProfile", () => {
|
|||||||
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
|
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 () => {
|
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 music = makeCategory({ id: "c1", slug: "music", name: "Music" });
|
||||||
const tagged = makeDump({
|
const tagged = makeDump({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DEFAULT_SETTINGS } from "../../shared/settings.ts";
|
|||||||
import { AuthContext } from "../contexts/auth/AuthContext.ts";
|
import { AuthContext } from "../contexts/auth/AuthContext.ts";
|
||||||
import { AuthProvider } from "../contexts/auth/AuthProvider.tsx";
|
import { AuthProvider } from "../contexts/auth/AuthProvider.tsx";
|
||||||
import {
|
import {
|
||||||
|
createWSEventBus,
|
||||||
defaultWSChatValue,
|
defaultWSChatValue,
|
||||||
defaultWSPresenceValue,
|
defaultWSPresenceValue,
|
||||||
defaultWSValue,
|
defaultWSValue,
|
||||||
@@ -17,6 +18,8 @@ import {
|
|||||||
type WSChatContextValue,
|
type WSChatContextValue,
|
||||||
WSContext,
|
WSContext,
|
||||||
type WSContextValue,
|
type WSContextValue,
|
||||||
|
type WSEventBus,
|
||||||
|
WSEventsContext,
|
||||||
WSPresenceContext,
|
WSPresenceContext,
|
||||||
type WSPresenceContextValue,
|
type WSPresenceContextValue,
|
||||||
} from "../contexts/ws/WSContext.ts";
|
} from "../contexts/ws/WSContext.ts";
|
||||||
@@ -97,6 +100,8 @@ export interface StubOptions extends RouterOptions {
|
|||||||
session?: Session | null;
|
session?: Session | null;
|
||||||
/** Any of the socket's three contexts' fields; each goes to its own. */
|
/** Any of the socket's three contexts' fields; each goes to its own. */
|
||||||
ws?: Partial<WSContextValue & WSChatContextValue & WSPresenceContextValue>;
|
ws?: Partial<WSContextValue & WSChatContextValue & WSPresenceContextValue>;
|
||||||
|
/** The bus the socket's events come from: a test emits on it. */
|
||||||
|
events?: WSEventBus;
|
||||||
follows?: Partial<FollowContextValue>;
|
follows?: Partial<FollowContextValue>;
|
||||||
categories?: Category[] | Partial<CategoriesContextValue>;
|
categories?: Category[] | Partial<CategoriesContextValue>;
|
||||||
player?: Partial<PlayerContextValue & PlayerTimeValue>;
|
player?: Partial<PlayerContextValue & PlayerTimeValue>;
|
||||||
@@ -145,6 +150,8 @@ export function renderWithContexts(
|
|||||||
opts: StubOptions = {},
|
opts: StubOptions = {},
|
||||||
renderOptions?: RenderOptions,
|
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.
|
// A wrapper rather than one tree, so `rerender(ui)` keeps the contexts.
|
||||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
@@ -161,25 +168,29 @@ export function renderWithContexts(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WSContext.Provider value={pick(defaultWSValue, opts.ws)}>
|
<WSContext.Provider value={pick(defaultWSValue, opts.ws)}>
|
||||||
<WSChatContext.Provider value={pick(defaultWSChatValue, opts.ws)}>
|
<WSEventsContext.Provider value={events}>
|
||||||
<WSPresenceContext.Provider
|
<WSChatContext.Provider
|
||||||
value={pick(defaultWSPresenceValue, opts.ws)}
|
value={pick(defaultWSChatValue, opts.ws)}
|
||||||
>
|
>
|
||||||
<FollowContext.Provider
|
<WSPresenceContext.Provider
|
||||||
value={{
|
value={pick(defaultWSPresenceValue, opts.ws)}
|
||||||
...defaultFollowValue,
|
|
||||||
isLoaded: true,
|
|
||||||
...opts.follows,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CategoriesContext.Provider
|
<FollowContext.Provider
|
||||||
value={categoriesValue(opts.categories)}
|
value={{
|
||||||
|
...defaultFollowValue,
|
||||||
|
isLoaded: true,
|
||||||
|
...opts.follows,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Router {...opts}>{children}</Router>
|
<CategoriesContext.Provider
|
||||||
</CategoriesContext.Provider>
|
value={categoriesValue(opts.categories)}
|
||||||
</FollowContext.Provider>
|
>
|
||||||
</WSPresenceContext.Provider>
|
<Router {...opts}>{children}</Router>
|
||||||
</WSChatContext.Provider>
|
</CategoriesContext.Provider>
|
||||||
|
</FollowContext.Provider>
|
||||||
|
</WSPresenceContext.Provider>
|
||||||
|
</WSChatContext.Provider>
|
||||||
|
</WSEventsContext.Provider>
|
||||||
</WSContext.Provider>
|
</WSContext.Provider>
|
||||||
</PlayerTimeContext.Provider>
|
</PlayerTimeContext.Provider>
|
||||||
</PlayerContext.Provider>
|
</PlayerContext.Provider>
|
||||||
|
|||||||
Reference in New Issue
Block a user