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:
khannurien
2026-10-10 07:43:28 +00:00
parent 308a8299f3
commit 7c00db2b84
11 changed files with 278 additions and 61 deletions

View File

@@ -33,6 +33,16 @@ describe("usePlaylistListSync", () => {
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", () => {
const p = makePlaylist({ id: "p", userId: "them" });
const byOwner = setup([], { ownerId: "me" });

View File

@@ -264,6 +264,44 @@ describe("Index feeds", () => {
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 () => {
route("GET /api/dumps", () => fail("nope", 404));
setup("/nonsense/new");

View File

@@ -238,4 +238,50 @@ describe("Search", () => {
await user.click(screen.getByRole("button", { name: /Playlists/ }));
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();
});
});

View File

@@ -5,7 +5,9 @@ import {
pageUrl,
readFirstPage,
readPage,
refetchPages,
restoredList,
withByDate,
withFirst,
} from "../../../utils/feed/pagedList.ts";
import { calls, fail, route } from "../../fetch.ts";
@@ -57,4 +59,61 @@ describe("pagedList", () => {
const items = [a, b];
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");
});
});