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