tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
28
pulls
pipelines
make subscriptions infinite scrolling as well
awarm.space
5 months ago
44725b46
4467bdf4
+265
-36
8 changed files
expand all
collapse all
unified
split
app
discover
PubListing.tsx
reader
ReaderContent.tsx
SubscriptionsContent.tsx
getSubscriptions.ts
idResolver.ts
page.tsx
components
PageLayouts
DashboardLayout.tsx
src
hooks
usePreserveScroll.ts
+7
-10
app/discover/PubListing.tsx
···
1
1
"use client";
2
2
import { AtUri } from "@atproto/syntax";
3
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
12
-
export const PubListing = (props: {
13
13
-
resizeHeight?: boolean;
14
14
-
record: Json;
15
15
-
uri: string;
16
16
-
documents_in_publications: {
17
17
-
documents: { data: Json; indexed_at: string } | null;
18
18
-
}[];
19
19
-
}) => {
13
13
+
export const PubListing = (
14
14
+
props: PublicationSubscription & {
15
15
+
resizeHeight?: boolean;
16
16
+
},
17
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
62
-
<div className="h-[14px] w-[14px] rounded-full bg-test shrink-0" />
63
63
-
<p>Name Here</p>{" "}
60
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
86
-
className="absolute bottom-0 left-0 w-full h-px pointer-events-none"
86
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
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
6
+
import { PublicationSubscription, getSubscriptions } from "./getSubscriptions";
7
7
+
import useSWRInfinite from "swr/infinite";
8
8
+
import { useEffect, useRef } from "react";
5
9
6
10
export const SubscriptionsContent = (props: {
7
7
-
publications: {
8
8
-
record: Json;
9
9
-
uri: string;
10
10
-
documents_in_publications: {
11
11
-
documents: { data: Json; indexed_at: string } | null;
12
12
-
}[];
13
13
-
}[];
11
11
+
publications: PublicationSubscription[];
12
12
+
nextCursor: string | null;
14
13
}) => {
15
15
-
if (props.publications.length === 0) return <SubscriptionsEmpty />;
14
14
+
const getKey = (
15
15
+
pageIndex: number,
16
16
+
previousPageData: {
17
17
+
subscriptions: PublicationSubscription[];
18
18
+
nextCursor: string | null;
19
19
+
} | null,
20
20
+
) => {
21
21
+
// Reached the end
22
22
+
if (previousPageData && !previousPageData.nextCursor) return null;
23
23
+
24
24
+
// First page, we don't have previousPageData
25
25
+
if (pageIndex === 0) return ["subscriptions", null];
26
26
+
27
27
+
// Add the cursor to the key
28
28
+
return ["subscriptions", previousPageData?.nextCursor];
29
29
+
};
30
30
+
31
31
+
const { data, error, size, setSize, isValidating } = useSWRInfinite(
32
32
+
getKey,
33
33
+
([_, cursor]) => getSubscriptions(cursor),
34
34
+
{
35
35
+
fallbackData: [
36
36
+
{ subscriptions: props.publications, nextCursor: props.nextCursor },
37
37
+
],
38
38
+
revalidateFirstPage: false,
39
39
+
},
40
40
+
);
41
41
+
42
42
+
const loadMoreRef = useRef<HTMLDivElement>(null);
43
43
+
44
44
+
// Set up intersection observer to load more when trigger element is visible
45
45
+
useEffect(() => {
46
46
+
const observer = new IntersectionObserver(
47
47
+
(entries) => {
48
48
+
if (entries[0].isIntersecting && !isValidating) {
49
49
+
const hasMore = data && data[data.length - 1]?.nextCursor;
50
50
+
if (hasMore) {
51
51
+
setSize(size + 1);
52
52
+
}
53
53
+
}
54
54
+
},
55
55
+
{ threshold: 0.1 },
56
56
+
);
57
57
+
58
58
+
if (loadMoreRef.current) {
59
59
+
observer.observe(loadMoreRef.current);
60
60
+
}
61
61
+
62
62
+
return () => observer.disconnect();
63
63
+
}, [data, size, setSize, isValidating]);
64
64
+
65
65
+
const allPublications = data
66
66
+
? data.flatMap((page) => page.subscriptions)
67
67
+
: [];
68
68
+
69
69
+
if (allPublications.length === 0 && !isValidating)
70
70
+
return <SubscriptionsEmpty />;
16
71
17
72
return (
18
18
-
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3">
19
19
-
{props.publications?.map((p) => <PubListing key={p.uri} {...p} />)}
73
73
+
<div className="relative">
74
74
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3">
75
75
+
{allPublications?.map((p) => <PubListing key={p.uri} {...p} />)}
76
76
+
</div>
77
77
+
{/* Trigger element for loading more subscriptions */}
78
78
+
<div
79
79
+
ref={loadMoreRef}
80
80
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
81
81
+
aria-hidden="true"
82
82
+
/>
83
83
+
{isValidating && (
84
84
+
<div className="text-center text-tertiary py-4">
85
85
+
Loading more subscriptions...
86
86
+
</div>
87
87
+
)}
20
88
</div>
21
89
);
22
90
};
+66
app/reader/getSubscriptions.ts
···
1
1
+
"use server";
2
2
+
3
3
+
import { AtpAgent } from "@atproto/api";
4
4
+
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
5
5
+
import { getIdentityData } from "actions/getIdentityData";
6
6
+
import { Json } from "supabase/database.types";
7
7
+
import { supabaseServerClient } from "supabase/serverClient";
8
8
+
import { idResolver } from "./idResolver";
9
9
+
10
10
+
export async function getSubscriptions(cursor?: string | null): Promise<{
11
11
+
nextCursor: null | string;
12
12
+
subscriptions: PublicationSubscription[];
13
13
+
}> {
14
14
+
let auth_res = await getIdentityData();
15
15
+
if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null };
16
16
+
let query = supabaseServerClient
17
17
+
.from("publication_subscriptions")
18
18
+
.select(`publications(*, documents_in_publications(*, documents(*)))`)
19
19
+
.order(`created_at`, { ascending: false })
20
20
+
.order("indexed_at", {
21
21
+
referencedTable: "publications.documents_in_publications",
22
22
+
})
23
23
+
.limit(1, { referencedTable: "publications.documents_in_publications" })
24
24
+
.limit(25)
25
25
+
.eq("identity", auth_res.atp_did);
26
26
+
27
27
+
if (cursor) query.lt("indexed_at", cursor);
28
28
+
let { data: pubs, error } = await query;
29
29
+
30
30
+
const actors: string[] = [
31
31
+
...new Set(
32
32
+
pubs?.map((pub) => pub.publications?.identity_did!).filter(Boolean) || [],
33
33
+
),
34
34
+
];
35
35
+
const hydratedSubscriptions: PublicationSubscription[] = await Promise.all(
36
36
+
pubs?.map(async (pub) => {
37
37
+
let id = await idResolver.did.resolve(pub.publications?.identity_did!);
38
38
+
return {
39
39
+
...pub.publications!,
40
40
+
authorProfile: id?.alsoKnownAs?.[0]
41
41
+
? { handle: id.alsoKnownAs[0] }
42
42
+
: undefined,
43
43
+
};
44
44
+
}) || [],
45
45
+
);
46
46
+
47
47
+
const nextCursor =
48
48
+
pubs && pubs.length > 0
49
49
+
? pubs[pubs.length - 1].publications?.documents_in_publications?.[0]
50
50
+
?.indexed_at || null
51
51
+
: null;
52
52
+
53
53
+
return {
54
54
+
subscriptions: hydratedSubscriptions,
55
55
+
nextCursor,
56
56
+
};
57
57
+
}
58
58
+
59
59
+
export type PublicationSubscription = {
60
60
+
authorProfile?: { handle: string };
61
61
+
record: Json;
62
62
+
uri: string;
63
63
+
documents_in_publications: {
64
64
+
documents: { data?: Json; indexed_at: string } | null;
65
65
+
}[];
66
66
+
};
+78
app/reader/idResolver.ts
···
1
1
+
import { IdResolver } from "@atproto/identity";
2
2
+
import type { DidCache, CacheResult, DidDocument } from "@atproto/identity";
3
3
+
import Client from "ioredis";
4
4
+
// Create Redis client for DID caching
5
5
+
let redisClient: Client | null = null;
6
6
+
if (process.env.REDIS_URL) {
7
7
+
redisClient = new Client(process.env.REDIS_URL);
8
8
+
}
9
9
+
10
10
+
// Redis-based DID cache implementation
11
11
+
class RedisDidCache implements DidCache {
12
12
+
private staleTTL: number;
13
13
+
private maxTTL: number;
14
14
+
15
15
+
constructor(
16
16
+
private client: Client,
17
17
+
staleTTL = 60 * 60, // 1 hour
18
18
+
maxTTL = 60 * 60 * 24, // 24 hours
19
19
+
) {
20
20
+
this.staleTTL = staleTTL;
21
21
+
this.maxTTL = maxTTL;
22
22
+
}
23
23
+
24
24
+
async cacheDid(did: string, doc: DidDocument): Promise<void> {
25
25
+
const cacheVal = {
26
26
+
doc,
27
27
+
updatedAt: Date.now(),
28
28
+
};
29
29
+
await this.client.setex(
30
30
+
`did:${did}`,
31
31
+
this.maxTTL,
32
32
+
JSON.stringify(cacheVal),
33
33
+
);
34
34
+
}
35
35
+
36
36
+
async checkCache(did: string): Promise<CacheResult | null> {
37
37
+
const cached = await this.client.get(`did:${did}`);
38
38
+
if (!cached) return null;
39
39
+
40
40
+
const { doc, updatedAt } = JSON.parse(cached);
41
41
+
const now = Date.now();
42
42
+
const age = now - updatedAt;
43
43
+
44
44
+
return {
45
45
+
did,
46
46
+
doc,
47
47
+
updatedAt,
48
48
+
stale: age > this.staleTTL * 1000,
49
49
+
expired: age > this.maxTTL * 1000,
50
50
+
};
51
51
+
}
52
52
+
53
53
+
async refreshCache(
54
54
+
did: string,
55
55
+
getDoc: () => Promise<DidDocument | null>,
56
56
+
): Promise<void> {
57
57
+
const doc = await getDoc();
58
58
+
if (doc) {
59
59
+
await this.cacheDid(did, doc);
60
60
+
}
61
61
+
}
62
62
+
63
63
+
async clearEntry(did: string): Promise<void> {
64
64
+
await this.client.del(`did:${did}`);
65
65
+
}
66
66
+
67
67
+
async clear(): Promise<void> {
68
68
+
const keys = await this.client.keys("did:*");
69
69
+
if (keys.length > 0) {
70
70
+
await this.client.del(...keys);
71
71
+
}
72
72
+
}
73
73
+
}
74
74
+
75
75
+
// Create IdResolver with Redis-based DID cache
76
76
+
export const idResolver = new IdResolver({
77
77
+
didCache: redisClient ? new RedisDidCache(redisClient) : undefined,
78
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
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
44
-
let { data: pubs, error } = await supabaseServerClient
45
45
-
.from("publication_subscriptions")
46
46
-
.select(`publications(*, documents_in_publications(*, documents(*)))`)
47
47
-
.order(`created_at`, { ascending: false })
48
48
-
.order("indexed_at", {
49
49
-
referencedTable: "publications.documents_in_publications",
50
50
-
})
51
51
-
.limit(1, { referencedTable: "publications.documents_in_publications" })
52
52
-
.eq("identity", auth_res.atp_did);
53
53
-
console.log(error);
54
54
-
let publications =
55
55
-
pubs
56
56
-
?.map((subscription) => subscription.publications)
57
57
-
.filter((pub) => pub !== null) || [];
45
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
89
-
content: <SubscriptionsContent publications={publications} />,
77
77
+
content: (
78
78
+
<SubscriptionsContent
79
79
+
publications={publications.subscriptions}
80
80
+
nextCursor={publications.nextCursor}
81
81
+
/>
82
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
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
139
+
let { ref } = usePreserveScroll<HTMLDivElement>(
140
140
+
`dashboard-${props.id}-${tab as string}`,
141
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
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
1
+
import { useRef, useEffect } from "react";
2
2
+
3
3
+
let scrollPositions: { [key: string]: number } = {};
4
4
+
export function usePreserveScroll<T extends HTMLElement>(key: string | null) {
5
5
+
let ref = useRef<T | null>(null);
6
6
+
useEffect(() => {
7
7
+
if (!ref.current || !key) return;
8
8
+
9
9
+
window.requestAnimationFrame(() => {
10
10
+
ref.current?.scrollTo({ top: scrollPositions[key] || 0 });
11
11
+
});
12
12
+
13
13
+
const listener = () => {
14
14
+
if (!ref.current?.scrollTop) return;
15
15
+
scrollPositions[key] = ref.current.scrollTop;
16
16
+
};
17
17
+
18
18
+
ref.current.addEventListener("scroll", listener);
19
19
+
return () => ref.current?.removeEventListener("scroll", listener);
20
20
+
}, [key, ref.current]);
21
21
+
return { ref };
22
22
+
}