diff --git a/app/api/appview_revalidate/route.ts b/app/api/appview_revalidate/route.ts index b2d1510c..c1e5e2d9 100644 --- a/app/api/appview_revalidate/route.ts +++ b/app/api/appview_revalidate/route.ts @@ -22,7 +22,11 @@ export type AppviewRevalidateEvent = | { kind: "document"; uri: string; - snapshot?: { publications: string[]; path?: string | null }; + snapshot?: { + publications: string[]; + path?: string | null; + sort_date?: string | null; + }; } | { kind: "publication"; uri: string; names: (string | null | undefined)[] } | { kind: "interaction"; document: string }; diff --git a/appview/index.ts b/appview/index.ts index 00e1e6fd..3c17307d 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -87,7 +87,7 @@ async function notifyRevalidate(event: AppviewRevalidateEvent) { async function deleteDocumentAndRevalidate(uri: string) { let { data: doc } = await supabase .from("documents") - .select("data, documents_in_publications(publication)") + .select("data, sort_date, documents_in_publications(publication)") .eq("uri", uri) .maybeSingle(); await supabase.from("documents").delete().eq("uri", uri); @@ -98,6 +98,7 @@ async function deleteDocumentAndRevalidate(uri: string) { publications: doc?.documents_in_publications.map((r) => r.publication) ?? [], path: (doc?.data as { path?: string } | null)?.path ?? null, + sort_date: doc?.sort_date ?? null, }, }); } diff --git a/src/utils/getPostPageData.ts b/src/utils/getPostPageData.ts index 15e8ab35..e6abf1cb 100644 --- a/src/utils/getPostPageData.ts +++ b/src/utils/getPostPageData.ts @@ -24,6 +24,7 @@ import { } from "./postPageProjection"; import { resolveDocumentFilter } from "./resolveDocumentFilter"; import { getPostImagePreloads } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/getPostImagePreloads"; +import { sortPostsForPrevNext } from "src/utils/prevNextPosts"; export const getPostPageData = cache(async function getPostPageData( did: string, @@ -145,18 +146,13 @@ export const getPostPageData = cache(async function getPostPageData( ?.documents_in_publications; if (currentPublishedAt && allDocs) { - const sortedDocs = allDocs - .flatMap((dip) => + const sortedDocs = sortPostsForPrevNext( + allDocs.flatMap((dip) => dip.documents ? [{ ...dip.documents, membersOnly: dip.members_only }] : [], - ) - .filter((doc) => doc.publishedAt && doc.title) - .sort( - (a, b) => - new Date(a.sort_date || 0).getTime() - - new Date(b.sort_date || 0).getTime(), - ); + ), + ); const currentIndex = sortedDocs.findIndex( (doc) => doc.uri === document.uri, diff --git a/src/utils/prevNextPosts.test.ts b/src/utils/prevNextPosts.test.ts new file mode 100644 index 00000000..5d414476 --- /dev/null +++ b/src/utils/prevNextPosts.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "vitest"; +import { + adjacentPosts, + sortPostsForPrevNext, + type PrevNextCandidate, +} from "src/utils/prevNextPosts"; + +const post = ( + n: number, + extra: Partial> = {}, +): PrevNextCandidate & { uri: string } => ({ + uri: `at://did:plc:x/pub.leaflet.document/${n}`, + sort_date: `2026-01-0${n}T00:00:00Z`, + title: `Post ${n}`, + publishedAt: `2026-01-0${n}T00:00:00Z`, + ...extra, +}); + +describe("sortPostsForPrevNext", () => { + test("orders oldest first and drops unpublished or untitled posts", () => { + const sorted = sortPostsForPrevNext([ + post(3), + post(1), + post(2, { title: null }), + post(4, { publishedAt: null }), + ]); + expect(sorted.map((d) => d.uri)).toEqual([post(1).uri, post(3).uri]); + }); +}); + +describe("adjacentPosts", () => { + const sorted = sortPostsForPrevNext([post(1), post(2), post(3), post(4)]); + + test("index neighbours for a post still in the run", () => { + const { prev, next, atEdge } = adjacentPosts(sorted, post(2)); + expect(prev?.uri).toBe(post(1).uri); + expect(next?.uri).toBe(post(3).uri); + expect(atEdge).toBe(false); + }); + + test("edges have one neighbour and report atEdge", () => { + expect(adjacentPosts(sorted, post(1))).toMatchObject({ + prev: undefined, + next: { uri: post(2).uri }, + atEdge: true, + }); + expect(adjacentPosts(sorted, post(4))).toMatchObject({ + prev: { uri: post(3).uri }, + next: undefined, + atEdge: true, + }); + }); + + test("a deleted post resolves to the posts that bracketed its sort_date", () => { + const remaining = sortPostsForPrevNext([post(1), post(2), post(4)]); + expect(adjacentPosts(remaining, post(3))).toMatchObject({ + prev: { uri: post(2).uri }, + next: { uri: post(4).uri }, + atEdge: false, + }); + expect(adjacentPosts(remaining, post(5))).toMatchObject({ + prev: { uri: post(4).uri }, + next: undefined, + atEdge: true, + }); + expect( + adjacentPosts(remaining, { + uri: "at://gone", + sort_date: "2025-12-31T00:00:00Z", + }), + ).toMatchObject({ + prev: undefined, + next: { uri: post(1).uri }, + atEdge: true, + }); + }); + + test("an unknown sort_date sorts to the start", () => { + expect( + adjacentPosts(sorted, { uri: "at://gone", sort_date: null }), + ).toMatchObject({ prev: undefined, next: { uri: post(1).uri } }); + }); +}); diff --git a/src/utils/prevNextPosts.ts b/src/utils/prevNextPosts.ts new file mode 100644 index 00000000..3bfa8937 --- /dev/null +++ b/src/utils/prevNextPosts.ts @@ -0,0 +1,50 @@ +export type PrevNextCandidate = { + uri: string | null; + sort_date: string | null; + title: string | null; + publishedAt: string | null; +}; + +/** + * The ordered run of posts the prev/next buttons page through, oldest first. + * The post page and the revalidation of its neighbours both derive adjacency + * from this, so they must never disagree on which posts count or how ties + * break. + */ +export function sortPostsForPrevNext(docs: T[]) { + return docs + .filter((doc) => doc.publishedAt && doc.title) + .sort( + (a, b) => + new Date(a.sort_date || 0).getTime() - + new Date(b.sort_date || 0).getTime(), + ); +} + +/** + * The posts whose prev/next buttons point at `doc`. When `doc` is still in + * `sorted` they're its index neighbours; when it has been deleted they're the + * posts that bracketed its `sort_date`, which now point at each other. + * `atEdge` reports whether `doc` is (or was) the first or last post, which is + * what the first/last buttons on every other post render. + */ +export function adjacentPosts( + sorted: T[], + doc: { uri: string; sort_date: string | null | undefined }, +): { prev?: T; next?: T; atEdge: boolean } { + const index = sorted.findIndex((d) => d.uri === doc.uri); + if (index !== -1) + return { + prev: sorted[index - 1], + next: sorted[index + 1], + atEdge: index === 0 || index === sorted.length - 1, + }; + const time = new Date(doc.sort_date || 0).getTime(); + const nextIndex = sorted.findIndex( + (d) => new Date(d.sort_date || 0).getTime() > time, + ); + const next = nextIndex === -1 ? undefined : sorted[nextIndex]; + const prev = + nextIndex === -1 ? sorted[sorted.length - 1] : sorted[nextIndex - 1]; + return { prev, next, atEdge: !prev || !next }; +} diff --git a/src/utils/revalidatePublication.ts b/src/utils/revalidatePublication.ts index 91be9fed..268e6944 100644 --- a/src/utils/revalidatePublication.ts +++ b/src/utils/revalidatePublication.ts @@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache"; import { AtUri } from "@atproto/syntax"; import { supabaseServerClient } from "supabase/serverClient"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { adjacentPosts, sortPostsForPrevNext } from "src/utils/prevNextPosts"; // ISR cache tags include the route group segment // (_N_T_/(published)/lish/...), so the pattern form @@ -29,25 +30,30 @@ export function revalidatePublicationPaths( for (const sub of subpaths) revalidatePath(`${base}${sub}`); } +function postSubpath(rkey: string, docPath: string | null | undefined) { + return docPath && docPath !== `/${rkey}` + ? [docPath.startsWith("/") ? docPath : `/${docPath}`] + : []; +} + // A single post's pages: its base URL, the archive listing, its rkey path, // its record `path` (if set to something other than the rkey) since documents -// can publish under a custom path, and the publication's own published pages, -// any of which may carry a posts list that includes this post. +// can publish under a custom path, and `extraPaths` — the publication's own +// published pages (any of which may carry a posts list that includes this +// post) and the neighbouring posts whose prev/next buttons name it. function revalidatePostPaths( pubUri: string, name: string | null | undefined, rkey: string, docPath: string | null | undefined, - pagePaths: string[] = [], + extraPaths: string[] = [], ) { revalidatePublicationPaths(pubUri, name, [ "", "/archive", `/${rkey}`, - ...pagePaths, - ...(docPath && docPath !== `/${rkey}` - ? [docPath.startsWith("/") ? docPath : `/${docPath}`] - : []), + ...extraPaths, + ...postSubpath(rkey, docPath), ]); } @@ -71,15 +77,66 @@ async function publishedPagePathsByPublication(pubUris: string[]) { return byPub; } +// The publication's posts as the prev/next buttons order them, plus the +// preferences that decide whether any post renders those buttons. +const POST_NEIGHBOURS_SELECT = + "documents_in_publications(documents(uri, sort_date, path:data->>path, title:data->>title, publishedAt:data->>publishedAt))"; +type PostNeighboursRows = { + documents: { + uri: string; + sort_date: string | null; + path: string | null; + title: string | null; + publishedAt: string | null; + } | null; +}[]; + +// Post pages render their neighbours' titles in the prev/next row and the +// first/last post in the first/last row, so a post appearing, disappearing, +// or changing title stales the pages either side of it — and, when first/last +// buttons are on and the post is at an edge, every post in the publication. +// `doc.sort_date` is where the post sits (or sat, for a delete) in that order. +function neighbourPostPaths( + record: unknown, + rows: PostNeighboursRows | null | undefined, + doc: { uri: string; sort_date: string | null | undefined }, +) { + const preferences = normalizePublicationRecord(record)?.preferences; + const showPrevNext = preferences?.showPrevNext !== false; + const showFirstLast = preferences?.showFirstLast === true; + if (!showPrevNext && !showFirstLast) return []; + const sorted = sortPostsForPrevNext( + (rows ?? []).flatMap((r) => (r.documents ? [r.documents] : [])), + ); + const { prev, next, atEdge } = adjacentPosts(sorted, doc); + const targets = + showFirstLast && atEdge + ? sorted + : showPrevNext + ? [prev, next].filter((d): d is (typeof sorted)[number] => !!d) + : []; + return targets + .filter((d) => d.uri !== doc.uri) + .flatMap((d) => { + const rkey = new AtUri(d.uri).rkey; + return [`/${rkey}`, ...postSubpath(rkey, d.path)]; + }); +} + // Every cached page that lists or renders a document: the standalone /p/ URL -// and, for each publication it belongs to, the post/index/archive paths. -// Callable before the document row exists (nothing to look up → /p/ only) or -// after it's gone: pass `snapshot` (captured pre-delete) and the join-row -// lookup is skipped — publication names still resolve from the publications -// table, which outlives the document. +// and, for each publication it belongs to, the post/index/archive paths and +// the neighbouring posts whose prev/next buttons point at it. Callable before +// the document row exists (nothing to look up → /p/ only) or after it's gone: +// pass `snapshot` (captured pre-delete) and the join-row lookup is skipped — +// publication names, posts and preferences still resolve from the +// publications table, which outlives the document. export async function revalidateDocumentPaths( documentUri: string, - snapshot?: { publications: string[]; path?: string | null }, + snapshot?: { + publications: string[]; + path?: string | null; + sort_date?: string | null; + }, ) { let docUri; try { @@ -87,31 +144,48 @@ export async function revalidateDocumentPaths( } catch { return; } - let pubs: { uri: string; name: string | null | undefined }[] = []; + let pubs: { + uri: string; + name: string | null | undefined; + neighbours: string[]; + }[] = []; let docPath = snapshot?.path; if (snapshot) { if (snapshot.publications.length) { const { data } = await supabaseServerClient .from("publications") - .select("uri, record") + .select(`uri, record, ${POST_NEIGHBOURS_SELECT}`) .in("uri", snapshot.publications); - pubs = snapshot.publications.map((uri) => ({ - uri, - name: normalizePublicationRecord( - data?.find((r) => r.uri === uri)?.record, - )?.name, - })); + pubs = snapshot.publications.map((uri) => { + const pub = data?.find((r) => r.uri === uri); + return { + uri, + name: normalizePublicationRecord(pub?.record)?.name, + neighbours: neighbourPostPaths( + pub?.record, + pub?.documents_in_publications, + { uri: documentUri, sort_date: snapshot.sort_date }, + ), + }; + }); } } else { const { data: rows } = await supabaseServerClient .from("documents_in_publications") - .select("publications(uri, record), documents(data)") + .select( + `publications(uri, record, ${POST_NEIGHBOURS_SELECT}), documents(data, sort_date)`, + ) .eq("document", documentUri); for (const row of rows ?? []) { if (!row.publications) continue; pubs.push({ uri: row.publications.uri, name: normalizePublicationRecord(row.publications.record)?.name, + neighbours: neighbourPostPaths( + row.publications.record, + row.publications.documents_in_publications, + { uri: documentUri, sort_date: row.documents?.sort_date }, + ), }); // Documents publish under record.path (usually "/", but other // clients can write any path) — drop both spellings. @@ -123,13 +197,10 @@ export async function revalidateDocumentPaths( pubs.map((p) => p.uri), ); for (const pub of pubs) - revalidatePostPaths( - pub.uri, - pub.name, - docUri.rkey, - docPath, - pagePaths.get(pub.uri), - ); + revalidatePostPaths(pub.uri, pub.name, docUri.rkey, docPath, [ + ...(pagePaths.get(pub.uri) ?? []), + ...pub.neighbours, + ]); // Handle-form /p/ URLs canonical-redirect onto this one, so the did // spelling is the only cache entry a document has here. revalidatePath(`/p/${docUri.host}/${docUri.rkey}`);