a tool for shared writing and social publishing

wip start to paginate followers

+217 -165
+2 -5
app/discover/PubListing.tsx
··· 65 65 <p> 66 66 Updated{" "} 67 67 {timeAgo( 68 - props.documents_in_publications.sort((a, b) => { 69 - let dateA = new Date(a.documents?.indexed_at || 0); 70 - let dateB = new Date(b.documents?.indexed_at || 0); 71 - return dateB.getTime() - dateA.getTime(); 72 - })[0].documents?.indexed_at || "", 68 + props.documents_in_publications?.[0]?.documents?.indexed_at || 69 + "", 73 70 )} 74 71 </p> 75 72 </div>
+6 -47
app/reader/ReaderContent.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/api"; 3 - import { Interactions } from "app/lish/[did]/[publication]/[rkey]/Interactions/Interactions"; 3 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 4 import { PubIcon } from "components/ActionBar/Publications"; 5 5 import { ButtonPrimary } from "components/Buttons"; 6 6 import { CommentTiny } from "components/Icons/CommentTiny"; ··· 14 14 import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 15 15 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 16 16 import { Json } from "supabase/database.types"; 17 + import type { Post } from "./getReaderFeed"; 17 18 18 19 export const ReaderContent = (props: { 19 20 root_entity: string; 20 - posts: { 21 - publication: { 22 - href: string; 23 - pubRecord: Json; 24 - uri: string; 25 - }; 26 - documents: { 27 - data: Json; 28 - uri: string; 29 - indexed_at: string; 30 - comments_on_documents: 31 - | { 32 - count: number; 33 - }[] 34 - | undefined; 35 - document_mentions_in_bsky: 36 - | { 37 - count: number; 38 - }[] 39 - | undefined; 40 - }; 41 - }[]; 21 + posts: Post[]; 42 22 }) => { 43 23 if (props.posts.length === 0) return <ReaderEmpty />; 44 24 return ( ··· 48 28 ); 49 29 }; 50 30 51 - const Post = (props: { 52 - publication: { 53 - pubRecord: Json; 54 - uri: string; 55 - href: string; 56 - }; 57 - documents: { 58 - data: Json; 59 - uri: string; 60 - indexed_at: string; 61 - comments_on_documents: 62 - | { 63 - count: number; 64 - }[] 65 - | undefined; 66 - document_mentions_in_bsky: 67 - | { 68 - count: number; 69 - }[] 70 - | undefined; 71 - }; 72 - }) => { 31 + const Post = (props: Post) => { 73 32 let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 74 33 75 34 let postRecord = props.documents.data as PubLeafletDocument.Record; ··· 133 92 /> 134 93 <Separator classname="h-4 !min-h-0 md:block hidden" /> 135 94 <PostInfo 136 - author="NAME HERE" 95 + author={props.author?.alsoKnownAs?.[0]?.slice(5) || ""} 137 96 publishedAt={postRecord.publishedAt} 138 97 /> 139 98 </div> ··· 173 132 }) => { 174 133 return ( 175 134 <div className="flex gap-2 grow items-center shrink-0"> 176 - NAME HERE 135 + {props.author} 177 136 {props.publishedAt && ( 178 137 <> 179 138 <Separator classname="h-4 !min-h-0" />
+161
app/reader/getReaderFeed.ts
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { IdResolver } from "@atproto/identity"; 7 + import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 8 + import Client from "ioredis"; 9 + import { AtUri } from "@atproto/api"; 10 + import { Json } from "supabase/database.types"; 11 + 12 + // Create Redis client for DID caching 13 + let redisClient: Client | null = null; 14 + if (process.env.REDIS_URL) { 15 + redisClient = new Client(process.env.REDIS_URL); 16 + } 17 + 18 + // Redis-based DID cache implementation 19 + class RedisDidCache implements DidCache { 20 + private staleTTL: number; 21 + private maxTTL: number; 22 + 23 + constructor( 24 + private client: Client, 25 + staleTTL = 60 * 60, // 1 hour 26 + maxTTL = 60 * 60 * 24, // 24 hours 27 + ) { 28 + this.staleTTL = staleTTL; 29 + this.maxTTL = maxTTL; 30 + } 31 + 32 + async cacheDid(did: string, doc: DidDocument): Promise<void> { 33 + const cacheVal = { 34 + doc, 35 + updatedAt: Date.now(), 36 + }; 37 + await this.client.setex( 38 + `did:${did}`, 39 + this.maxTTL, 40 + JSON.stringify(cacheVal), 41 + ); 42 + } 43 + 44 + async checkCache(did: string): Promise<CacheResult | null> { 45 + const cached = await this.client.get(`did:${did}`); 46 + if (!cached) return null; 47 + 48 + const { doc, updatedAt } = JSON.parse(cached); 49 + const now = Date.now(); 50 + const age = now - updatedAt; 51 + 52 + return { 53 + did, 54 + doc, 55 + updatedAt, 56 + stale: age > this.staleTTL * 1000, 57 + expired: age > this.maxTTL * 1000, 58 + }; 59 + } 60 + 61 + async refreshCache( 62 + did: string, 63 + getDoc: () => Promise<DidDocument | null>, 64 + ): Promise<void> { 65 + const doc = await getDoc(); 66 + if (doc) { 67 + await this.cacheDid(did, doc); 68 + } 69 + } 70 + 71 + async clearEntry(did: string): Promise<void> { 72 + await this.client.del(`did:${did}`); 73 + } 74 + 75 + async clear(): Promise<void> { 76 + const keys = await this.client.keys("did:*"); 77 + if (keys.length > 0) { 78 + await this.client.del(...keys); 79 + } 80 + } 81 + } 82 + 83 + // Create IdResolver with Redis-based DID cache 84 + const idResolver = new IdResolver({ 85 + didCache: redisClient ? new RedisDidCache(redisClient) : undefined, 86 + }); 87 + 88 + export async function getReaderFeed( 89 + cursor?: string, 90 + ): Promise<{ posts: Post[]; nextCursor: string | null }> { 91 + let auth_res = await getIdentityData(); 92 + if (!auth_res?.atp_did) return { posts: [], nextCursor: null }; 93 + let query = supabaseServerClient 94 + .from("documents") 95 + .select( 96 + `*, 97 + comments_on_documents(count), 98 + document_mentions_in_bsky(count), 99 + documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, 100 + ) 101 + .eq( 102 + "documents_in_publications.publications.publication_subscriptions.identity", 103 + auth_res.atp_did, 104 + ) 105 + .order("indexed_at", { ascending: false }) 106 + .limit(25); 107 + if (cursor) query.lt("indexed_at", cursor); 108 + let { data: feed, error } = await query; 109 + 110 + let posts = await Promise.all( 111 + feed?.map(async (post) => { 112 + let pub = post.documents_in_publications[0].publications!; 113 + let uri = new AtUri(post.uri); 114 + let handle = await idResolver.did.resolve(uri.host); 115 + let p: Post = { 116 + publication: { 117 + href: getPublicationURL(pub), 118 + pubRecord: pub?.record || null, 119 + uri: pub?.uri || "", 120 + }, 121 + author: handle, 122 + documents: { 123 + comments_on_documents: post.comments_on_documents, 124 + document_mentions_in_bsky: post.document_mentions_in_bsky, 125 + data: post.data, 126 + uri: post.uri, 127 + indexed_at: post.indexed_at, 128 + }, 129 + }; 130 + return p; 131 + }) || [], 132 + ); 133 + return { 134 + posts, 135 + nextCursor: posts[posts.length - 1]?.documents.indexed_at || null, 136 + }; 137 + } 138 + 139 + export type Post = { 140 + author: DidDocument | null; 141 + publication: { 142 + href: string; 143 + pubRecord: Json; 144 + uri: string; 145 + }; 146 + documents: { 147 + data: Json; 148 + uri: string; 149 + indexed_at: string; 150 + comments_on_documents: 151 + | { 152 + count: number; 153 + }[] 154 + | undefined; 155 + document_mentions_in_bsky: 156 + | { 157 + count: number; 158 + }[] 159 + | undefined; 160 + }; 161 + };
+17 -112
app/reader/page.tsx
··· 1 1 import { cookies } from "next/headers"; 2 - import { Fact, ReplicacheProvider, useEntity } from "src/replicache"; 2 + import { Fact, ReplicacheProvider } from "src/replicache"; 3 3 import type { Attribute } from "src/replicache/attributes"; 4 4 import { 5 5 ThemeBackgroundProvider, 6 6 ThemeProvider, 7 7 } from "components/ThemeManager/ThemeProvider"; 8 8 import { EntitySetProvider } from "components/EntitySetProvider"; 9 - import { createIdentity } from "actions/createIdentity"; 10 - import { drizzle } from "drizzle-orm/node-postgres"; 11 - import { IdentitySetter } from "app/home/IdentitySetter"; 12 9 import { getIdentityData } from "actions/getIdentityData"; 13 - import { getFactsFromHomeLeaflets } from "app/api/rpc/[command]/getFactsFromHomeLeaflets"; 14 10 import { supabaseServerClient } from "supabase/serverClient"; 15 - import { pool } from "supabase/pool"; 16 11 17 12 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 18 13 import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 19 14 import { ReaderContent } from "./ReaderContent"; 20 15 import { SubscriptionsContent } from "./SubscriptionsContent"; 21 - import { Json } from "supabase/database.types"; 22 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 23 - import { PubLeafletDocument } from "lexicons/api"; 16 + import { getReaderFeed } from "./getReaderFeed"; 24 17 25 18 export default async function Reader(props: {}) { 26 19 let cookieStore = await cookies(); 27 20 let auth_res = await getIdentityData(); 28 21 let identity: string | undefined; 29 - if (auth_res) identity = auth_res.id; 30 - else identity = cookieStore.get("identity")?.value; 31 - let needstosetcookie = false; 32 - if (!identity) { 33 - const client = await pool.connect(); 34 - const db = drizzle(client); 35 - let newIdentity = await createIdentity(db); 36 - client.release(); 37 - identity = newIdentity.id; 38 - needstosetcookie = true; 39 - } 40 - 41 - async function setCookie() { 42 - "use server"; 43 - 44 - (await cookies()).set("identity", identity as string, { 45 - sameSite: "strict", 46 - }); 47 - } 48 - 49 22 let permission_token = auth_res?.home_leaflet; 50 - if (!permission_token) { 51 - let res = await supabaseServerClient 52 - .from("identities") 53 - .select( 54 - `*, 55 - permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)) 56 - `, 57 - ) 58 - .eq("id", identity) 59 - .single(); 60 - permission_token = res.data?.permission_tokens; 61 - } 62 - 63 23 if (!permission_token) 64 24 return ( 65 25 <NotFoundLayout> ··· 70 30 </p> 71 31 </NotFoundLayout> 72 32 ); 73 - let [homeLeafletFacts, allLeafletFacts] = await Promise.all([ 33 + let [homeLeafletFacts] = await Promise.all([ 74 34 supabaseServerClient.rpc("get_facts", { 75 35 root: permission_token.root_entity, 76 36 }), 77 - auth_res 78 - ? getFactsFromHomeLeaflets.handler( 79 - { 80 - tokens: auth_res.permission_token_on_homepage.map( 81 - (r) => r.permission_tokens.root_entity, 82 - ), 83 - }, 84 - { supabase: supabaseServerClient }, 85 - ) 86 - : undefined, 87 37 ]); 88 38 let initialFacts = 89 39 (homeLeafletFacts.data as unknown as Fact<Attribute>[]) || []; 90 40 let root_entity = permission_token.root_entity; 91 41 92 42 if (!auth_res?.atp_did) return; 93 - let { data: publications } = await supabaseServerClient 43 + let posts = await getReaderFeed(); 44 + let { data: pubs, error } = await supabaseServerClient 94 45 .from("publication_subscriptions") 95 - .select( 96 - `publications(*, documents_in_publications(documents( 97 - *, 98 - comments_on_documents(count), 99 - document_mentions_in_bsky(count) 100 - )))`, 101 - ) 102 - .eq("identity", auth_res?.atp_did); 103 - 104 - // get publications to fit PublicationList type 105 - let subbedPublications = 106 - publications 46 + .select(`publications(*, documents_in_publications(*, documents(*)))`) 47 + .order(`created_at`, { ascending: false }) 48 + .order("indexed_at", { 49 + referencedTable: "publications.documents_in_publications", 50 + }) 51 + .limit(1, { referencedTable: "publications.documents_in_publications" }) 52 + .eq("identity", auth_res.atp_did); 53 + console.log(error); 54 + let publications = 55 + pubs 107 56 ?.map((subscription) => subscription.publications) 108 57 .filter((pub) => pub !== null) || []; 109 - 110 - // Flatten all posts from all publications into a single array 111 - let posts = 112 - subbedPublications?.flatMap((pub) => { 113 - const postsInPub = pub.documents_in_publications.filter( 114 - (d) => !!d?.documents, 115 - ); 116 - 117 - if (!postsInPub || postsInPub.length === 0) return []; 118 - 119 - return postsInPub 120 - .filter( 121 - (postInPub) => 122 - postInPub.documents?.data && 123 - postInPub.documents?.uri && 124 - postInPub.documents?.indexed_at, 125 - ) 126 - .map((postInPub) => ({ 127 - publication: { 128 - href: getPublicationURL(pub!), 129 - pubRecord: pub?.record || null, 130 - uri: pub?.uri || "", 131 - }, 132 - documents: { 133 - data: postInPub.documents!.data, 134 - uri: postInPub.documents!.uri, 135 - indexed_at: postInPub.documents!.indexed_at, 136 - comments_on_documents: postInPub.documents?.comments_on_documents, 137 - document_mentions_in_bsky: 138 - postInPub.documents?.document_mentions_in_bsky, 139 - }, 140 - })); 141 - }) || []; 142 - 143 - let sortedPosts = posts.sort((a, b) => { 144 - let recordA = a.documents.data as PubLeafletDocument.Record; 145 - let recordB = b.documents.data as PubLeafletDocument.Record; 146 - const dateA = new Date(recordA.publishedAt || 0); 147 - const dateB = new Date(recordB.publishedAt || 0); 148 - return dateB.getTime() - dateA.getTime(); 149 - }); 150 58 return ( 151 59 <ReplicacheProvider 152 60 rootEntity={root_entity} ··· 154 62 name={root_entity} 155 63 initialFacts={initialFacts} 156 64 > 157 - <IdentitySetter cb={setCookie} call={needstosetcookie} /> 158 65 <EntitySetProvider 159 66 set={permission_token.permission_token_rights[0].entity_set} 160 67 > ··· 172 79 content: ( 173 80 <ReaderContent 174 81 root_entity={root_entity} 175 - posts={sortedPosts} 82 + posts={posts.posts} 176 83 /> 177 84 ), 178 85 }, 179 86 Subscriptions: { 180 87 controls: null, 181 - content: ( 182 - <SubscriptionsContent publications={subbedPublications} /> 183 - ), 88 + content: <SubscriptionsContent publications={publications} />, 184 89 }, 185 90 }} 186 91 />
+1 -1
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 32 32 node.toArray().map((node, index) => { 33 33 if (node.constructor === XmlText) { 34 34 let deltas = node.toDelta() as Delta[]; 35 - if (deltas.length === 0) return <br />; 35 + if (deltas.length === 0) return <br key={index} />; 36 36 return ( 37 37 <Fragment key={index}> 38 38 {deltas.map((d, index) => {
+30
lexicons/src/blocks.ts
··· 177 177 }, 178 178 }; 179 179 180 + export const PubLeafletBlocksOrderedList: LexiconDoc = { 181 + lexicon: 1, 182 + id: "pub.leaflet.blocks.orderedList", 183 + defs: { 184 + main: { 185 + type: "object", 186 + required: ["children"], 187 + properties: { 188 + startIndex: { type: "integer" }, 189 + children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 190 + }, 191 + }, 192 + listItem: { 193 + type: "object", 194 + required: ["content"], 195 + properties: { 196 + content: { 197 + type: "union", 198 + refs: [ 199 + PubLeafletBlocksText, 200 + PubLeafletBlocksHeader, 201 + PubLeafletBlocksImage, 202 + ].map((l) => l.id), 203 + }, 204 + children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 205 + }, 206 + }, 207 + }, 208 + }; 209 + 180 210 export const PubLeafletBlocksUnorderedList: LexiconDoc = { 181 211 lexicon: 1, 182 212 id: "pub.leaflet.blocks.unorderedList",