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:
khannurien
2026-10-10 07:34:49 +00:00
parent 1c5a449d84
commit 308a8299f3
53 changed files with 1941 additions and 1044 deletions

View File

@@ -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
);
});
}

View File

@@ -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]),

View File

@@ -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",

View File

@@ -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 };
}

View File

@@ -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]);
}

View 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;
}

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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]);
}