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:
@@ -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" });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user