v4: what comes into a live list goes where its date puts it — a dump edited into a category stayed out of the index's feed of it until a reload, and a dump or a playlist made public went on top of the others' lists as if just posted. The index's feed now takes a dump edited into its scope, and orders the dumps that arrive by date, as the server pages them; a created playlist goes by date in the profile's and /playlists' lists, a new one first as before. Both use withByDate (utils/feed/pagedList.ts), which leaves an item older than every one loaded to a later page. The search runs again after a reconnection, for every page of dumps loaded, with its users and playlists; it shares refetchPages with usePagedList's resync. injectDump's doc said a private dump is never broadcast, while its poster gets it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -210,10 +210,10 @@ export interface WSContextValue {
|
|||||||
/** As `myVotes`, for comments. */
|
/** As `myVotes`, for comments. */
|
||||||
myLikes: Set<string>;
|
myLikes: Set<string>;
|
||||||
/**
|
/**
|
||||||
* Shows new dumps on the index as they are posted, above the feed `Index`
|
* Shows new dumps on the index as they are posted, merged by date with the
|
||||||
* fetched. It holds, newest first and at most 100, the ones the server
|
* feed `Index` fetched. It holds, newest first and at most 100, the ones
|
||||||
* announced this session, which are public, and the ones this user posted,
|
* the server announced this session, which are public, and the ones this
|
||||||
* which may be private.
|
* user posted, which may be private.
|
||||||
*/
|
*/
|
||||||
recentDumps: Dump[];
|
recentDumps: Dump[];
|
||||||
/**
|
/**
|
||||||
@@ -258,8 +258,8 @@ export interface WSContextValue {
|
|||||||
removeCommentLike: (commentId: string, shownCount?: number) => void;
|
removeCommentLike: (commentId: string, shownCount?: number) => void;
|
||||||
/**
|
/**
|
||||||
* Shows a dump this user just posted on the index without waiting for its
|
* Shows a dump this user just posted on the index without waiting for its
|
||||||
* broadcast, which a private dump never gets. `DumpCreateModal` calls it,
|
* broadcast. `DumpCreateModal` calls it, and it adds the dump to
|
||||||
* and it adds the dump to `recentDumps`.
|
* `recentDumps`.
|
||||||
*/
|
*/
|
||||||
injectDump: (dump: Dump) => void;
|
injectDump: (dump: Dump) => void;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
|||||||
import type { Dump } from "../../../shared/dump.ts";
|
import type { Dump } from "../../../shared/dump.ts";
|
||||||
import { useWS, useWSEvent } from "../ws/useWS.ts";
|
import { useWS, useWSEvent } from "../ws/useWS.ts";
|
||||||
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
|
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
|
||||||
|
import { withByDate } from "../../utils/feed/pagedList.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Passed to {@link useDumpListSync} by the lists that need more than edits and
|
* 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
|
* deletions.
|
||||||
* `WSContext`'s `recentDumps` itself.
|
|
||||||
*/
|
*/
|
||||||
export interface DumpListSyncOptions {
|
export interface DumpListSyncOptions {
|
||||||
/**
|
/**
|
||||||
@@ -32,23 +32,11 @@ export interface DumpListSyncOptions {
|
|||||||
* Also adds a dump `belongs` accepts that the list never held, where the
|
* 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
|
* 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
|
* older than all of them is left to the next pages. For the lists sorted by
|
||||||
* `createdAt`, as a profile's dumps.
|
* `createdAt`: the index's feed, a profile's dumps, the followed users'.
|
||||||
*/
|
*/
|
||||||
addNew?: boolean;
|
addNew?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* 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
|
* list, which `setDumps` updates. A deleted dump is removed, an edited one is
|
||||||
@@ -96,7 +84,7 @@ export function useDumpListSync(
|
|||||||
}
|
}
|
||||||
if (!accepted) return prev;
|
if (!accepted) return prev;
|
||||||
if (oldIdx !== undefined) return insertAt(prev, dump, oldIdx);
|
if (oldIdx !== undefined) return insertAt(prev, dump, oldIdx);
|
||||||
return addNew ? insertByDate(prev, dump) : prev;
|
return addNew ? withByDate(prev, dump) : prev;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
firstPage,
|
firstPage,
|
||||||
mapItems,
|
mapItems,
|
||||||
type PagedList,
|
type PagedList,
|
||||||
|
refetchPages,
|
||||||
restoredList,
|
restoredList,
|
||||||
} from "../../utils/feed/pagedList.ts";
|
} from "../../utils/feed/pagedList.ts";
|
||||||
import type { FeedCacheEntry } from "./useFeedCache.ts";
|
import type { FeedCacheEntry } from "./useFeedCache.ts";
|
||||||
@@ -114,22 +115,8 @@ export function usePagedList<T extends { id: string }>(
|
|||||||
const resync = useCallback(() => {
|
const resync = useCallback(() => {
|
||||||
const { list, fetchPage } = latestRef.current;
|
const { list, fetchPage } = latestRef.current;
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const pages = Array.from({ length: list.page }, (_, i) => i + 1);
|
refetchPages(list.page, (page) => fetchPage(page))
|
||||||
Promise.all(pages.map((page) => fetchPage(page)))
|
.then(({ items, hasMore }) => setData((l) => ({ ...l, items, hasMore })))
|
||||||
.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(() => {});
|
.catch(() => {});
|
||||||
}, [setData]);
|
}, [setData]);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
|||||||
import type { Playlist } from "../../../shared/playlist.ts";
|
import type { Playlist } from "../../../shared/playlist.ts";
|
||||||
import { useWS, useWSEvent } from "../ws/useWS.ts";
|
import { useWS, useWSEvent } from "../ws/useWS.ts";
|
||||||
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
|
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
|
||||||
|
import { withByDate } from "../../utils/feed/pagedList.ts";
|
||||||
|
|
||||||
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
|
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
|
||||||
export interface PlaylistListSyncOptions {
|
export interface PlaylistListSyncOptions {
|
||||||
@@ -29,12 +30,12 @@ export interface PlaylistListSyncOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the socket's playlist events to `items`, the loaded list, which
|
* Applies the socket's playlist events to `items`, the loaded list, newest
|
||||||
* `setPlaylists` updates. A created playlist goes first, an updated one
|
* first, which `setPlaylists` updates. A created playlist goes where its date
|
||||||
* changes in place or leaves when it turns private (unless `isOwner`), one
|
* puts it, first when new, an updated one changes in place or leaves when it
|
||||||
* that left comes back where it was when it returns, a deleted one leaves,
|
* turns private (unless `isOwner`), one that left comes back where it was
|
||||||
* and a playlist's `dumpCount` follows its dumps. A renamed user's playlists
|
* when it returns, a deleted one leaves, and a playlist's `dumpCount` follows
|
||||||
* take their new name.
|
* its dumps. A renamed user's playlists take their new name.
|
||||||
*/
|
*/
|
||||||
export function usePlaylistListSync(
|
export function usePlaylistListSync(
|
||||||
items: Playlist[],
|
items: Playlist[],
|
||||||
@@ -73,7 +74,7 @@ export function usePlaylistListSync(
|
|||||||
if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx);
|
if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx);
|
||||||
// An update never adds a playlist the list doesn't hold.
|
// An update never adds a playlist the list doesn't hold.
|
||||||
if (ev.type === "updated" || noNewEntries) return prev;
|
if (ev.type === "updated" || noNewEntries) return prev;
|
||||||
return [playlist, ...prev];
|
return withByDate(prev, playlist);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ev.type === "deleted") {
|
if (ev.type === "deleted") {
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ export interface IndexProps {
|
|||||||
* user only. The feed comes from
|
* user only. The feed comes from
|
||||||
* `GET /api/dumps`, or `GET /api/dumps?category=`, a page at a time,
|
* `GET /api/dumps`, or `GET /api/dumps?category=`, a page at a time,
|
||||||
* and the "hot", "new" and "journal" tabs share it. `WSContext` adds the dumps
|
* and the "hot", "new" and "journal" tabs share it. `WSContext` adds the dumps
|
||||||
* posted since, the live vote counts, and who's online. The router keys the
|
* posted since, or brought into the feed by an edit, by date, the live vote
|
||||||
|
* counts, and who's online. The router keys the
|
||||||
* page by category, so switching tabs keeps the feed and switching categories
|
* page by category, so switching tabs keeps the feed and switching categories
|
||||||
* refetches it. It also opens the dump creation modal on a link shared from
|
* refetches it. It also opens the dump creation modal on a link shared from
|
||||||
* Android (`?share_url=`).
|
* Android (`?share_url=`).
|
||||||
@@ -139,10 +140,7 @@ export function Index(
|
|||||||
() => dumpsState.status === "loaded" ? dumpsState.data.items : [],
|
() => dumpsState.status === "loaded" ? dumpsState.data.items : [],
|
||||||
[dumpsState],
|
[dumpsState],
|
||||||
);
|
);
|
||||||
// Signed in, the feed holds the user's own private dumps.
|
// A category's feed takes only the dumps filed under it.
|
||||||
useDumpListSync(dumps, setScopedItems, { isOwner: true });
|
|
||||||
|
|
||||||
// A category's feed takes only the new dumps filed under it.
|
|
||||||
const categoryId = category?.id;
|
const categoryId = category?.id;
|
||||||
const inScope = useCallback(
|
const inScope = useCallback(
|
||||||
(d: Dump) =>
|
(d: Dump) =>
|
||||||
@@ -152,13 +150,24 @@ export function Index(
|
|||||||
[categorySlug, categoryId],
|
[categorySlug, categoryId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Signed in, the feed holds the user's own private dumps. A dump edited
|
||||||
|
// into the category, or made public, comes in where its date puts it.
|
||||||
|
useDumpListSync(dumps, setScopedItems, {
|
||||||
|
isOwner: true,
|
||||||
|
belongs: inScope,
|
||||||
|
addNew: true,
|
||||||
|
});
|
||||||
|
|
||||||
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
|
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
|
||||||
// Dumps deleted since loading are dropped, and the one the edit page just
|
// Dumps deleted since loading are dropped, and the one the edit page just
|
||||||
// deleted before its `dump_deleted` arrives.
|
// deleted before its `dump_deleted` arrives. In the server's order, by date:
|
||||||
|
// a dump made public long after it was posted isn't news, and goes below
|
||||||
|
// what came since, even when older than every dump loaded.
|
||||||
const combined = useMemo(
|
const combined = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps]
|
[...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps]
|
||||||
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId),
|
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId)
|
||||||
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
|
||||||
[recentDumps, restIds, dumps, deletedDumpIds, justDeletedId, inScope],
|
[recentDumps, restIds, dumps, deletedDumpIds, justDeletedId, inScope],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||||
import { Link, useSearchParams } from "react-router";
|
import { Link, useSearchParams } from "react-router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
@@ -39,11 +39,13 @@ import {
|
|||||||
firstPage,
|
firstPage,
|
||||||
type PagedList,
|
type PagedList,
|
||||||
pageUrl,
|
pageUrl,
|
||||||
|
refetchPages,
|
||||||
} from "../utils/feed/pagedList.ts";
|
} from "../utils/feed/pagedList.ts";
|
||||||
import { useFetchState } from "../hooks/shared/useFetchState.ts";
|
import { useFetchState } from "../hooks/shared/useFetchState.ts";
|
||||||
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
|
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
|
||||||
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
||||||
import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
|
import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
|
||||||
|
import { useOnReconnect } from "../hooks/ws/useWS.ts";
|
||||||
import {
|
import {
|
||||||
SEARCH_TABS,
|
SEARCH_TABS,
|
||||||
searchPath,
|
searchPath,
|
||||||
@@ -64,6 +66,18 @@ interface Results {
|
|||||||
playlists: Playlist[];
|
playlists: Playlist[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the first page of a search's answer, as `Search` holds it. */
|
||||||
|
function toResults(
|
||||||
|
{ dumps, users, playlists }: Wire<SearchResults>,
|
||||||
|
): Results {
|
||||||
|
return {
|
||||||
|
dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore),
|
||||||
|
dumpTotal: dumps.total ?? 0,
|
||||||
|
users: users.map(deserializePublicUser),
|
||||||
|
playlists: playlists.map(deserializePlaylist),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Names, for each tab a category narrows, the kind of its categories and the
|
* Names, for each tab a category narrows, the kind of its categories and the
|
||||||
* parameter of `GET /api/search` that takes one.
|
* parameter of `GET /api/search` that takes one.
|
||||||
@@ -80,7 +94,8 @@ const TAB_CATEGORIES: Partial<
|
|||||||
* from `GET /api/search`, signed in when the viewer is, so they find their own
|
* from `GET /api/search`, signed in when the viewer is, so they find their own
|
||||||
* private dumps and playlists too. The dumps load a page at a time as the
|
* private dumps and playlists too. The dumps load a page at a time as the
|
||||||
* "dumps" tab scrolls. A new `q` aborts the search under way. The socket keeps
|
* "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.
|
* the dumps and playlists found live, as edited or deleted, but adds none; a
|
||||||
|
* reconnection runs the search again, for every page of dumps loaded.
|
||||||
*
|
*
|
||||||
* On the "dumps" and "playlists" tabs, a menu narrows the results to a
|
* On the "dumps" and "playlists" tabs, a menu narrows the results to a
|
||||||
* category of their kind, kept in the URL's `?category=`, which another tab
|
* category of their kind, kept in the URL's `?category=`, which another tab
|
||||||
@@ -113,13 +128,7 @@ export function Search(): React.JSX.Element {
|
|||||||
categorySlug ?? ""
|
categorySlug ?? ""
|
||||||
}`
|
}`
|
||||||
: undefined,
|
: undefined,
|
||||||
(signal) =>
|
(signal) => search(1, signal).then(toResults),
|
||||||
search(1, signal).then(({ dumps, users, playlists }) => ({
|
|
||||||
dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore),
|
|
||||||
dumpTotal: dumps.total ?? 0,
|
|
||||||
users: users.map(deserializePublicUser),
|
|
||||||
playlists: playlists.map(deserializePlaylist),
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const dumps = state.status === "loaded" ? state.data.dumps : null;
|
const dumps = state.status === "loaded" ? state.data.dumps : null;
|
||||||
@@ -159,6 +168,33 @@ export function Search(): React.JSX.Element {
|
|||||||
{ isOwner: true, noNewEntries: true },
|
{ isOwner: true, noNewEntries: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// What changed while the socket was down: every page of dumps loaded is
|
||||||
|
// fetched again, and the users and playlists, which come with the first.
|
||||||
|
const searchRef = useRef(search);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
searchRef.current = search;
|
||||||
|
});
|
||||||
|
useOnReconnect(() => {
|
||||||
|
if (state.status !== "loaded") return;
|
||||||
|
const asked = search;
|
||||||
|
let first: Wire<SearchResults> | undefined;
|
||||||
|
refetchPages(state.data.dumps.page, (page) =>
|
||||||
|
asked(page).then((res) => {
|
||||||
|
if (page === 1) first = res;
|
||||||
|
return {
|
||||||
|
items: res.dumps.items.map(deserializeDump),
|
||||||
|
hasMore: res.dumps.hasMore,
|
||||||
|
};
|
||||||
|
}))
|
||||||
|
.then(({ items, hasMore }) => {
|
||||||
|
// The query changed meanwhile, and its own results are on screen.
|
||||||
|
if (!first || asked !== searchRef.current) return;
|
||||||
|
const fresh = toResults(first);
|
||||||
|
setData((d) => ({ ...fresh, dumps: { ...d.dumps, items, hasMore } }));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
const sentinelRef = useInfiniteScroll(
|
const sentinelRef = useInfiniteScroll(
|
||||||
loadMore,
|
loadMore,
|
||||||
tab === "dumps" && canLoadMore(dumps),
|
tab === "dumps" && canLoadMore(dumps),
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ describe("usePlaylistListSync", () => {
|
|||||||
expect(ids(items())).toEqual(["b", "a"]);
|
expect(ids(items())).toEqual(["b", "a"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a playlist made public goes where its date puts it", () => {
|
||||||
|
const at = (id: string, day: number) =>
|
||||||
|
makePlaylist({ id, createdAt: new Date(Date.UTC(2026, 0, day)) });
|
||||||
|
const { send, items } = setup([at("c", 30), at("a", 10)]);
|
||||||
|
send("created", at("b", 20));
|
||||||
|
// Older than every one loaded: a later page holds it.
|
||||||
|
send("created", at("old", 1));
|
||||||
|
expect(ids(items())).toEqual(["c", "b", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("created: filtered by owner, privacy and noNewEntries", () => {
|
test("created: filtered by owner, privacy and noNewEntries", () => {
|
||||||
const p = makePlaylist({ id: "p", userId: "them" });
|
const p = makePlaylist({ id: "p", userId: "them" });
|
||||||
const byOwner = setup([], { ownerId: "me" });
|
const byOwner = setup([], { ownerId: "me" });
|
||||||
|
|||||||
@@ -264,6 +264,44 @@ describe("Index feeds", () => {
|
|||||||
expect(screen.queryByText("A song")).not.toBeInTheDocument();
|
expect(screen.queryByText("A song")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a dump edited into the category comes in where its date puts it", async () => {
|
||||||
|
const at = (minutes: number) => new Date(Date.now() - minutes * 60_000);
|
||||||
|
const tagged = (id: string, title: string, minutes: number) =>
|
||||||
|
makeDump({ id, title, categoryIds: ["c-music"], createdAt: at(minutes) });
|
||||||
|
route(
|
||||||
|
"GET /api/dumps",
|
||||||
|
() => page([tagged("n", "Newer", 1), tagged("o", "Older", 30)]),
|
||||||
|
);
|
||||||
|
const { socket } = setup("/music/new");
|
||||||
|
await screen.findByText("Older");
|
||||||
|
socket.receive({
|
||||||
|
type: "dump_updated",
|
||||||
|
dump: wire(tagged("m", "Now music", 10)),
|
||||||
|
});
|
||||||
|
expect(titles()).toEqual(["Newer", "Now music", "Older"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a dump made public long after it was posted goes by its date, not on top", async () => {
|
||||||
|
const at = (minutes: number) => new Date(Date.now() - minutes * 60_000);
|
||||||
|
route("GET /api/dumps", () =>
|
||||||
|
page([
|
||||||
|
makeDump({ id: "n", title: "Newer", createdAt: at(1) }),
|
||||||
|
makeDump({ id: "o", title: "Older", createdAt: at(30) }),
|
||||||
|
]));
|
||||||
|
const { socket } = setup("/new");
|
||||||
|
await screen.findByText("Older");
|
||||||
|
// To everyone but its poster, it arrives as created.
|
||||||
|
socket.receive({
|
||||||
|
type: "dump_created",
|
||||||
|
dump: wire(makeDump({ id: "m", title: "Unveiled", createdAt: at(10) })),
|
||||||
|
});
|
||||||
|
socket.receive({
|
||||||
|
type: "dump_created",
|
||||||
|
dump: wire(makeDump({ id: "a", title: "Ancient", createdAt: at(600) })),
|
||||||
|
});
|
||||||
|
expect(titles()).toEqual(["Newer", "Unveiled", "Older", "Ancient"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("an unknown category is a 404", async () => {
|
test("an unknown category is a 404", async () => {
|
||||||
route("GET /api/dumps", () => fail("nope", 404));
|
route("GET /api/dumps", () => fail("nope", 404));
|
||||||
setup("/nonsense/new");
|
setup("/nonsense/new");
|
||||||
|
|||||||
@@ -238,4 +238,50 @@ describe("Search", () => {
|
|||||||
await user.click(screen.getByRole("button", { name: /Playlists/ }));
|
await user.click(screen.getByRole("button", { name: /Playlists/ }));
|
||||||
expect(screen.queryByText("Cats")).not.toBeInTheDocument();
|
expect(screen.queryByText("Cats")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a reconnect runs the search again, for every page loaded", async () => {
|
||||||
|
route(
|
||||||
|
"GET /api/search",
|
||||||
|
(req) =>
|
||||||
|
req.url.searchParams.get("page") === "2"
|
||||||
|
? results({ dumps: [makeDump({ title: "Second page" })] })
|
||||||
|
: results({
|
||||||
|
dumps: [makeDump({ title: "First page" })],
|
||||||
|
hasMore: true,
|
||||||
|
users: [makeUser({ username: "catlover" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
open("/search?q=cat");
|
||||||
|
const socket = FakeWebSocket.last;
|
||||||
|
socket.welcome();
|
||||||
|
await screen.findByText("First page");
|
||||||
|
const sentinel = FakeIntersectionObserver.instances.findLast((o) =>
|
||||||
|
o.elements.size > 0
|
||||||
|
)!;
|
||||||
|
act(() => sentinel.report(true));
|
||||||
|
await screen.findByText("Second page");
|
||||||
|
|
||||||
|
// Gone while the socket was down: an edit, a deletion, a new user.
|
||||||
|
route(
|
||||||
|
"GET /api/search",
|
||||||
|
(req) =>
|
||||||
|
req.url.searchParams.get("page") === "2"
|
||||||
|
? results({ dumps: [] })
|
||||||
|
: results({
|
||||||
|
dumps: [makeDump({ title: "First page, edited" })],
|
||||||
|
hasMore: true,
|
||||||
|
users: [
|
||||||
|
makeUser({ username: "catlover" }),
|
||||||
|
makeUser({ username: "cat2" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
socket.drop();
|
||||||
|
await act(() => new Promise((r) => setTimeout(r, 600)));
|
||||||
|
FakeWebSocket.last.welcome();
|
||||||
|
expect(await screen.findByText("First page, edited")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Second page")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Users (2)" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
pageUrl,
|
pageUrl,
|
||||||
readFirstPage,
|
readFirstPage,
|
||||||
readPage,
|
readPage,
|
||||||
|
refetchPages,
|
||||||
restoredList,
|
restoredList,
|
||||||
|
withByDate,
|
||||||
withFirst,
|
withFirst,
|
||||||
} from "../../../utils/feed/pagedList.ts";
|
} from "../../../utils/feed/pagedList.ts";
|
||||||
import { calls, fail, route } from "../../fetch.ts";
|
import { calls, fail, route } from "../../fetch.ts";
|
||||||
@@ -57,4 +59,61 @@ describe("pagedList", () => {
|
|||||||
const items = [a, b];
|
const items = [a, b];
|
||||||
expect(withFirst(items, { id: "b" })).toBe(items);
|
expect(withFirst(items, { id: "b" })).toBe(items);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("withByDate puts an item where its date falls, newest first", () => {
|
||||||
|
const at = (id: string, day: number) => ({
|
||||||
|
id,
|
||||||
|
createdAt: new Date(Date.UTC(2026, 0, day)),
|
||||||
|
});
|
||||||
|
const list = [at("c", 30), at("b", 20), at("a", 10)];
|
||||||
|
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
|
||||||
|
expect(ids(withByDate(list, at("new", 31)))).toEqual([
|
||||||
|
"new",
|
||||||
|
"c",
|
||||||
|
"b",
|
||||||
|
"a",
|
||||||
|
]);
|
||||||
|
expect(ids(withByDate(list, at("mid", 25)))).toEqual([
|
||||||
|
"c",
|
||||||
|
"mid",
|
||||||
|
"b",
|
||||||
|
"a",
|
||||||
|
]);
|
||||||
|
// A tie goes first, as something new does.
|
||||||
|
expect(ids(withByDate(list, at("tie", 20)))).toEqual([
|
||||||
|
"c",
|
||||||
|
"tie",
|
||||||
|
"b",
|
||||||
|
"a",
|
||||||
|
]);
|
||||||
|
// Older than everything loaded: a later page holds it.
|
||||||
|
expect(withByDate(list, at("old", 1))).toBe(list);
|
||||||
|
expect(withByDate(list, list[1])).toBe(list);
|
||||||
|
expect(ids(withByDate([], at("only", 1)))).toEqual(["only"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refetchPages reads each page again, each item once", async () => {
|
||||||
|
const pages: Record<number, { id: string }[]> = {
|
||||||
|
1: [{ id: "new" }, { id: "a" }],
|
||||||
|
// Shifted by the new item: "a" is on both.
|
||||||
|
2: [{ id: "a" }, { id: "b" }],
|
||||||
|
};
|
||||||
|
const result = await refetchPages(
|
||||||
|
2,
|
||||||
|
(n) => Promise.resolve({ items: pages[n], hasMore: n === 1 }),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
items: [{ id: "new" }, { id: "a" }, { id: "b" }],
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
refetchPages(
|
||||||
|
2,
|
||||||
|
(n) =>
|
||||||
|
n === 2
|
||||||
|
? Promise.reject(new Error("offline"))
|
||||||
|
: Promise.resolve({ items: [], hasMore: true }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("offline");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,3 +103,46 @@ export async function readFirstPage<T>(
|
|||||||
export function withFirst<T extends { id: string }>(items: T[], item: T): T[] {
|
export function withFirst<T extends { id: string }>(items: T[], item: T): T[] {
|
||||||
return items.some((i) => i.id === item.id) ? items : [item, ...items];
|
return items.some((i) => i.id === item.id) ? items : [item, ...items];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `items`, newest first as the API pages them, with `item` where its
|
||||||
|
* `createdAt` puts it: before the first item no newer than it. An item older
|
||||||
|
* than every one loaded is left to a later page, and `items` returned as they
|
||||||
|
* are, as they are when `item` is already there. The list sync hooks put a
|
||||||
|
* new or newly visible dump or playlist in a list with it.
|
||||||
|
*/
|
||||||
|
export function withByDate<T extends { id: string; createdAt: Date }>(
|
||||||
|
items: T[],
|
||||||
|
item: T,
|
||||||
|
): T[] {
|
||||||
|
if (items.some((i) => i.id === item.id)) return items;
|
||||||
|
if (items.length === 0) return [item];
|
||||||
|
const time = item.createdAt.getTime();
|
||||||
|
const idx = items.findIndex((i) => i.createdAt.getTime() <= time);
|
||||||
|
if (idx === -1) return items;
|
||||||
|
return [...items.slice(0, idx), item, ...items.slice(idx)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches pages 1 to `pages` of a list again, and resolves to their items,
|
||||||
|
* each once, and whether more follow. An item added between two pages'
|
||||||
|
* fetches shifts the later page by one, which would list an item twice. A
|
||||||
|
* live list calls it after a reconnection, through `usePagedList`'s `resync`
|
||||||
|
* or, for the search's dumps, directly; it rejects when a page fails.
|
||||||
|
*/
|
||||||
|
export async function refetchPages<T extends { id: string }>(
|
||||||
|
pages: number,
|
||||||
|
fetchPage: (page: number) => Promise<PaginatedData<T>>,
|
||||||
|
): Promise<PaginatedData<T>> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: pages }, (_, i) => fetchPage(i + 1)),
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items: [...byId.values()],
|
||||||
|
hasMore: results[results.length - 1].hasMore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user