tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
288
fork
atom
a tool for shared writing and social publishing
288
fork
atom
overview
issues
27
pulls
pipelines
add notification for bluesky posts mentioned in docs
awarm.space
1 month ago
d5049245
7affa516
+263
-53
4 changed files
expand all
collapse all
unified
split
actions
publishToPublication.ts
app
(home-pages)
notifications
BskyPostEmbedNotification.tsx
NotificationList.tsx
src
notifications.ts
+109
-51
actions/publishToPublication.ts
···
903
903
const mentionedDids = new Set<string>();
904
904
const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
905
905
const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
906
906
+
const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI
906
907
907
908
// Extract pages from either format
908
909
let pages: PubLeafletContent.Main["pages"] | undefined;
···
917
918
918
919
if (!pages) return;
919
920
920
920
-
// Extract mentions from all text blocks in all pages
921
921
-
for (const page of pages) {
922
922
-
if (page.$type === "pub.leaflet.pages.linearDocument") {
923
923
-
const linearPage = page as PubLeafletPagesLinearDocument.Main;
924
924
-
for (const blockWrapper of linearPage.blocks) {
925
925
-
const block = blockWrapper.block;
926
926
-
if (block.$type === "pub.leaflet.blocks.text") {
927
927
-
const textBlock = block as PubLeafletBlocksText.Main;
928
928
-
if (textBlock.facets) {
929
929
-
for (const facet of textBlock.facets) {
930
930
-
for (const feature of facet.features) {
931
931
-
// Check for DID mentions
932
932
-
if (PubLeafletRichtextFacet.isDidMention(feature)) {
933
933
-
if (feature.did !== authorDid) {
934
934
-
mentionedDids.add(feature.did);
935
935
-
}
936
936
-
}
937
937
-
// Check for AT URI mentions (publications and documents)
938
938
-
if (PubLeafletRichtextFacet.isAtMention(feature)) {
939
939
-
const uri = new AtUri(feature.atURI);
921
921
+
// Helper to extract blocks from all pages (both linear and canvas)
922
922
+
function getAllBlocks(pages: PubLeafletContent.Main["pages"]) {
923
923
+
const blocks: (
924
924
+
| PubLeafletPagesLinearDocument.Block["block"]
925
925
+
| PubLeafletPagesCanvas.Block["block"]
926
926
+
)[] = [];
927
927
+
for (const page of pages) {
928
928
+
if (page.$type === "pub.leaflet.pages.linearDocument") {
929
929
+
const linearPage = page as PubLeafletPagesLinearDocument.Main;
930
930
+
for (const blockWrapper of linearPage.blocks) {
931
931
+
blocks.push(blockWrapper.block);
932
932
+
}
933
933
+
} else if (page.$type === "pub.leaflet.pages.canvas") {
934
934
+
const canvasPage = page as PubLeafletPagesCanvas.Main;
935
935
+
for (const blockWrapper of canvasPage.blocks) {
936
936
+
blocks.push(blockWrapper.block);
937
937
+
}
938
938
+
}
939
939
+
}
940
940
+
return blocks;
941
941
+
}
940
942
941
941
-
if (isPublicationCollection(uri.collection)) {
942
942
-
// Get the publication owner's DID
943
943
-
const { data: publication } = await supabaseServerClient
944
944
-
.from("publications")
945
945
-
.select("identity_did")
946
946
-
.eq("uri", feature.atURI)
947
947
-
.single();
943
943
+
const allBlocks = getAllBlocks(pages);
948
944
949
949
-
if (publication && publication.identity_did !== authorDid) {
950
950
-
mentionedPublications.set(
951
951
-
publication.identity_did,
952
952
-
feature.atURI,
953
953
-
);
954
954
-
}
955
955
-
} else if (isDocumentCollection(uri.collection)) {
956
956
-
// Get the document owner's DID
957
957
-
const { data: document } = await supabaseServerClient
958
958
-
.from("documents")
959
959
-
.select("uri, data")
960
960
-
.eq("uri", feature.atURI)
961
961
-
.single();
945
945
+
// Extract mentions from all text blocks and embedded Bluesky posts
946
946
+
for (const block of allBlocks) {
947
947
+
// Check for embedded Bluesky posts
948
948
+
if (PubLeafletBlocksBskyPost.isMain(block)) {
949
949
+
const bskyPostUri = block.postRef.uri;
950
950
+
// Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx)
951
951
+
const postAuthorDid = new AtUri(bskyPostUri).host;
952
952
+
if (postAuthorDid !== authorDid) {
953
953
+
embeddedBskyPosts.set(postAuthorDid, bskyPostUri);
954
954
+
}
955
955
+
}
962
956
963
963
-
if (document) {
964
964
-
const normalizedMentionedDoc = normalizeDocumentRecord(
965
965
-
document.data,
966
966
-
);
967
967
-
// Get the author from the document URI (the DID is the host part)
968
968
-
const mentionedUri = new AtUri(feature.atURI);
969
969
-
const docAuthor = mentionedUri.host;
970
970
-
if (normalizedMentionedDoc && docAuthor !== authorDid) {
971
971
-
mentionedDocuments.set(docAuthor, feature.atURI);
972
972
-
}
973
973
-
}
957
957
+
// Check for text blocks with mentions
958
958
+
if (block.$type === "pub.leaflet.blocks.text") {
959
959
+
const textBlock = block as PubLeafletBlocksText.Main;
960
960
+
if (textBlock.facets) {
961
961
+
for (const facet of textBlock.facets) {
962
962
+
for (const feature of facet.features) {
963
963
+
// Check for DID mentions
964
964
+
if (PubLeafletRichtextFacet.isDidMention(feature)) {
965
965
+
if (feature.did !== authorDid) {
966
966
+
mentionedDids.add(feature.did);
967
967
+
}
968
968
+
}
969
969
+
// Check for AT URI mentions (publications and documents)
970
970
+
if (PubLeafletRichtextFacet.isAtMention(feature)) {
971
971
+
const uri = new AtUri(feature.atURI);
972
972
+
973
973
+
if (isPublicationCollection(uri.collection)) {
974
974
+
// Get the publication owner's DID
975
975
+
const { data: publication } = await supabaseServerClient
976
976
+
.from("publications")
977
977
+
.select("identity_did")
978
978
+
.eq("uri", feature.atURI)
979
979
+
.single();
980
980
+
981
981
+
if (publication && publication.identity_did !== authorDid) {
982
982
+
mentionedPublications.set(
983
983
+
publication.identity_did,
984
984
+
feature.atURI,
985
985
+
);
986
986
+
}
987
987
+
} else if (isDocumentCollection(uri.collection)) {
988
988
+
// Get the document owner's DID
989
989
+
const { data: document } = await supabaseServerClient
990
990
+
.from("documents")
991
991
+
.select("uri, data")
992
992
+
.eq("uri", feature.atURI)
993
993
+
.single();
994
994
+
995
995
+
if (document) {
996
996
+
const normalizedMentionedDoc = normalizeDocumentRecord(
997
997
+
document.data,
998
998
+
);
999
999
+
// Get the author from the document URI (the DID is the host part)
1000
1000
+
const mentionedUri = new AtUri(feature.atURI);
1001
1001
+
const docAuthor = mentionedUri.host;
1002
1002
+
if (normalizedMentionedDoc && docAuthor !== authorDid) {
1003
1003
+
mentionedDocuments.set(docAuthor, feature.atURI);
974
1004
}
975
1005
}
976
1006
}
···
1026
1056
};
1027
1057
await supabaseServerClient.from("notifications").insert(notification);
1028
1058
await pingIdentityToUpdateNotification(recipientDid);
1059
1059
+
}
1060
1060
+
1061
1061
+
// Create notifications for embedded Bluesky posts (only if the author has a Leaflet account)
1062
1062
+
if (embeddedBskyPosts.size > 0) {
1063
1063
+
// Check which of the Bluesky post authors have Leaflet accounts
1064
1064
+
const { data: identities } = await supabaseServerClient
1065
1065
+
.from("identities")
1066
1066
+
.select("atp_did")
1067
1067
+
.in("atp_did", Array.from(embeddedBskyPosts.keys()));
1068
1068
+
1069
1069
+
const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []);
1070
1070
+
1071
1071
+
for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) {
1072
1072
+
// Only notify if the post author has a Leaflet account
1073
1073
+
if (leafletUserDids.has(postAuthorDid)) {
1074
1074
+
const notification: Notification = {
1075
1075
+
id: v7(),
1076
1076
+
recipient: postAuthorDid,
1077
1077
+
data: {
1078
1078
+
type: "bsky_post_embed",
1079
1079
+
document_uri: documentUri,
1080
1080
+
bsky_post_uri: bskyPostUri,
1081
1081
+
},
1082
1082
+
};
1083
1083
+
await supabaseServerClient.from("notifications").insert(notification);
1084
1084
+
await pingIdentityToUpdateNotification(postAuthorDid);
1085
1085
+
}
1086
1086
+
}
1029
1087
}
1030
1088
}
+44
app/(home-pages)/notifications/BskyPostEmbedNotification.tsx
···
1
1
+
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
2
2
+
import { ContentLayout, Notification } from "./Notification";
3
3
+
import { HydratedBskyPostEmbedNotification } from "src/notifications";
4
4
+
import { AtUri } from "@atproto/api";
5
5
+
6
6
+
export const BskyPostEmbedNotification = (
7
7
+
props: HydratedBskyPostEmbedNotification,
8
8
+
) => {
9
9
+
const docRecord = props.normalizedDocument;
10
10
+
const pubRecord = props.normalizedPublication;
11
11
+
12
12
+
if (!docRecord) return null;
13
13
+
14
14
+
const docUri = new AtUri(props.document.uri);
15
15
+
const rkey = docUri.rkey;
16
16
+
const did = docUri.host;
17
17
+
18
18
+
const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`;
19
19
+
20
20
+
const embedder = props.documentCreatorHandle
21
21
+
? `@${props.documentCreatorHandle}`
22
22
+
: "Someone";
23
23
+
24
24
+
return (
25
25
+
<Notification
26
26
+
timestamp={props.created_at}
27
27
+
href={href}
28
28
+
icon={<BlueskyTiny />}
29
29
+
actionText={<>{embedder} embedded your Bluesky post</>}
30
30
+
content={
31
31
+
<ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}>
32
32
+
{props.bskyPostText && (
33
33
+
<pre
34
34
+
style={{ wordBreak: "break-word" }}
35
35
+
className="whitespace-pre-wrap text-secondary line-clamp-3 text-sm"
36
36
+
>
37
37
+
{props.bskyPostText}
38
38
+
</pre>
39
39
+
)}
40
40
+
</ContentLayout>
41
41
+
}
42
42
+
/>
43
43
+
);
44
44
+
};
+4
app/(home-pages)/notifications/NotificationList.tsx
···
8
8
import { useIdentityData } from "components/IdentityProvider";
9
9
import { FollowNotification } from "./FollowNotification";
10
10
import { QuoteNotification } from "./QuoteNotification";
11
11
+
import { BskyPostEmbedNotification } from "./BskyPostEmbedNotification";
11
12
import { MentionNotification } from "./MentionNotification";
12
13
import { CommentMentionNotification } from "./CommentMentionNotification";
13
14
···
47
48
}
48
49
if (n.type === "quote") {
49
50
return <QuoteNotification key={n.id} {...n} />;
51
51
+
}
52
52
+
if (n.type === "bsky_post_embed") {
53
53
+
return <BskyPostEmbedNotification key={n.id} {...n} />;
50
54
}
51
55
if (n.type === "mention") {
52
56
return <MentionNotification key={n.id} {...n} />;
+106
-2
src/notifications.ts
···
21
21
| { type: "comment"; comment_uri: string; parent_uri?: string }
22
22
| { type: "subscribe"; subscription_uri: string }
23
23
| { type: "quote"; bsky_post_uri: string; document_uri: string }
24
24
+
| { type: "bsky_post_embed"; document_uri: string; bsky_post_uri: string }
24
25
| { type: "mention"; document_uri: string; mention_type: "did" }
25
26
| { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string }
26
27
| { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string }
···
32
33
| HydratedCommentNotification
33
34
| HydratedSubscribeNotification
34
35
| HydratedQuoteNotification
36
36
+
| HydratedBskyPostEmbedNotification
35
37
| HydratedMentionNotification
36
38
| HydratedCommentMentionNotification;
37
39
export async function hydrateNotifications(
38
40
notifications: NotificationRow[],
39
41
): Promise<Array<HydratedNotification>> {
40
42
// Call all hydrators in parallel
41
41
-
const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([
43
43
+
const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([
42
44
hydrateCommentNotifications(notifications),
43
45
hydrateSubscribeNotifications(notifications),
44
46
hydrateQuoteNotifications(notifications),
47
47
+
hydrateBskyPostEmbedNotifications(notifications),
45
48
hydrateMentionNotifications(notifications),
46
49
hydrateCommentMentionNotifications(notifications),
47
50
]);
48
51
49
52
// Combine all hydrated notifications
50
50
-
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications];
53
53
+
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications];
51
54
52
55
// Sort by created_at to maintain order
53
56
allHydrated.sort(
···
198
201
document_uri: notification.data.document_uri,
199
202
bskyPost,
200
203
document,
204
204
+
normalizedDocument: normalizeDocumentRecord(document.data, document.uri),
205
205
+
normalizedPublication: normalizePublicationRecord(
206
206
+
document.documents_in_publications[0]?.publications?.record,
207
207
+
),
208
208
+
};
209
209
+
})
210
210
+
.filter((n) => n !== null);
211
211
+
}
212
212
+
213
213
+
export type HydratedBskyPostEmbedNotification = Awaited<
214
214
+
ReturnType<typeof hydrateBskyPostEmbedNotifications>
215
215
+
>[0];
216
216
+
217
217
+
async function hydrateBskyPostEmbedNotifications(notifications: NotificationRow[]) {
218
218
+
const bskyPostEmbedNotifications = notifications.filter(
219
219
+
(n): n is NotificationRow & { data: ExtractNotificationType<"bsky_post_embed"> } =>
220
220
+
(n.data as NotificationData)?.type === "bsky_post_embed",
221
221
+
);
222
222
+
223
223
+
if (bskyPostEmbedNotifications.length === 0) {
224
224
+
return [];
225
225
+
}
226
226
+
227
227
+
// Fetch document data (the leaflet that embedded the post)
228
228
+
const documentUris = bskyPostEmbedNotifications.map((n) => n.data.document_uri);
229
229
+
const bskyPostUris = bskyPostEmbedNotifications.map((n) => n.data.bsky_post_uri);
230
230
+
231
231
+
const [{ data: documents }, { data: cachedBskyPosts }] = await Promise.all([
232
232
+
supabaseServerClient
233
233
+
.from("documents")
234
234
+
.select("*, documents_in_publications(publications(*))")
235
235
+
.in("uri", documentUris),
236
236
+
supabaseServerClient
237
237
+
.from("bsky_posts")
238
238
+
.select("*")
239
239
+
.in("uri", bskyPostUris),
240
240
+
]);
241
241
+
242
242
+
// Find which posts we need to fetch from the API
243
243
+
const cachedPostUris = new Set(cachedBskyPosts?.map((p) => p.uri) ?? []);
244
244
+
const missingPostUris = bskyPostUris.filter((uri) => !cachedPostUris.has(uri));
245
245
+
246
246
+
// Fetch missing posts from Bluesky API
247
247
+
const fetchedPosts = new Map<string, { text: string } | null>();
248
248
+
if (missingPostUris.length > 0) {
249
249
+
try {
250
250
+
const { AtpAgent } = await import("@atproto/api");
251
251
+
const agent = new AtpAgent({ service: "https://public.api.bsky.app" });
252
252
+
const response = await agent.app.bsky.feed.getPosts({ uris: missingPostUris });
253
253
+
for (const post of response.data.posts) {
254
254
+
const record = post.record as { text?: string };
255
255
+
fetchedPosts.set(post.uri, { text: record.text ?? "" });
256
256
+
}
257
257
+
} catch (error) {
258
258
+
console.error("Failed to fetch Bluesky posts:", error);
259
259
+
}
260
260
+
}
261
261
+
262
262
+
// Extract unique DIDs from document URIs to resolve handles
263
263
+
const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))];
264
264
+
265
265
+
// Resolve DIDs to handles in parallel
266
266
+
const didToHandleMap = new Map<string, string | null>();
267
267
+
await Promise.all(
268
268
+
documentCreatorDids.map(async (did) => {
269
269
+
try {
270
270
+
const resolved = await idResolver.did.resolve(did);
271
271
+
const handle = resolved?.alsoKnownAs?.[0]
272
272
+
? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix
273
273
+
: null;
274
274
+
didToHandleMap.set(did, handle);
275
275
+
} catch (error) {
276
276
+
console.error(`Failed to resolve DID ${did}:`, error);
277
277
+
didToHandleMap.set(did, null);
278
278
+
}
279
279
+
}),
280
280
+
);
281
281
+
282
282
+
return bskyPostEmbedNotifications
283
283
+
.map((notification) => {
284
284
+
const document = documents?.find((d) => d.uri === notification.data.document_uri);
285
285
+
if (!document) return null;
286
286
+
287
287
+
const documentCreatorDid = new AtUri(notification.data.document_uri).host;
288
288
+
const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null;
289
289
+
290
290
+
// Get post text from cache or fetched data
291
291
+
const cachedPost = cachedBskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri);
292
292
+
const postView = cachedPost?.post_view as { record?: { text?: string } } | undefined;
293
293
+
const bskyPostText = postView?.record?.text ?? fetchedPosts.get(notification.data.bsky_post_uri)?.text ?? null;
294
294
+
295
295
+
return {
296
296
+
id: notification.id,
297
297
+
recipient: notification.recipient,
298
298
+
created_at: notification.created_at,
299
299
+
type: "bsky_post_embed" as const,
300
300
+
document_uri: notification.data.document_uri,
301
301
+
bsky_post_uri: notification.data.bsky_post_uri,
302
302
+
document,
303
303
+
documentCreatorHandle,
304
304
+
bskyPostText,
201
305
normalizedDocument: normalizeDocumentRecord(document.data, document.uri),
202
306
normalizedPublication: normalizePublicationRecord(
203
307
document.documents_in_publications[0]?.publications?.record,