From abcb6ae0ed901f1f73bf12980fb78b4715777cc5 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 14 Oct 2025 17:54:26 -0400 Subject: [PATCH] wip start to paginate followers --- app/discover/PubListing.tsx | 7 +- app/reader/ReaderContent.tsx | 53 +----- app/reader/getReaderFeed.ts | 161 ++++++++++++++++++ app/reader/page.tsx | 129 ++------------ .../Blocks/TextBlock/RenderYJSFragment.tsx | 2 +- lexicons/src/blocks.ts | 30 ++++ 6 files changed, 217 insertions(+), 165 deletions(-) create mode 100644 app/reader/getReaderFeed.ts diff --git a/app/discover/PubListing.tsx b/app/discover/PubListing.tsx index 14387eb1..78699686 100644 --- a/app/discover/PubListing.tsx +++ b/app/discover/PubListing.tsx @@ -65,11 +65,8 @@ export const PubListing = (props: {

Updated{" "} {timeAgo( - props.documents_in_publications.sort((a, b) => { - let dateA = new Date(a.documents?.indexed_at || 0); - let dateB = new Date(b.documents?.indexed_at || 0); - return dateB.getTime() - dateA.getTime(); - })[0].documents?.indexed_at || "", + props.documents_in_publications?.[0]?.documents?.indexed_at || + "", )}

diff --git a/app/reader/ReaderContent.tsx b/app/reader/ReaderContent.tsx index e62b1665..db42c558 100644 --- a/app/reader/ReaderContent.tsx +++ b/app/reader/ReaderContent.tsx @@ -1,6 +1,6 @@ "use client"; import { AtUri } from "@atproto/api"; -import { Interactions } from "app/lish/[did]/[publication]/[rkey]/Interactions/Interactions"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { PubIcon } from "components/ActionBar/Publications"; import { ButtonPrimary } from "components/Buttons"; import { CommentTiny } from "components/Icons/CommentTiny"; @@ -14,31 +14,11 @@ import { useSmoker } from "components/Toast"; import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { Json } from "supabase/database.types"; +import type { Post } from "./getReaderFeed"; export const ReaderContent = (props: { root_entity: string; - posts: { - publication: { - href: string; - pubRecord: Json; - uri: string; - }; - documents: { - data: Json; - uri: string; - indexed_at: string; - comments_on_documents: - | { - count: number; - }[] - | undefined; - document_mentions_in_bsky: - | { - count: number; - }[] - | undefined; - }; - }[]; + posts: Post[]; }) => { if (props.posts.length === 0) return ; return ( @@ -48,28 +28,7 @@ export const ReaderContent = (props: { ); }; -const Post = (props: { - publication: { - pubRecord: Json; - uri: string; - href: string; - }; - documents: { - data: Json; - uri: string; - indexed_at: string; - comments_on_documents: - | { - count: number; - }[] - | undefined; - document_mentions_in_bsky: - | { - count: number; - }[] - | undefined; - }; -}) => { +const Post = (props: Post) => { let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; let postRecord = props.documents.data as PubLeafletDocument.Record; @@ -133,7 +92,7 @@ const Post = (props: { /> @@ -173,7 +132,7 @@ const PostInfo = (props: { }) => { return (
- NAME HERE + {props.author} {props.publishedAt && ( <> diff --git a/app/reader/getReaderFeed.ts b/app/reader/getReaderFeed.ts new file mode 100644 index 00000000..75d18dbe --- /dev/null +++ b/app/reader/getReaderFeed.ts @@ -0,0 +1,161 @@ +"use server"; + +import { getIdentityData } from "actions/getIdentityData"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { supabaseServerClient } from "supabase/serverClient"; +import { IdResolver } from "@atproto/identity"; +import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; +import Client from "ioredis"; +import { AtUri } from "@atproto/api"; +import { Json } from "supabase/database.types"; + +// Create Redis client for DID caching +let redisClient: Client | null = null; +if (process.env.REDIS_URL) { + redisClient = new Client(process.env.REDIS_URL); +} + +// Redis-based DID cache implementation +class RedisDidCache implements DidCache { + private staleTTL: number; + private maxTTL: number; + + constructor( + private client: Client, + staleTTL = 60 * 60, // 1 hour + maxTTL = 60 * 60 * 24, // 24 hours + ) { + this.staleTTL = staleTTL; + this.maxTTL = maxTTL; + } + + async cacheDid(did: string, doc: DidDocument): Promise { + const cacheVal = { + doc, + updatedAt: Date.now(), + }; + await this.client.setex( + `did:${did}`, + this.maxTTL, + JSON.stringify(cacheVal), + ); + } + + async checkCache(did: string): Promise { + const cached = await this.client.get(`did:${did}`); + if (!cached) return null; + + const { doc, updatedAt } = JSON.parse(cached); + const now = Date.now(); + const age = now - updatedAt; + + return { + did, + doc, + updatedAt, + stale: age > this.staleTTL * 1000, + expired: age > this.maxTTL * 1000, + }; + } + + async refreshCache( + did: string, + getDoc: () => Promise, + ): Promise { + const doc = await getDoc(); + if (doc) { + await this.cacheDid(did, doc); + } + } + + async clearEntry(did: string): Promise { + await this.client.del(`did:${did}`); + } + + async clear(): Promise { + const keys = await this.client.keys("did:*"); + if (keys.length > 0) { + await this.client.del(...keys); + } + } +} + +// Create IdResolver with Redis-based DID cache +const idResolver = new IdResolver({ + didCache: redisClient ? new RedisDidCache(redisClient) : undefined, +}); + +export async function getReaderFeed( + cursor?: string, +): Promise<{ posts: Post[]; nextCursor: string | null }> { + let auth_res = await getIdentityData(); + if (!auth_res?.atp_did) return { posts: [], nextCursor: null }; + let query = supabaseServerClient + .from("documents") + .select( + `*, + comments_on_documents(count), + document_mentions_in_bsky(count), + documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, + ) + .eq( + "documents_in_publications.publications.publication_subscriptions.identity", + auth_res.atp_did, + ) + .order("indexed_at", { ascending: false }) + .limit(25); + if (cursor) query.lt("indexed_at", cursor); + let { data: feed, error } = await query; + + let posts = await Promise.all( + feed?.map(async (post) => { + let pub = post.documents_in_publications[0].publications!; + let uri = new AtUri(post.uri); + let handle = await idResolver.did.resolve(uri.host); + let p: Post = { + publication: { + href: getPublicationURL(pub), + pubRecord: pub?.record || null, + uri: pub?.uri || "", + }, + author: handle, + documents: { + comments_on_documents: post.comments_on_documents, + document_mentions_in_bsky: post.document_mentions_in_bsky, + data: post.data, + uri: post.uri, + indexed_at: post.indexed_at, + }, + }; + return p; + }) || [], + ); + return { + posts, + nextCursor: posts[posts.length - 1]?.documents.indexed_at || null, + }; +} + +export type Post = { + author: DidDocument | null; + publication: { + href: string; + pubRecord: Json; + uri: string; + }; + documents: { + data: Json; + uri: string; + indexed_at: string; + comments_on_documents: + | { + count: number; + }[] + | undefined; + document_mentions_in_bsky: + | { + count: number; + }[] + | undefined; + }; +}; diff --git a/app/reader/page.tsx b/app/reader/page.tsx index f6974341..c757f4c9 100644 --- a/app/reader/page.tsx +++ b/app/reader/page.tsx @@ -1,65 +1,25 @@ import { cookies } from "next/headers"; -import { Fact, ReplicacheProvider, useEntity } from "src/replicache"; +import { Fact, ReplicacheProvider } from "src/replicache"; import type { Attribute } from "src/replicache/attributes"; import { ThemeBackgroundProvider, ThemeProvider, } from "components/ThemeManager/ThemeProvider"; import { EntitySetProvider } from "components/EntitySetProvider"; -import { createIdentity } from "actions/createIdentity"; -import { drizzle } from "drizzle-orm/node-postgres"; -import { IdentitySetter } from "app/home/IdentitySetter"; import { getIdentityData } from "actions/getIdentityData"; -import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; import { supabaseServerClient } from "supabase/serverClient"; -import { pool } from "supabase/pool"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; import { ReaderContent } from "./ReaderContent"; import { SubscriptionsContent } from "./SubscriptionsContent"; -import { Json } from "supabase/database.types"; -import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; -import { PubLeafletDocument } from "lexicons/api"; +import { getReaderFeed } from "./getReaderFeed"; export default async function Reader(props: {}) { let cookieStore = await cookies(); let auth_res = await getIdentityData(); let identity: string | undefined; - if (auth_res) identity = auth_res.id; - else identity = cookieStore.get("identity")?.value; - let needstosetcookie = false; - if (!identity) { - const client = await pool.connect(); - const db = drizzle(client); - let newIdentity = await createIdentity(db); - client.release(); - identity = newIdentity.id; - needstosetcookie = true; - } - - async function setCookie() { - "use server"; - - (await cookies()).set("identity", identity as string, { - sameSite: "strict", - }); - } - let permission_token = auth_res?.home_leaflet; - if (!permission_token) { - let res = await supabaseServerClient - .from("identities") - .select( - `*, - permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)) - `, - ) - .eq("id", identity) - .single(); - permission_token = res.data?.permission_tokens; - } - if (!permission_token) return ( @@ -70,83 +30,31 @@ export default async function Reader(props: {}) {

); - let [homeLeafletFacts, allLeafletFacts] = await Promise.all([ + let [homeLeafletFacts] = await Promise.all([ supabaseServerClient.rpc("get_facts", { root: permission_token.root_entity, }), - auth_res - ? getFactsFromHomeLeaflets.handler( - { - tokens: auth_res.permission_token_on_homepage.map( - (r) => r.permission_tokens.root_entity, - ), - }, - { supabase: supabaseServerClient }, - ) - : undefined, ]); let initialFacts = (homeLeafletFacts.data as unknown as Fact[]) || []; let root_entity = permission_token.root_entity; if (!auth_res?.atp_did) return; - let { data: publications } = await supabaseServerClient + let posts = await getReaderFeed(); + let { data: pubs, error } = await supabaseServerClient .from("publication_subscriptions") - .select( - `publications(*, documents_in_publications(documents( - *, - comments_on_documents(count), - document_mentions_in_bsky(count) - )))`, - ) - .eq("identity", auth_res?.atp_did); - - // get publications to fit PublicationList type - let subbedPublications = - publications + .select(`publications(*, documents_in_publications(*, documents(*)))`) + .order(`created_at`, { ascending: false }) + .order("indexed_at", { + referencedTable: "publications.documents_in_publications", + }) + .limit(1, { referencedTable: "publications.documents_in_publications" }) + .eq("identity", auth_res.atp_did); + console.log(error); + let publications = + pubs ?.map((subscription) => subscription.publications) .filter((pub) => pub !== null) || []; - - // Flatten all posts from all publications into a single array - let posts = - subbedPublications?.flatMap((pub) => { - const postsInPub = pub.documents_in_publications.filter( - (d) => !!d?.documents, - ); - - if (!postsInPub || postsInPub.length === 0) return []; - - return postsInPub - .filter( - (postInPub) => - postInPub.documents?.data && - postInPub.documents?.uri && - postInPub.documents?.indexed_at, - ) - .map((postInPub) => ({ - publication: { - href: getPublicationURL(pub!), - pubRecord: pub?.record || null, - uri: pub?.uri || "", - }, - documents: { - data: postInPub.documents!.data, - uri: postInPub.documents!.uri, - indexed_at: postInPub.documents!.indexed_at, - comments_on_documents: postInPub.documents?.comments_on_documents, - document_mentions_in_bsky: - postInPub.documents?.document_mentions_in_bsky, - }, - })); - }) || []; - - let sortedPosts = posts.sort((a, b) => { - let recordA = a.documents.data as PubLeafletDocument.Record; - let recordB = b.documents.data as PubLeafletDocument.Record; - const dateA = new Date(recordA.publishedAt || 0); - const dateB = new Date(recordB.publishedAt || 0); - return dateB.getTime() - dateA.getTime(); - }); return ( - @@ -172,15 +79,13 @@ export default async function Reader(props: {}) { content: ( ), }, Subscriptions: { controls: null, - content: ( - - ), + content: , }, }} /> diff --git a/components/Blocks/TextBlock/RenderYJSFragment.tsx b/components/Blocks/TextBlock/RenderYJSFragment.tsx index 6cbfe1f5..132e72a7 100644 --- a/components/Blocks/TextBlock/RenderYJSFragment.tsx +++ b/components/Blocks/TextBlock/RenderYJSFragment.tsx @@ -32,7 +32,7 @@ export function RenderYJSFragment({ node.toArray().map((node, index) => { if (node.constructor === XmlText) { let deltas = node.toDelta() as Delta[]; - if (deltas.length === 0) return
; + if (deltas.length === 0) return
; return ( {deltas.map((d, index) => { diff --git a/lexicons/src/blocks.ts b/lexicons/src/blocks.ts index 16bb6d4a..b416e32b 100644 --- a/lexicons/src/blocks.ts +++ b/lexicons/src/blocks.ts @@ -177,6 +177,36 @@ export const PubLeafletBlocksImage: LexiconDoc = { }, }; +export const PubLeafletBlocksOrderedList: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.blocks.orderedList", + defs: { + main: { + type: "object", + required: ["children"], + properties: { + startIndex: { type: "integer" }, + children: { type: "array", items: { type: "ref", ref: "#listItem" } }, + }, + }, + listItem: { + type: "object", + required: ["content"], + properties: { + content: { + type: "union", + refs: [ + PubLeafletBlocksText, + PubLeafletBlocksHeader, + PubLeafletBlocksImage, + ].map((l) => l.id), + }, + children: { type: "array", items: { type: "ref", ref: "#listItem" } }, + }, + }, + }, +}; + export const PubLeafletBlocksUnorderedList: LexiconDoc = { lexicon: 1, id: "pub.leaflet.blocks.unorderedList", -- 2.51.2