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