diff --git a/app/discover/PubListing.tsx b/app/discover/PubListing.tsx
index 78699686..582dc331 100644
--- a/app/discover/PubListing.tsx
+++ b/app/discover/PubListing.tsx
@@ -1,5 +1,6 @@
"use client";
import { AtUri } from "@atproto/syntax";
+import { PublicationSubscription } from "app/reader/getSubscriptions";
import { PubIcon } from "components/ActionBar/Publications";
import { Separator } from "components/Layout";
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
@@ -9,14 +10,11 @@ import { blobRefToSrc } from "src/utils/blobRefToSrc";
import { timeAgo } from "src/utils/timeAgo";
import { Json } from "supabase/database.types";
-export const PubListing = (props: {
- resizeHeight?: boolean;
- record: Json;
- uri: string;
- documents_in_publications: {
- documents: { data: Json; indexed_at: string } | null;
- }[];
-}) => {
+export const PubListing = (
+ props: PublicationSubscription & {
+ resizeHeight?: boolean;
+ },
+) => {
let record = props.record as PubLeafletPublication.Record;
let theme = usePubTheme(record);
let backgroundImage = record?.theme?.backgroundImage?.image?.ref
@@ -59,8 +57,7 @@ export const PubListing = (props: {
)}
-
-
Name Here
{" "}
+ {props.authorProfile?.handle}
Updated{" "}
diff --git a/app/reader/ReaderContent.tsx b/app/reader/ReaderContent.tsx
index e3e11df1..4f68ab41 100644
--- a/app/reader/ReaderContent.tsx
+++ b/app/reader/ReaderContent.tsx
@@ -83,7 +83,7 @@ export const ReaderContent = (props: {
{/* Trigger element for loading more posts */}
{isValidating && (
diff --git a/app/reader/SubscriptionsContent.tsx b/app/reader/SubscriptionsContent.tsx
index 9fd83485..57522c19 100644
--- a/app/reader/SubscriptionsContent.tsx
+++ b/app/reader/SubscriptionsContent.tsx
@@ -1,22 +1,90 @@
+"use client";
import { PubListing } from "app/discover/PubListing";
import { ButtonPrimary } from "components/Buttons";
import { DiscoverSmall } from "components/Icons/DiscoverSmall";
import { Json } from "supabase/database.types";
+import { PublicationSubscription, getSubscriptions } from "./getSubscriptions";
+import useSWRInfinite from "swr/infinite";
+import { useEffect, useRef } from "react";
export const SubscriptionsContent = (props: {
- publications: {
- record: Json;
- uri: string;
- documents_in_publications: {
- documents: { data: Json; indexed_at: string } | null;
- }[];
- }[];
+ publications: PublicationSubscription[];
+ nextCursor: string | null;
}) => {
- if (props.publications.length === 0) return
;
+ const getKey = (
+ pageIndex: number,
+ previousPageData: {
+ subscriptions: PublicationSubscription[];
+ nextCursor: string | null;
+ } | null,
+ ) => {
+ // Reached the end
+ if (previousPageData && !previousPageData.nextCursor) return null;
+
+ // First page, we don't have previousPageData
+ if (pageIndex === 0) return ["subscriptions", null];
+
+ // Add the cursor to the key
+ return ["subscriptions", previousPageData?.nextCursor];
+ };
+
+ const { data, error, size, setSize, isValidating } = useSWRInfinite(
+ getKey,
+ ([_, cursor]) => getSubscriptions(cursor),
+ {
+ fallbackData: [
+ { subscriptions: props.publications, nextCursor: props.nextCursor },
+ ],
+ revalidateFirstPage: false,
+ },
+ );
+
+ const loadMoreRef = useRef
(null);
+
+ // Set up intersection observer to load more when trigger element is visible
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && !isValidating) {
+ const hasMore = data && data[data.length - 1]?.nextCursor;
+ if (hasMore) {
+ setSize(size + 1);
+ }
+ }
+ },
+ { threshold: 0.1 },
+ );
+
+ if (loadMoreRef.current) {
+ observer.observe(loadMoreRef.current);
+ }
+
+ return () => observer.disconnect();
+ }, [data, size, setSize, isValidating]);
+
+ const allPublications = data
+ ? data.flatMap((page) => page.subscriptions)
+ : [];
+
+ if (allPublications.length === 0 && !isValidating)
+ return ;
return (
-
- {props.publications?.map((p) =>
)}
+
+
+ {allPublications?.map((p) =>
)}
+
+ {/* Trigger element for loading more subscriptions */}
+
+ {isValidating && (
+
+ Loading more subscriptions...
+
+ )}
);
};
diff --git a/app/reader/getSubscriptions.ts b/app/reader/getSubscriptions.ts
new file mode 100644
index 00000000..0c6b5fd9
--- /dev/null
+++ b/app/reader/getSubscriptions.ts
@@ -0,0 +1,66 @@
+"use server";
+
+import { AtpAgent } from "@atproto/api";
+import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
+import { getIdentityData } from "actions/getIdentityData";
+import { Json } from "supabase/database.types";
+import { supabaseServerClient } from "supabase/serverClient";
+import { idResolver } from "./idResolver";
+
+export async function getSubscriptions(cursor?: string | null): Promise<{
+ nextCursor: null | string;
+ subscriptions: PublicationSubscription[];
+}> {
+ let auth_res = await getIdentityData();
+ if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null };
+ let query = supabaseServerClient
+ .from("publication_subscriptions")
+ .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" })
+ .limit(25)
+ .eq("identity", auth_res.atp_did);
+
+ if (cursor) query.lt("indexed_at", cursor);
+ let { data: pubs, error } = await query;
+
+ const actors: string[] = [
+ ...new Set(
+ pubs?.map((pub) => pub.publications?.identity_did!).filter(Boolean) || [],
+ ),
+ ];
+ const hydratedSubscriptions: PublicationSubscription[] = await Promise.all(
+ pubs?.map(async (pub) => {
+ let id = await idResolver.did.resolve(pub.publications?.identity_did!);
+ return {
+ ...pub.publications!,
+ authorProfile: id?.alsoKnownAs?.[0]
+ ? { handle: id.alsoKnownAs[0] }
+ : undefined,
+ };
+ }) || [],
+ );
+
+ const nextCursor =
+ pubs && pubs.length > 0
+ ? pubs[pubs.length - 1].publications?.documents_in_publications?.[0]
+ ?.indexed_at || null
+ : null;
+
+ return {
+ subscriptions: hydratedSubscriptions,
+ nextCursor,
+ };
+}
+
+export type PublicationSubscription = {
+ authorProfile?: { handle: string };
+ record: Json;
+ uri: string;
+ documents_in_publications: {
+ documents: { data?: Json; indexed_at: string } | null;
+ }[];
+};
diff --git a/app/reader/idResolver.ts b/app/reader/idResolver.ts
new file mode 100644
index 00000000..f4c1af72
--- /dev/null
+++ b/app/reader/idResolver.ts
@@ -0,0 +1,78 @@
+import { IdResolver } from "@atproto/identity";
+import type { DidCache, CacheResult, DidDocument } from "@atproto/identity";
+import Client from "ioredis";
+// 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
+export const idResolver = new IdResolver({
+ didCache: redisClient ? new RedisDidCache(redisClient) : undefined,
+});
diff --git a/app/reader/page.tsx b/app/reader/page.tsx
index 68bc5f70..a22d5ce4 100644
--- a/app/reader/page.tsx
+++ b/app/reader/page.tsx
@@ -14,6 +14,7 @@ import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
import { ReaderContent } from "./ReaderContent";
import { SubscriptionsContent } from "./SubscriptionsContent";
import { getReaderFeed } from "./getReaderFeed";
+import { getSubscriptions } from "./getSubscriptions";
export default async function Reader(props: {}) {
let cookieStore = await cookies();
@@ -41,20 +42,7 @@ export default async function Reader(props: {}) {
if (!auth_res?.atp_did) return;
let posts = await getReaderFeed();
- let { data: pubs, error } = await supabaseServerClient
- .from("publication_subscriptions")
- .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) || [];
+ let publications = await getSubscriptions();
return (
,
+ content: (
+
+ ),
},
}}
/>
diff --git a/components/PageLayouts/DashboardLayout.tsx b/components/PageLayouts/DashboardLayout.tsx
index 8a4baa75..6de5f146 100644
--- a/components/PageLayouts/DashboardLayout.tsx
+++ b/components/PageLayouts/DashboardLayout.tsx
@@ -22,6 +22,7 @@ import { InterfaceState, useIdentityData } from "components/IdentityProvider";
import { updateIdentityInterfaceState } from "actions/updateIdentityInterfaceState";
import Link from "next/link";
import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny";
+import { usePreserveScroll } from "src/hooks/usePreserveScroll";
export type DashboardState = {
display?: "grid" | "list";
@@ -135,6 +136,9 @@ export function DashboardLayout<
}) {
let [tab, setTab] = useState(props.defaultTab);
let { content, controls } = props.tabs[tab];
+ let { ref } = usePreserveScroll(
+ `dashboard-${props.id}-${tab as string}`,
+ );
let [headerState, setHeaderState] = useState<"default" | "controls">(
"default",
@@ -155,6 +159,7 @@ export function DashboardLayout<
{Object.keys(props.tabs).length <= 1 && !controls ? null : (
diff --git a/src/hooks/usePreserveScroll.ts b/src/hooks/usePreserveScroll.ts
new file mode 100644
index 00000000..c64c83f0
--- /dev/null
+++ b/src/hooks/usePreserveScroll.ts
@@ -0,0 +1,22 @@
+import { useRef, useEffect } from "react";
+
+let scrollPositions: { [key: string]: number } = {};
+export function usePreserveScroll(key: string | null) {
+ let ref = useRef(null);
+ useEffect(() => {
+ if (!ref.current || !key) return;
+
+ window.requestAnimationFrame(() => {
+ ref.current?.scrollTo({ top: scrollPositions[key] || 0 });
+ });
+
+ const listener = () => {
+ if (!ref.current?.scrollTop) return;
+ scrollPositions[key] = ref.current.scrollTop;
+ };
+
+ ref.current.addEventListener("scroll", listener);
+ return () => ref.current?.removeEventListener("scroll", listener);
+ }, [key, ref.current]);
+ return { ref };
+}