diff --git a/actions/publishBskyPost.ts b/actions/publishBskyPost.ts index fae38c96..ff737aa4 100644 --- a/actions/publishBskyPost.ts +++ b/actions/publishBskyPost.ts @@ -11,6 +11,7 @@ import { AtUri } from "@atproto/syntax"; import { getAuthIdentity } from "src/auth"; import { AtpBaseClient, SiteStandardDocument } from "lexicons/api"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; +import { loggedFetchHandler } from "src/utils/loggedFetchHandler"; import type { OAuthSession } from "@atproto/oauth-client-node"; import { idResolver } from "src/identity"; import { supabaseServerClient } from "supabase/serverClient"; @@ -60,7 +61,7 @@ async function postingSession(did?: string): Promise< if (!sessionResult.ok) return Err(sessionResult.error); let credentialSession = sessionResult.value; let agent = new AtpBaseClient( - credentialSession.fetchHandler.bind(credentialSession), + loggedFetchHandler(credentialSession, { actorDid: identity.atp_did }), ); let uploadThumb: UploadThumb = (bytes) => agent.com.atproto.repo @@ -298,7 +299,10 @@ async function createExternalCardPost( external.associatedRefs = args.associatedRefs; if (args.thumb) external.thumb = args.thumb; - let bsky = new BskyAgent(credentialSession); + let bsky = new BskyAgent({ + did: credentialSession.did, + fetchHandler: loggedFetchHandler(credentialSession, { url: args.url }), + }); return bsky.app.bsky.feed.post.create( { repo: credentialSession.did!, diff --git a/app/(app)/(published)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx b/app/(app)/(published)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx index 8419f14b..88874848 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx @@ -13,6 +13,7 @@ import { type PublicationPostsListFakePost, } from "./PublicationPostsList"; import { type PublicationPostsListPost } from "src/utils/buildPublicationPosts"; +import { sortPostsForList } from "src/utils/postsListPagination"; type FakePost = PublicationPostsListFakePost; @@ -53,7 +54,7 @@ export const DefaultPublicationHomepage = ({ const navPages = publishedNavPages(publication.publication_pages); const posts: PublicationPostsListPost[] = fakePosts ? [] - : resolvedPosts ?? []; + : sortPostsForList(resolvedPosts ?? []); return ( <> ({ + PublicationPostsList: (props: { posts: PublicationPostsListPost[] }) => + React.createElement( + "ul", + null, + props.posts.map((p) => React.createElement("li", { key: p.uri }, p.uri)), + ), +})); + +let controls: { setState: (s: any) => void } | null = null; +vi.mock("./PostsListReaderControls", () => ({ + PostsListReaderControlsBar: (props: any) => { + controls = props; + return null; + }, +})); + +import { PaginatedPublicationPostsList } from "./PaginatedPublicationPostsList"; + +type Observer = { trigger: () => void }; +let observers: Observer[] = []; +class FakeIntersectionObserver { + cb: IntersectionObserverCallback; + constructor(cb: IntersectionObserverCallback) { + this.cb = cb; + observers.push({ + trigger: () => this.cb([{ isIntersecting: true } as any], this as any), + }); + } + observe() {} + disconnect() {} +} + +const post = (i: number): PublicationPostsListPost => ({ + uri: `at://did/p/${String(i).padStart(3, "0")}`, + record: { + title: `Post ${i}`, + publishedAt: new Date(2026, 0, 1, 0, i).toISOString(), + } as any, + commentsCount: 0, + mentionsCount: 0, + recommendsCount: 0, + membersOnly: false, +}); +// Newest first, so the list order is 49, 48, ... 0. +const all = Array.from({ length: 50 }, (_, i) => post(49 - i)); +const uris = all.map((p) => p.uri); +const byUri = new Map(all.map((p) => [p.uri, p])); + +let root: Root; +let container: HTMLElement; +const rendered = () => + Array.from(container.querySelectorAll("li")).map((li) => li.textContent); +const loading = () => container.textContent?.includes("Loading more posts"); +const flush = () => act(async () => {}); + +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + (globalThis as any).IntersectionObserver = FakeIntersectionObserver; + observers = []; + controls = null; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +const render = ( + props: Partial>, +) => + act(async () => { + root.render( + React.createElement(PaginatedPublicationPostsList, { + publication: { uri: "at://did/pub/x", record: {} }, + publicationRecord: null, + uris, + knownPosts: all.slice(0, 20), + ...props, + }), + ); + }); + +describe("PaginatedPublicationPostsList", () => { + it("renders the seeded first page without fetching", async () => { + const loadBatch = vi.fn(async (batch: string[]) => + batch.map((u) => byUri.get(u)!), + ); + await render({ loadBatch }); + expect(rendered()).toEqual(uris.slice(0, 20)); + expect(loadBatch).not.toHaveBeenCalled(); + expect(loading()).toBe(false); + }); + + it("hydrates the next page on scroll and only what it lacks", async () => { + const loadBatch = vi.fn(async (batch: string[]) => + batch.map((u) => byUri.get(u)!), + ); + await render({ loadBatch }); + await act(async () => observers.at(-1)!.trigger()); + expect(loadBatch).toHaveBeenCalledTimes(1); + expect(loadBatch.mock.calls[0][0]).toEqual(uris.slice(20, 40)); + await flush(); + expect(rendered()).toEqual(uris.slice(0, 40)); + expect(loading()).toBe(false); + }); + + it("serves a re-sorted list from already hydrated posts", async () => { + const loadBatch = vi.fn(async (batch: string[]) => + batch.map((u) => byUri.get(u)!), + ); + const index = buildPostsListIndex(all); + await render({ + uris: undefined, + index, + loadBatch, + readerControls: { search: true, tagFilter: true, sort: true }, + }); + expect(rendered()).toEqual(uris.slice(0, 20)); + // "post 4" matches Post 4, 14, 24, 34 and 40-49. Only 4, 14 and 24 fall + // outside the seeded first page, so they are the whole request. + await act(async () => + controls!.setState({ search: "post 4", tags: [], sort: "latest" }), + ); + await act(async () => { + await new Promise((r) => setTimeout(r, 250)); + }); + expect(loadBatch).toHaveBeenCalledTimes(1); + expect(loadBatch.mock.calls[0][0]).toEqual( + [24, 14, 4].map((i) => post(i).uri), + ); + await flush(); + const matches = [ + ...uris.slice(0, 10), + ...[34, 24, 14, 4].map((i) => post(i).uri), + ]; + expect(rendered()).toEqual(matches); + // Switching to oldest reorders without another request. + await act(async () => + controls!.setState({ search: "post 4", tags: [], sort: "oldest" }), + ); + await act(async () => { + await new Promise((r) => setTimeout(r, 250)); + }); + expect(loadBatch).toHaveBeenCalledTimes(1); + expect(rendered()).toEqual([...matches].reverse()); + }); + + it("does not re-request a post the loader came back without", async () => { + const loadBatch = vi.fn(async (batch: string[]) => + batch.filter((u) => u !== uris[25]).map((u) => byUri.get(u)!), + ); + await render({ loadBatch }); + await act(async () => observers.at(-1)!.trigger()); + await flush(); + expect(loadBatch).toHaveBeenCalledTimes(1); + expect(rendered()).toEqual(uris.slice(0, 40).filter((u) => u !== uris[25])); + expect(loading()).toBe(false); + }); + + it("treats every post as known in the editor", async () => { + await render({ knownPosts: all, loadBatch: undefined }); + await act(async () => observers.at(-1)!.trigger()); + expect(rendered()).toEqual(uris.slice(0, 40)); + expect(loading()).toBe(false); + }); +}); diff --git a/app/(app)/(published)/lish/[did]/[publication]/PaginatedPublicationPostsList.tsx b/app/(app)/(published)/lish/[did]/[publication]/PaginatedPublicationPostsList.tsx index 64b4c7ff..b9cfedcd 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/PaginatedPublicationPostsList.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/PaginatedPublicationPostsList.tsx @@ -1,11 +1,8 @@ "use client"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import useSWRInfinite from "swr/infinite"; -import { SpeedyLink } from "components/SpeedyLink"; -import { getPublicationURL } from "src/utils/getPublicationURL"; import { type NormalizedPublication } from "src/utils/normalizeRecords"; -import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; +import { useDebouncedValue } from "src/hooks/useDebouncedValue"; import { PublicationPostsList } from "./PublicationPostsList"; import { PostsListReaderControlsBar, @@ -26,18 +23,19 @@ const DEFAULT_READER_STATE: PostsListReaderState = { sort: "latest", }; +const RETRY_DELAY_MS = 2000; + export function PaginatedPublicationPostsList({ publication, publicationRecord, - listId, uris, - initialPosts, + index, + knownPosts, loadBatch, view = "medium", highlightFirstPost = false, limit, readerControls, - readerIndex, emptyState, className, disableLinks = false, @@ -45,15 +43,14 @@ export function PaginatedPublicationPostsList({ }: { publication: { uri: string; record: unknown }; publicationRecord: NormalizedPublication | null; - listId: string; - uris: string[]; - initialPosts: PublicationPostsListPost[]; - loadBatch: LoadPostsBatch; + uris?: string[]; + index?: PostsListIndexEntry[]; + knownPosts: PublicationPostsListPost[]; + loadBatch?: LoadPostsBatch; view?: "small" | "medium"; highlightFirstPost?: boolean; limit?: number; readerControls?: PostsListReaderControls; - readerIndex?: PostsListIndexEntry[]; emptyState?: React.ReactNode; className?: string; disableLinks?: boolean; @@ -61,100 +58,105 @@ export function PaginatedPublicationPostsList({ }) { const [readerState, setReaderState] = useState(DEFAULT_READER_STATE); - const [debouncedSearch, setDebouncedSearch] = useState(""); - useDebouncedEffect(() => setDebouncedSearch(readerState.search), 200, [ - readerState.search, - ]); + const search = useDebouncedValue(readerState.search, 200); const searchEnabled = !!readerControls?.search; const tagFilterEnabled = !!readerControls?.tagFilter; const sortEnabled = !!readerControls?.sort; - // this filters the posts according to reader options const orderedUris = useMemo(() => { - if (!readerIndex || !(searchEnabled || tagFilterEnabled || sortEnabled)) - return uris; - - return readerControlledUris(readerIndex, { - search: searchEnabled ? debouncedSearch : "", + if (!index) return uris ?? []; + return readerControlledUris(index, { + search: searchEnabled ? search : "", tags: tagFilterEnabled ? readerState.tags : null, sort: sortEnabled ? readerState.sort : "latest", }); }, [ uris, - readerIndex, + index, searchEnabled, tagFilterEnabled, sortEnabled, - debouncedSearch, + search, readerState.tags, readerState.sort, ]); - const cappedUris = limit && limit > 0 ? orderedUris.slice(0, limit) : orderedUris; - const cappedInitialPosts = - limit && limit > 0 ? initialPosts.slice(0, limit) : initialPosts; - const getKey = (pageIndex: number) => { - const start = pageIndex * POSTS_LIST_PAGE_SIZE; - const slice = cappedUris.slice(start, start + POSTS_LIST_PAGE_SIZE); - if (slice.length === 0) return null; - return ["posts-batch", listId, slice] as const; - }; + const filterKey = `${search}\n${readerState.tags.join(",")}\n${readerState.sort}`; + const [shown, setShown] = useState({ filterKey, pages: 1 }); + const pages = shown.filterKey === filterKey ? shown.pages : 1; + const visibleUris = cappedUris.slice(0, pages * POSTS_LIST_PAGE_SIZE); + const hasMore = cappedUris.length > visibleUris.length; - const isSeededSlice = (slice: string[]) => - slice.length === cappedInitialPosts.length && - cappedInitialPosts.every((p, i) => p.uri === slice[i]); - const seedMatches = - cappedInitialPosts.length > 0 && isSeededSlice(getKey(0)?.[2] ?? []); - - // The server already rendered the first page, so it is served from the seed - // rather than refetched: SWR's `isLoading` ignores fallback data and would - // otherwise dim the list on every mount while re-requesting posts it has. - const { data, size, setSize, isValidating, isLoading } = useSWRInfinite( - getKey, - ([, , slice]) => - seedMatches && isSeededSlice(slice) - ? cappedInitialPosts - : loadBatch(slice), - { - fallbackData: seedMatches ? [cappedInitialPosts] : undefined, - revalidateOnMount: !seedMatches, - revalidateFirstPage: false, - keepPreviousData: true, - }, + const known = useMemo( + () => new Map(knownPosts.map((p) => [p.uri, p])), + [knownPosts], + ); + const [fetched, setFetched] = useState( + () => new Map(), ); + const postFor = (uri: string) => + known.get(uri) ?? (loadBatch ? fetched.get(uri) : null); + + const posts: PublicationPostsListPost[] = []; + const pendingUris: string[] = []; + for (const uri of visibleUris) { + const post = postFor(uri); + if (post === undefined) pendingUris.push(uri); + else if (post && pendingUris.length === 0) posts.push(post); + } + const isHydrating = pendingUris.length > 0; + + const inflight = useRef(new Set()); + const [retry, setRetry] = useState(0); + const pendingKey = pendingUris.join(" "); + useEffect(() => { + if (!loadBatch || !pendingKey) return; + const batch = pendingKey + .split(" ") + .filter((uri) => !inflight.current.has(uri)) + .slice(0, POSTS_LIST_PAGE_SIZE); + if (batch.length === 0) return; + for (const uri of batch) inflight.current.add(uri); + loadBatch(batch) + .then((result) => { + setFetched((prev) => { + const next = new Map(prev); + for (const uri of batch) + next.set(uri, result.find((p) => p.uri === uri) ?? null); + return next; + }); + }) + .catch(() => { + setTimeout(() => setRetry((r) => r + 1), RETRY_DELAY_MS); + }) + .finally(() => { + for (const uri of batch) inflight.current.delete(uri); + }); + }, [loadBatch, pendingKey, retry]); const loadMoreRef = useRef(null); - const hasMore = cappedUris.length > size * POSTS_LIST_PAGE_SIZE; - // `data` gains a page only once the whole array resolves, so a size that has - // outrun it means a batch is in flight. `isValidating` alone also fires for - // focus revalidation, and `hasMore` describes the page *after* the one being - // loaded — it goes false on the final batch, the one readers wait on. - const isLoadingMore = isValidating && size > (data?.length ?? 0); - const hasUnshownPosts = hasMore || cappedUris.length < orderedUris.length; useEffect(() => { + const el = loadMoreRef.current; + if (!el || !hasMore || isHydrating) return; const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && !isValidating && hasMore) { - setSize(size + 1); - } + ([entry]) => { + if (entry.isIntersecting) setShown({ filterKey, pages: pages + 1 }); }, { threshold: 0.1 }, ); - if (loadMoreRef.current) observer.observe(loadMoreRef.current); + observer.observe(el); return () => observer.disconnect(); - }, [size, setSize, isValidating, hasMore]); - - if (uris.length === 0) return <>{emptyState}; + }, [hasMore, isHydrating, filterKey, pages]); - const allPosts = data ? data.flatMap((page) => page) : []; + if ((index ?? uris ?? []).length === 0) return <>{emptyState}; return (
- {readerControls && readerIndex && ( + {readerControls && index && ( @@ -166,17 +168,13 @@ export function PaginatedPublicationPostsList({
) : ( <> -
+
@@ -187,26 +185,13 @@ export function PaginatedPublicationPostsList({ className="absolute bottom-[1200px] left-0 w-full h-px pointer-events-none" aria-hidden="true" /> - {isLoadingMore && ( + {isHydrating && (
Loading more posts...
)} )} - {/* In the SSR HTML whenever posts are missing from it: only the first - batch is served, so crawlers need a plain anchor to the archive to - reach the rest. */} - {!disableLinks && hasUnshownPosts && ( -
- - View all posts - -
- )}
); } diff --git a/app/(app)/(published)/lish/[did]/[publication]/PostsListReaderControls.tsx b/app/(app)/(published)/lish/[did]/[publication]/PostsListReaderControls.tsx index d8711d06..2dac4656 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/PostsListReaderControls.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/PostsListReaderControls.tsx @@ -98,7 +98,7 @@ export function PostsListReaderControlsBar({ )}
{controls.tagFilter && (
diff --git a/app/(app)/(published)/lish/[did]/[publication]/PublicationPostsList.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostsList.tsx index 40121ab3..58e5bb39 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/PublicationPostsList.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostsList.tsx @@ -60,7 +60,6 @@ export function PublicationPostsList({ fakePosts, view = "medium", highlightFirstPost = false, - preSorted = false, className, inList, disableLinks = false, @@ -72,7 +71,6 @@ export function PublicationPostsList({ fakePosts?: PublicationPostsListFakePost[]; view?: PublicationPostsListView; highlightFirstPost?: boolean; - preSorted?: boolean; className?: string; inList?: boolean; disableLinks?: boolean; @@ -123,18 +121,7 @@ export function PublicationPostsList({ date={post.date} /> )) - : (preSorted - ? posts - : posts?.slice().sort((a, b) => { - const aDate = a.record.publishedAt - ? new Date(a.record.publishedAt) - : new Date(0); - const bDate = b.record.publishedAt - ? new Date(b.record.publishedAt) - : new Date(0); - return bDate.getTime() - aDate.getTime(); - }) - )?.map((post, index) => { + : posts?.map((post, index) => { const doc_record = post.record; const quotes = post.mentionsCount; const comments = diff --git a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx index e32a71c9..4052ce6d 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -81,14 +81,14 @@ type PostsListData = { publication: { uri: string; record: unknown }; publicationRecord: NormalizedPublication | null; // Per tag-filter signature (postsListFilterKey), what the blocks using that - // filter need: list views the full ordered URI list plus an SSR-seeded, - // byline-resolved first batch; chapter views the server-grouped cards and - // the newest post for the "Latest" highlight. `index` is only built for - // lists with reader controls, which search/sort across every post. + // filter need: list views the full ordered post list (`uris`, or `index` + // when a block has reader controls) plus an SSR-seeded, byline-resolved + // first batch; chapter views the server-grouped cards and the newest post + // for the "Latest" highlight. initialByFilter: Record< string, { - uris: string[]; + uris?: string[]; initialPosts: PublicationPostsListPost[]; index?: PostsListIndexEntry[]; latestPost?: PublicationPostsListPost; @@ -441,15 +441,14 @@ export let Block = ({
); diff --git a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx index 79aa53f0..9f14a119 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx @@ -181,7 +181,8 @@ export async function PublicationPageRenderer({ return [ key, { - uris: needsList ? ordered.map((p) => p.uri) : [], + uris: + needsList && !needsIndex ? ordered.map((p) => p.uri) : undefined, initialPosts: attachBylineProfiles(firstBatch, profiles), index: needsIndex ? buildPostsListIndex(ordered, (p) => getFirstParagraph(p.record)) diff --git a/components/Blocks/PostsListBlock.tsx b/components/Blocks/PostsListBlock.tsx index c615e88c..c4ff285c 100644 --- a/components/Blocks/PostsListBlock.tsx +++ b/components/Blocks/PostsListBlock.tsx @@ -10,13 +10,10 @@ import { PaginatedPublicationPostsList } from "app/(app)/(published)/lish/[did]/ import { ChapterShelf } from "app/(app)/(published)/lish/[did]/[publication]/PublicationPostsChapterList"; import { buildChapterCards } from "src/utils/chapterGrouping"; import { - POSTS_LIST_PAGE_SIZE, buildPostsListIndex, - postsListFilterKey, resolveReaderControls, sortPostsForList, filterPostsByTags, - type LoadPostsBatch, type PostsListView, } from "src/utils/postsListPagination"; import { getFirstParagraph } from "src/utils/getFirstParagraph"; @@ -111,7 +108,7 @@ function PostsListBlockContent({ entityID }: { entityID: string }) { let listData = useMemo(() => { if (!data?.documents) return null; - let ordered = sortPostsForList( + let ordered: PublicationPostsListPost[] = sortPostsForList( filterPostsByTags(data.documents, filterTags).map((d) => ({ uri: d.uri, record: d.record, @@ -121,14 +118,8 @@ function PostsListBlockContent({ entityID }: { entityID: string }) { membersOnly: d.membersOnly, })), ); - let byUri = new Map(ordered.map((p) => [p.uri, p])); - let loadBatch: LoadPostsBatch = async (batch) => - batch - .map((u) => byUri.get(u)) - .filter((p): p is PublicationPostsListPost => p !== undefined); return { - uris: ordered.map((p) => p.uri), - initialPosts: ordered.slice(0, POSTS_LIST_PAGE_SIZE), + posts: ordered, latestPost: ordered[0], chapterCards: view === "chapter" && data.publication @@ -137,14 +128,13 @@ function PostsListBlockContent({ entityID }: { entityID: string }) { readerIndex: readerControls ? buildPostsListIndex(ordered, (p) => getFirstParagraph(p.record)) : undefined, - loadBatch, }; }, [data?.documents, data?.publication, filterTags, view, readerControls]); if (data === undefined) return ; if (!data?.publication) return ; - if (!listData || listData.uris.length === 0) + if (!listData || listData.posts.length === 0) return ( You haven't published any posts yet! When you do, they'll show here. @@ -171,15 +161,13 @@ function PostsListBlockContent({ entityID }: { entityID: string }) { p.uri)} + index={listData.readerIndex} + knownPosts={listData.posts} view={view} highlightFirstPost={highlightFirst} limit={limit} readerControls={readerControls} - readerIndex={listData.readerIndex} disableLinks pageWidth={pageWidth} /> diff --git a/src/atproto-oauth.ts b/src/atproto-oauth.ts index 75648ed6..8ce7ee77 100644 --- a/src/atproto-oauth.ts +++ b/src/atproto-oauth.ts @@ -173,12 +173,18 @@ function dedupedRestore(did: string): Promise { } export async function restoreOAuthSession( - did: string + did: string, ): Promise> { try { const session = await dedupedRestore(did); return Ok(session); } catch (error) { + // The caller only gets "expired"; the real reason (no stored session, + // refresh rejected, token revoked) is only visible here. + console.error("[oauth] restore session failed", { + did, + error: error instanceof Error ? `${error.name}: ${error.message}` : error, + }); return Err({ type: "oauth_session_expired", message: diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts new file mode 100644 index 00000000..c2748c52 --- /dev/null +++ b/src/hooks/useDebouncedValue.ts @@ -0,0 +1,10 @@ +import { useEffect, useState } from "react"; + +export function useDebouncedValue(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(handle); + }, [value, delay]); + return debounced; +} diff --git a/src/utils/loggedFetchHandler.test.ts b/src/utils/loggedFetchHandler.test.ts new file mode 100644 index 00000000..464fbba3 --- /dev/null +++ b/src/utils/loggedFetchHandler.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OAuthSession } from "@atproto/oauth-client-node"; +import { loggedFetchHandler } from "./loggedFetchHandler"; + +const session = (res: Response) => + ({ did: "did:plc:a", fetchHandler: async () => res }) as unknown as OAuthSession; + +describe("loggedFetchHandler", () => { + it("logs a failed response with the PDS reply and leaves it readable", async () => { + const log = vi.spyOn(console, "error").mockImplementation(() => {}); + const reply = '{"error":"BlobTooLarge","message":"too big"}'; + const res = await loggedFetchHandler( + session( + new Response(reply, { + status: 400, + headers: { "ratelimit-remaining": "5", "content-type": "text/plain" }, + }), + ), + { leaflet_id: "l1" }, + )("/xrpc/com.atproto.repo.uploadBlob", { + method: "post", + body: new Blob([new Uint8Array(3)]), + }); + expect(await res.text()).toBe(reply); + expect(log).toHaveBeenCalledWith( + "[pds] request failed", + expect.objectContaining({ + leaflet_id: "l1", + repo: "did:plc:a", + path: "/xrpc/com.atproto.repo.uploadBlob", + bodyBytes: 3, + status: 400, + response: reply, + headers: { "ratelimit-remaining": "5" }, + }), + ); + log.mockRestore(); + }); + + it("is silent on success", async () => { + const log = vi.spyOn(console, "error").mockImplementation(() => {}); + await loggedFetchHandler(session(new Response("{}")), {})( + "/xrpc/com.atproto.repo.putRecord", + { method: "post", body: "{}" }, + ); + expect(log).not.toHaveBeenCalled(); + log.mockRestore(); + }); +}); diff --git a/src/utils/loggedFetchHandler.ts b/src/utils/loggedFetchHandler.ts new file mode 100644 index 00000000..b600c8bc --- /dev/null +++ b/src/utils/loggedFetchHandler.ts @@ -0,0 +1,55 @@ +import type { FetchHandler } from "@atproto/xrpc"; +import type { OAuthSession } from "@atproto/oauth-client-node"; + +// Rate limiting, DPoP nonce churn, and the PDS's own request correlation ids. +const HEADERS_TO_LOG = + /^(ratelimit-|retry-after|dpop-nonce|www-authenticate|x-request-id|cf-ray)/; + +// Wraps a session's fetch handler so every PDS call that fails (non-2xx or a +// transport error) logs one line with what's needed to reproduce it: which +// XRPC method, payload size, duration, and what the PDS answered. `context` +// is whatever identifies the flow (leaflet id, publication uri, actor). +export function loggedFetchHandler( + session: OAuthSession, + context: Record, +): FetchHandler { + return async (url, init) => { + const started = Date.now(); + const body = init?.body; + const details = () => ({ + ...context, + repo: session.did, + path: url, + bodyBytes: + typeof body === "string" + ? Buffer.byteLength(body) + : body instanceof Blob + ? body.size + : body instanceof Uint8Array + ? body.byteLength + : undefined, + durationMs: Date.now() - started, + }); + let res: Response; + try { + res = await session.fetchHandler(url, init); + } catch (e) { + console.error("[pds] request threw", { + ...details(), + error: e instanceof Error ? `${e.name}: ${e.message}` : String(e), + }); + throw e; + } + if (!res.ok) + console.error("[pds] request failed", { + ...details(), + pds: res.url, + status: res.status, + response: await res.clone().text(), + headers: Object.fromEntries( + [...res.headers].filter(([k]) => HEADERS_TO_LOG.test(k)), + ), + }); + return res; + }; +} diff --git a/src/utils/postsListPagination.test.ts b/src/utils/postsListPagination.test.ts new file mode 100644 index 00000000..c2b5418e --- /dev/null +++ b/src/utils/postsListPagination.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + buildPostsListIndex, + readerControlledUris, + sortPostsForList, +} from "./postsListPagination"; + +const post = (uri: string, publishedAt?: string, tags?: string[]) => ({ + uri, + record: { title: uri, publishedAt, tags }, + commentsCount: 0, + mentionsCount: 0, + recommendsCount: 0, +}); + +describe("readerControlledUris", () => { + // The published page ships only the index when a list has reader controls, + // so the default order must reproduce the server's list order exactly. + it("matches sortPostsForList at the default reader state", () => { + const posts = [ + post("at://a/3", "2026-01-03T00:00:00Z"), + post("at://a/1", "2026-01-01T00:00:00Z"), + post("at://a/undated"), + post("at://a/2b", "2026-01-02T00:00:00Z"), + post("at://a/2a", "2026-01-02T00:00:00Z"), + post("at://a/undated2"), + ]; + const expected = sortPostsForList(posts).map((p) => p.uri); + expect(readerControlledUris(buildPostsListIndex(posts), {})).toEqual( + expected, + ); + expect( + readerControlledUris(buildPostsListIndex(posts), { + search: "", + tags: null, + sort: "latest", + }), + ).toEqual(expected); + }); +}); diff --git a/src/utils/publishHelpers.ts b/src/utils/publishHelpers.ts index a2b3ab63..fee62f31 100644 --- a/src/utils/publishHelpers.ts +++ b/src/utils/publishHelpers.ts @@ -46,7 +46,10 @@ export function makePublishUploadHooks( } as unknown as BlobRef; } const data = await fetch(src); - if (data.status !== 200) return; + if (data.status !== 200) { + console.error("[publish] image fetch failed", src, data.status); + return; + } const binary = await data.blob(); return uploadLock.withLock(async () => { const blob = await agent.com.atproto.repo.uploadBlob(binary, { @@ -146,7 +149,12 @@ export async function extractThemeFromFacts( width: Math.floor(backgroundImageRepeat.data.value), }), }; - } + } else + console.error( + "[publish] theme background image fetch failed", + backgroundImage.data.src, + imageData.status, + ); } if (wordmarkImage?.data) { @@ -172,7 +180,12 @@ export async function extractThemeFromFacts( }, }), }; - } + } else + console.error( + "[publish] wordmark image fetch failed", + wordmarkImage.data.src, + imageData.status, + ); } return theme; diff --git a/src/utils/publishLeaflet.ts b/src/utils/publishLeaflet.ts index 5b4f42e3..f6d845a9 100644 --- a/src/utils/publishLeaflet.ts +++ b/src/utils/publishLeaflet.ts @@ -48,6 +48,8 @@ import { } from "src/utils/collectionHelpers"; import { inngest } from "app/api/inngest/client"; import { withPublishLock } from "src/utils/publishLock"; +import { loggedFetchHandler } from "src/utils/loggedFetchHandler"; +import { XRPCError } from "@atproto/xrpc"; export type PublishError = | OAuthSessionError @@ -100,7 +102,9 @@ export type PublishLeafletArgs = { export async function publishLeaflet( args: PublishLeafletArgs & { actorDid: string }, ): Promise { - let result = await withPublishLock(args.leaflet_id, () => publish(args)); + let result = await withPublishLock(args.leaflet_id, () => + publishLogged(args), + ); if (!result.acquired) return { success: false, @@ -112,6 +116,49 @@ export async function publishLeaflet( return result.value; } +// One summary line per publish, and a PDS rejection (already logged in +// detail by loggedFetchHandler) becomes a publish_failed result that tells +// the author the same thing. +async function publishLogged( + args: PublishLeafletArgs & { actorDid: string }, +): Promise { + const started = Date.now(); + const ids = { + leaflet_id: args.leaflet_id, + publication_uri: args.publication_uri, + actorDid: args.actorDid, + }; + try { + const result = await publish(args); + console.log( + `[publish] ${result.success ? "ok" : `failed: ${result.error.type}`}`, + { + ...ids, + durationMs: Date.now() - started, + ...(result.success + ? { uri: result.uri } + : { message: result.error.message }), + }, + ); + return result; + } catch (e) { + console.error("[publish] threw", { + ...ids, + durationMs: Date.now() - started, + error: e instanceof Error ? `${e.name}: ${e.message}` : String(e), + }); + if (e instanceof XRPCError) + return { + success: false, + error: { + type: "publish_failed", + message: `Your PDS didn't accept this (${e.status} ${e.error}: ${e.message}). Please try again!`, + }, + }; + throw e; + } +} + async function publish({ actorDid, root_entity, @@ -142,7 +189,7 @@ async function publish({ .eq("uri", publication_uri) .eq("leaflets_in_publications.leaflet", leaflet_id) .single(); - console.log(error); + if (error) console.error("[publish] publication lookup failed", error); if (!data) throw new Error("No draft or not publisher"); @@ -189,7 +236,11 @@ async function publish({ } let credentialSession = sessionResult.value; let agent = new AtpBaseClient( - credentialSession.fetchHandler.bind(credentialSession), + loggedFetchHandler(credentialSession, { + leaflet_id, + publication_uri, + actorDid, + }), ); // Heuristic: Remove title entities if this is the first time publishing @@ -311,7 +362,12 @@ async function publish({ headers: { "Content-Type": binary.type }, }); coverImageBlob = blob.data.blob; - } + } else + console.error( + "[publish] cover image fetch failed", + imageData.data.src, + imageResponse.status, + ); } } diff --git a/src/utils/publishPublicationPages.ts b/src/utils/publishPublicationPages.ts index a6a82451..d035c060 100644 --- a/src/utils/publishPublicationPages.ts +++ b/src/utils/publishPublicationPages.ts @@ -8,6 +8,7 @@ import type { Fact } from "src/replicache"; import type { Attribute } from "src/replicache/attributes"; import { readNavEntries } from "src/utils/publicationNavEntries"; import { OAuthSessionError, restoreOAuthSession } from "src/atproto-oauth"; +import { loggedFetchHandler } from "src/utils/loggedFetchHandler"; import { leafletToPublicationPageRecord } from "src/utils/leafletToPublicationPageRecord"; import { extractThemeFromFacts, @@ -110,7 +111,7 @@ export async function publishPublicationPages({ } const credentialSession = sessionResult.value; const agent = new AtpBaseClient( - credentialSession.fetchHandler.bind(credentialSession), + loggedFetchHandler(credentialSession, { publication_uri, actorDid }), ); const { data: existingRows } = await supabaseServerClient