a tool for shared writing and social publishing

make subscriptions infinite scrolling as well

+265 -36
+7 -10
app/discover/PubListing.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 + import { PublicationSubscription } from "app/reader/getSubscriptions"; 3 4 import { PubIcon } from "components/ActionBar/Publications"; 4 5 import { Separator } from "components/Layout"; 5 6 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; ··· 9 10 import { timeAgo } from "src/utils/timeAgo"; 10 11 import { Json } from "supabase/database.types"; 11 12 12 - export const PubListing = (props: { 13 - resizeHeight?: boolean; 14 - record: Json; 15 - uri: string; 16 - documents_in_publications: { 17 - documents: { data: Json; indexed_at: string } | null; 18 - }[]; 19 - }) => { 13 + export const PubListing = ( 14 + props: PublicationSubscription & { 15 + resizeHeight?: boolean; 16 + }, 17 + ) => { 20 18 let record = props.record as PubLeafletPublication.Record; 21 19 let theme = usePubTheme(record); 22 20 let backgroundImage = record?.theme?.backgroundImage?.image?.ref ··· 59 57 )} 60 58 <div className="flex flex-col items-center justify-center text-xs text-tertiary pt-2"> 61 59 <div className="flex flex-row gap-2 items-center"> 62 - <div className="h-[14px] w-[14px] rounded-full bg-test shrink-0" /> 63 - <p>Name Here</p>{" "} 60 + {props.authorProfile?.handle} 64 61 </div> 65 62 <p> 66 63 Updated{" "}
+1 -1
app/reader/ReaderContent.tsx
··· 83 83 {/* Trigger element for loading more posts */} 84 84 <div 85 85 ref={loadMoreRef} 86 - className="absolute bottom-0 left-0 w-full h-px pointer-events-none" 86 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 87 87 aria-hidden="true" 88 88 /> 89 89 {isValidating && (
+78 -10
app/reader/SubscriptionsContent.tsx
··· 1 + "use client"; 1 2 import { PubListing } from "app/discover/PubListing"; 2 3 import { ButtonPrimary } from "components/Buttons"; 3 4 import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 4 5 import { Json } from "supabase/database.types"; 6 + import { PublicationSubscription, getSubscriptions } from "./getSubscriptions"; 7 + import useSWRInfinite from "swr/infinite"; 8 + import { useEffect, useRef } from "react"; 5 9 6 10 export const SubscriptionsContent = (props: { 7 - publications: { 8 - record: Json; 9 - uri: string; 10 - documents_in_publications: { 11 - documents: { data: Json; indexed_at: string } | null; 12 - }[]; 13 - }[]; 11 + publications: PublicationSubscription[]; 12 + nextCursor: string | null; 14 13 }) => { 15 - if (props.publications.length === 0) return <SubscriptionsEmpty />; 14 + const getKey = ( 15 + pageIndex: number, 16 + previousPageData: { 17 + subscriptions: PublicationSubscription[]; 18 + nextCursor: string | null; 19 + } | null, 20 + ) => { 21 + // Reached the end 22 + if (previousPageData && !previousPageData.nextCursor) return null; 23 + 24 + // First page, we don't have previousPageData 25 + if (pageIndex === 0) return ["subscriptions", null]; 26 + 27 + // Add the cursor to the key 28 + return ["subscriptions", previousPageData?.nextCursor]; 29 + }; 30 + 31 + const { data, error, size, setSize, isValidating } = useSWRInfinite( 32 + getKey, 33 + ([_, cursor]) => getSubscriptions(cursor), 34 + { 35 + fallbackData: [ 36 + { subscriptions: props.publications, nextCursor: props.nextCursor }, 37 + ], 38 + revalidateFirstPage: false, 39 + }, 40 + ); 41 + 42 + const loadMoreRef = useRef<HTMLDivElement>(null); 43 + 44 + // Set up intersection observer to load more when trigger element is visible 45 + useEffect(() => { 46 + const observer = new IntersectionObserver( 47 + (entries) => { 48 + if (entries[0].isIntersecting && !isValidating) { 49 + const hasMore = data && data[data.length - 1]?.nextCursor; 50 + if (hasMore) { 51 + setSize(size + 1); 52 + } 53 + } 54 + }, 55 + { threshold: 0.1 }, 56 + ); 57 + 58 + if (loadMoreRef.current) { 59 + observer.observe(loadMoreRef.current); 60 + } 61 + 62 + return () => observer.disconnect(); 63 + }, [data, size, setSize, isValidating]); 64 + 65 + const allPublications = data 66 + ? data.flatMap((page) => page.subscriptions) 67 + : []; 68 + 69 + if (allPublications.length === 0 && !isValidating) 70 + return <SubscriptionsEmpty />; 16 71 17 72 return ( 18 - <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 19 - {props.publications?.map((p) => <PubListing key={p.uri} {...p} />)} 73 + <div className="relative"> 74 + <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 75 + {allPublications?.map((p) => <PubListing key={p.uri} {...p} />)} 76 + </div> 77 + {/* Trigger element for loading more subscriptions */} 78 + <div 79 + ref={loadMoreRef} 80 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 81 + aria-hidden="true" 82 + /> 83 + {isValidating && ( 84 + <div className="text-center text-tertiary py-4"> 85 + Loading more subscriptions... 86 + </div> 87 + )} 20 88 </div> 21 89 ); 22 90 };
+66
app/reader/getSubscriptions.ts
··· 1 + "use server"; 2 + 3 + import { AtpAgent } from "@atproto/api"; 4 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 5 + import { getIdentityData } from "actions/getIdentityData"; 6 + import { Json } from "supabase/database.types"; 7 + import { supabaseServerClient } from "supabase/serverClient"; 8 + import { idResolver } from "./idResolver"; 9 + 10 + export async function getSubscriptions(cursor?: string | null): Promise<{ 11 + nextCursor: null | string; 12 + subscriptions: PublicationSubscription[]; 13 + }> { 14 + let auth_res = await getIdentityData(); 15 + if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 16 + let query = supabaseServerClient 17 + .from("publication_subscriptions") 18 + .select(`publications(*, documents_in_publications(*, documents(*)))`) 19 + .order(`created_at`, { ascending: false }) 20 + .order("indexed_at", { 21 + referencedTable: "publications.documents_in_publications", 22 + }) 23 + .limit(1, { referencedTable: "publications.documents_in_publications" }) 24 + .limit(25) 25 + .eq("identity", auth_res.atp_did); 26 + 27 + if (cursor) query.lt("indexed_at", cursor); 28 + let { data: pubs, error } = await query; 29 + 30 + const actors: string[] = [ 31 + ...new Set( 32 + pubs?.map((pub) => pub.publications?.identity_did!).filter(Boolean) || [], 33 + ), 34 + ]; 35 + const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( 36 + pubs?.map(async (pub) => { 37 + let id = await idResolver.did.resolve(pub.publications?.identity_did!); 38 + return { 39 + ...pub.publications!, 40 + authorProfile: id?.alsoKnownAs?.[0] 41 + ? { handle: id.alsoKnownAs[0] } 42 + : undefined, 43 + }; 44 + }) || [], 45 + ); 46 + 47 + const nextCursor = 48 + pubs && pubs.length > 0 49 + ? pubs[pubs.length - 1].publications?.documents_in_publications?.[0] 50 + ?.indexed_at || null 51 + : null; 52 + 53 + return { 54 + subscriptions: hydratedSubscriptions, 55 + nextCursor, 56 + }; 57 + } 58 + 59 + export type PublicationSubscription = { 60 + authorProfile?: { handle: string }; 61 + record: Json; 62 + uri: string; 63 + documents_in_publications: { 64 + documents: { data?: Json; indexed_at: string } | null; 65 + }[]; 66 + };
+78
app/reader/idResolver.ts
··· 1 + import { IdResolver } from "@atproto/identity"; 2 + import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 3 + import Client from "ioredis"; 4 + // Create Redis client for DID caching 5 + let redisClient: Client | null = null; 6 + if (process.env.REDIS_URL) { 7 + redisClient = new Client(process.env.REDIS_URL); 8 + } 9 + 10 + // Redis-based DID cache implementation 11 + class RedisDidCache implements DidCache { 12 + private staleTTL: number; 13 + private maxTTL: number; 14 + 15 + constructor( 16 + private client: Client, 17 + staleTTL = 60 * 60, // 1 hour 18 + maxTTL = 60 * 60 * 24, // 24 hours 19 + ) { 20 + this.staleTTL = staleTTL; 21 + this.maxTTL = maxTTL; 22 + } 23 + 24 + async cacheDid(did: string, doc: DidDocument): Promise<void> { 25 + const cacheVal = { 26 + doc, 27 + updatedAt: Date.now(), 28 + }; 29 + await this.client.setex( 30 + `did:${did}`, 31 + this.maxTTL, 32 + JSON.stringify(cacheVal), 33 + ); 34 + } 35 + 36 + async checkCache(did: string): Promise<CacheResult | null> { 37 + const cached = await this.client.get(`did:${did}`); 38 + if (!cached) return null; 39 + 40 + const { doc, updatedAt } = JSON.parse(cached); 41 + const now = Date.now(); 42 + const age = now - updatedAt; 43 + 44 + return { 45 + did, 46 + doc, 47 + updatedAt, 48 + stale: age > this.staleTTL * 1000, 49 + expired: age > this.maxTTL * 1000, 50 + }; 51 + } 52 + 53 + async refreshCache( 54 + did: string, 55 + getDoc: () => Promise<DidDocument | null>, 56 + ): Promise<void> { 57 + const doc = await getDoc(); 58 + if (doc) { 59 + await this.cacheDid(did, doc); 60 + } 61 + } 62 + 63 + async clearEntry(did: string): Promise<void> { 64 + await this.client.del(`did:${did}`); 65 + } 66 + 67 + async clear(): Promise<void> { 68 + const keys = await this.client.keys("did:*"); 69 + if (keys.length > 0) { 70 + await this.client.del(...keys); 71 + } 72 + } 73 + } 74 + 75 + // Create IdResolver with Redis-based DID cache 76 + export const idResolver = new IdResolver({ 77 + didCache: redisClient ? new RedisDidCache(redisClient) : undefined, 78 + });
+8 -15
app/reader/page.tsx
··· 14 14 import { ReaderContent } from "./ReaderContent"; 15 15 import { SubscriptionsContent } from "./SubscriptionsContent"; 16 16 import { getReaderFeed } from "./getReaderFeed"; 17 + import { getSubscriptions } from "./getSubscriptions"; 17 18 18 19 export default async function Reader(props: {}) { 19 20 let cookieStore = await cookies(); ··· 41 42 42 43 if (!auth_res?.atp_did) return; 43 44 let posts = await getReaderFeed(); 44 - let { data: pubs, error } = await supabaseServerClient 45 - .from("publication_subscriptions") 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 56 - ?.map((subscription) => subscription.publications) 57 - .filter((pub) => pub !== null) || []; 45 + let publications = await getSubscriptions(); 58 46 return ( 59 47 <ReplicacheProvider 60 48 rootEntity={root_entity} ··· 86 74 }, 87 75 Subscriptions: { 88 76 controls: null, 89 - content: <SubscriptionsContent publications={publications} />, 77 + content: ( 78 + <SubscriptionsContent 79 + publications={publications.subscriptions} 80 + nextCursor={publications.nextCursor} 81 + /> 82 + ), 90 83 }, 91 84 }} 92 85 />
+5
components/PageLayouts/DashboardLayout.tsx
··· 22 22 import { updateIdentityInterfaceState } from "actions/updateIdentityInterfaceState"; 23 23 import Link from "next/link"; 24 24 import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; 25 + import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 25 26 26 27 export type DashboardState = { 27 28 display?: "grid" | "list"; ··· 135 136 }) { 136 137 let [tab, setTab] = useState(props.defaultTab); 137 138 let { content, controls } = props.tabs[tab]; 139 + let { ref } = usePreserveScroll<HTMLDivElement>( 140 + `dashboard-${props.id}-${tab as string}`, 141 + ); 138 142 139 143 let [headerState, setHeaderState] = useState<"default" | "controls">( 140 144 "default", ··· 155 159 </MediaContents> 156 160 <div 157 161 className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-12 px-3 sm:pt-8 sm:pb-12 sm:pl-6 sm:pr-4 `} 162 + ref={ref} 158 163 id="home-content" 159 164 > 160 165 {Object.keys(props.tabs).length <= 1 && !controls ? null : (
+22
src/hooks/usePreserveScroll.ts
··· 1 + import { useRef, useEffect } from "react"; 2 + 3 + let scrollPositions: { [key: string]: number } = {}; 4 + export function usePreserveScroll<T extends HTMLElement>(key: string | null) { 5 + let ref = useRef<T | null>(null); 6 + useEffect(() => { 7 + if (!ref.current || !key) return; 8 + 9 + window.requestAnimationFrame(() => { 10 + ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 11 + }); 12 + 13 + const listener = () => { 14 + if (!ref.current?.scrollTop) return; 15 + scrollPositions[key] = ref.current.scrollTop; 16 + }; 17 + 18 + ref.current.addEventListener("scroll", listener); 19 + return () => ref.current?.removeEventListener("scroll", listener); 20 + }, [key, ref.current]); 21 + return { ref }; 22 + }