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
implement mention notifications
awarm.space
3 months ago
5961fe6f
691de382
+275
-27
5 changed files
expand all
collapse all
unified
split
actions
publishToPublication.ts
app
(home-pages)
notifications
MentionNotification.tsx
NotificationList.tsx
QuoteNotification.tsx
src
notifications.ts
+123
actions/publishToPublication.ts
···
50
50
ColorToRGBA,
51
51
} from "components/ThemeManager/colorToLexicons";
52
52
import { parseColor } from "@react-stately/color";
53
53
+
import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
54
54
+
import { v7 } from "uuid";
53
55
54
56
export async function publishToPublication({
55
57
root_entity,
···
208
210
.delete()
209
211
.in("id", entitiesToDelete);
210
212
}
213
213
+
}
214
214
+
215
215
+
// Create notifications for mentions (only on first publish)
216
216
+
if (!existingDocUri) {
217
217
+
await createMentionNotifications(result.uri, record, credentialSession.did!);
211
218
}
212
219
213
220
return { rkey, record: JSON.parse(JSON.stringify(record)) };
···
735
742
736
743
return undefined;
737
744
}
745
745
+
746
746
+
/**
747
747
+
* Extract mentions from a published document and create notifications
748
748
+
*/
749
749
+
async function createMentionNotifications(
750
750
+
documentUri: string,
751
751
+
record: PubLeafletDocument.Record,
752
752
+
authorDid: string,
753
753
+
) {
754
754
+
const mentionedDids = new Set<string>();
755
755
+
const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
756
756
+
const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
757
757
+
758
758
+
// Extract mentions from all text blocks in all pages
759
759
+
for (const page of record.pages) {
760
760
+
if (page.$type === "pub.leaflet.pages.linearDocument") {
761
761
+
const linearPage = page as PubLeafletPagesLinearDocument.Main;
762
762
+
for (const blockWrapper of linearPage.blocks) {
763
763
+
const block = blockWrapper.block;
764
764
+
if (block.$type === "pub.leaflet.blocks.text") {
765
765
+
const textBlock = block as PubLeafletBlocksText.Main;
766
766
+
if (textBlock.facets) {
767
767
+
for (const facet of textBlock.facets) {
768
768
+
for (const feature of facet.features) {
769
769
+
// Check for DID mentions
770
770
+
if (PubLeafletRichtextFacet.isDidMention(feature)) {
771
771
+
if (feature.did !== authorDid) {
772
772
+
mentionedDids.add(feature.did);
773
773
+
}
774
774
+
}
775
775
+
// Check for AT URI mentions (publications and documents)
776
776
+
if (PubLeafletRichtextFacet.isAtMention(feature)) {
777
777
+
const uri = new AtUri(feature.atURI);
778
778
+
779
779
+
if (uri.collection === "pub.leaflet.publication") {
780
780
+
// Get the publication owner's DID
781
781
+
const { data: publication } = await supabaseServerClient
782
782
+
.from("publications")
783
783
+
.select("identity_did")
784
784
+
.eq("uri", feature.atURI)
785
785
+
.single();
786
786
+
787
787
+
if (publication && publication.identity_did !== authorDid) {
788
788
+
mentionedPublications.set(publication.identity_did, feature.atURI);
789
789
+
}
790
790
+
} else if (uri.collection === "pub.leaflet.document") {
791
791
+
// Get the document owner's DID
792
792
+
const { data: document } = await supabaseServerClient
793
793
+
.from("documents")
794
794
+
.select("uri, data")
795
795
+
.eq("uri", feature.atURI)
796
796
+
.single();
797
797
+
798
798
+
if (document) {
799
799
+
const docRecord = document.data as PubLeafletDocument.Record;
800
800
+
if (docRecord.author !== authorDid) {
801
801
+
mentionedDocuments.set(docRecord.author, feature.atURI);
802
802
+
}
803
803
+
}
804
804
+
}
805
805
+
}
806
806
+
}
807
807
+
}
808
808
+
}
809
809
+
}
810
810
+
}
811
811
+
}
812
812
+
}
813
813
+
814
814
+
// Create notifications for DID mentions
815
815
+
for (const did of mentionedDids) {
816
816
+
const notification: Notification = {
817
817
+
id: v7(),
818
818
+
recipient: did,
819
819
+
data: {
820
820
+
type: "mention",
821
821
+
document_uri: documentUri,
822
822
+
mention_type: "did",
823
823
+
},
824
824
+
};
825
825
+
await supabaseServerClient.from("notifications").insert(notification);
826
826
+
await pingIdentityToUpdateNotification(did);
827
827
+
}
828
828
+
829
829
+
// Create notifications for publication mentions
830
830
+
for (const [recipientDid, publicationUri] of mentionedPublications) {
831
831
+
const notification: Notification = {
832
832
+
id: v7(),
833
833
+
recipient: recipientDid,
834
834
+
data: {
835
835
+
type: "mention",
836
836
+
document_uri: documentUri,
837
837
+
mention_type: "publication",
838
838
+
mentioned_uri: publicationUri,
839
839
+
},
840
840
+
};
841
841
+
await supabaseServerClient.from("notifications").insert(notification);
842
842
+
await pingIdentityToUpdateNotification(recipientDid);
843
843
+
}
844
844
+
845
845
+
// Create notifications for document mentions
846
846
+
for (const [recipientDid, mentionedDocUri] of mentionedDocuments) {
847
847
+
const notification: Notification = {
848
848
+
id: v7(),
849
849
+
recipient: recipientDid,
850
850
+
data: {
851
851
+
type: "mention",
852
852
+
document_uri: documentUri,
853
853
+
mention_type: "document",
854
854
+
mentioned_uri: mentionedDocUri,
855
855
+
},
856
856
+
};
857
857
+
await supabaseServerClient.from("notifications").insert(notification);
858
858
+
await pingIdentityToUpdateNotification(recipientDid);
859
859
+
}
860
860
+
}
+25
-22
app/(home-pages)/notifications/MentionNotification.tsx
···
1
1
-
import { QuoteTiny } from "components/Icons/QuoteTiny";
1
1
+
import { MentionTiny } from "components/Icons/MentionTiny";
2
2
import { ContentLayout, Notification } from "./Notification";
3
3
-
import { HydratedQuoteNotification } from "src/notifications";
3
3
+
import { HydratedMentionNotification } from "src/notifications";
4
4
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
5
5
import { AtUri } from "@atproto/api";
6
6
-
import { Avatar } from "components/Avatar";
7
6
8
8
-
export const QuoteNotification = (props: HydratedQuoteNotification) => {
9
9
-
const postView = props.bskyPost.post_view as any;
10
10
-
const author = postView.author;
11
11
-
const displayName = author.displayName || author.handle || "Someone";
7
7
+
export const MentionNotification = (props: HydratedMentionNotification) => {
12
8
const docRecord = props.document.data as PubLeafletDocument.Record;
13
13
-
const pubRecord = props.document.documents_in_publications[0]?.publications
9
9
+
const pubRecord = props.document.documents_in_publications?.[0]?.publications
14
10
?.record as PubLeafletPublication.Record | undefined;
15
11
const docUri = new AtUri(props.document.uri);
16
12
const rkey = docUri.rkey;
17
13
const did = docUri.host;
18
18
-
const postText = postView.record?.text || "";
19
14
20
15
const href = pubRecord
21
16
? `https://${pubRecord.base_path}/${rkey}`
22
17
: `/p/${did}/${rkey}`;
23
18
19
19
+
let actionText: React.ReactNode;
20
20
+
let mentionedItemName: string | undefined;
21
21
+
22
22
+
if (props.mention_type === "did") {
23
23
+
actionText = <>mentioned you</>;
24
24
+
} else if (props.mention_type === "publication" && props.mentionedPublication) {
25
25
+
const mentionedPubRecord = props.mentionedPublication.record as PubLeafletPublication.Record;
26
26
+
mentionedItemName = mentionedPubRecord.name;
27
27
+
actionText = <>mentioned your publication <span className="italic">{mentionedItemName}</span></>;
28
28
+
} else if (props.mention_type === "document" && props.mentionedDocument) {
29
29
+
const mentionedDocRecord = props.mentionedDocument.data as PubLeafletDocument.Record;
30
30
+
mentionedItemName = mentionedDocRecord.title;
31
31
+
actionText = <>mentioned your post <span className="italic">{mentionedItemName}</span></>;
32
32
+
} else {
33
33
+
actionText = <>mentioned you</>;
34
34
+
}
35
35
+
24
36
return (
25
37
<Notification
26
38
timestamp={props.created_at}
27
39
href={href}
28
28
-
icon={<QuoteTiny />}
29
29
-
actionText={<>{displayName} quoted your post</>}
40
40
+
icon={<MentionTiny />}
41
41
+
actionText={actionText}
30
42
content={
31
43
<ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}>
32
32
-
<div className="flex gap-2 text-sm w-full">
33
33
-
<Avatar
34
34
-
src={author.avatar}
35
35
-
displayName={displayName}
36
36
-
/>
37
37
-
<pre
38
38
-
style={{ wordBreak: "break-word" }}
39
39
-
className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6"
40
40
-
>
41
41
-
{postText}
42
42
-
</pre>
44
44
+
<div className="text-sm text-secondary">
45
45
+
in <span className="italic">{docRecord.title}</span>
43
46
</div>
44
47
</ContentLayout>
45
48
}
+5
-1
app/(home-pages)/notifications/NotificationList.tsx
···
7
7
import { ReplyNotification } from "./ReplyNotification";
8
8
import { useIdentityData } from "components/IdentityProvider";
9
9
import { FollowNotification } from "./FollowNotification";
10
10
-
import { QuoteNotification } from "./MentionNotification";
10
10
+
import { QuoteNotification } from "./QuoteNotification";
11
11
+
import { MentionNotification } from "./MentionNotification";
11
12
12
13
export function NotificationList({
13
14
notifications,
···
45
46
}
46
47
if (n.type === "quote") {
47
48
return <QuoteNotification key={n.id} {...n} />;
49
49
+
}
50
50
+
if (n.type === "mention") {
51
51
+
return <MentionNotification key={n.id} {...n} />;
48
52
}
49
53
})}
50
54
</div>
+48
app/(home-pages)/notifications/QuoteNotification.tsx
···
1
1
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
2
2
+
import { ContentLayout, Notification } from "./Notification";
3
3
+
import { HydratedQuoteNotification } from "src/notifications";
4
4
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
5
5
+
import { AtUri } from "@atproto/api";
6
6
+
import { Avatar } from "components/Avatar";
7
7
+
8
8
+
export const QuoteNotification = (props: HydratedQuoteNotification) => {
9
9
+
const postView = props.bskyPost.post_view as any;
10
10
+
const author = postView.author;
11
11
+
const displayName = author.displayName || author.handle || "Someone";
12
12
+
const docRecord = props.document.data as PubLeafletDocument.Record;
13
13
+
const pubRecord = props.document.documents_in_publications[0]?.publications
14
14
+
?.record as PubLeafletPublication.Record | undefined;
15
15
+
const docUri = new AtUri(props.document.uri);
16
16
+
const rkey = docUri.rkey;
17
17
+
const did = docUri.host;
18
18
+
const postText = postView.record?.text || "";
19
19
+
20
20
+
const href = pubRecord
21
21
+
? `https://${pubRecord.base_path}/${rkey}`
22
22
+
: `/p/${did}/${rkey}`;
23
23
+
24
24
+
return (
25
25
+
<Notification
26
26
+
timestamp={props.created_at}
27
27
+
href={href}
28
28
+
icon={<QuoteTiny />}
29
29
+
actionText={<>{displayName} quoted your post</>}
30
30
+
content={
31
31
+
<ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}>
32
32
+
<div className="flex gap-2 text-sm w-full">
33
33
+
<Avatar
34
34
+
src={author.avatar}
35
35
+
displayName={displayName}
36
36
+
/>
37
37
+
<pre
38
38
+
style={{ wordBreak: "break-word" }}
39
39
+
className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6"
40
40
+
>
41
41
+
{postText}
42
42
+
</pre>
43
43
+
</div>
44
44
+
</ContentLayout>
45
45
+
}
46
46
+
/>
47
47
+
);
48
48
+
};
+74
-4
src/notifications.ts
···
12
12
export type NotificationData =
13
13
| { type: "comment"; comment_uri: string; parent_uri?: string }
14
14
| { type: "subscribe"; subscription_uri: string }
15
15
-
| { type: "quote"; bsky_post_uri: string; document_uri: string };
15
15
+
| { type: "quote"; bsky_post_uri: string; document_uri: string }
16
16
+
| { type: "mention"; document_uri: string; mention_type: "did" }
17
17
+
| { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string }
18
18
+
| { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string };
16
19
17
20
export type HydratedNotification =
18
21
| HydratedCommentNotification
19
22
| HydratedSubscribeNotification
20
20
-
| HydratedQuoteNotification;
23
23
+
| HydratedQuoteNotification
24
24
+
| HydratedMentionNotification;
21
25
export async function hydrateNotifications(
22
26
notifications: NotificationRow[],
23
27
): Promise<Array<HydratedNotification>> {
24
28
// Call all hydrators in parallel
25
25
-
const [commentNotifications, subscribeNotifications, quoteNotifications] = await Promise.all([
29
29
+
const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications] = await Promise.all([
26
30
hydrateCommentNotifications(notifications),
27
31
hydrateSubscribeNotifications(notifications),
28
32
hydrateQuoteNotifications(notifications),
33
33
+
hydrateMentionNotifications(notifications),
29
34
]);
30
35
31
36
// Combine all hydrated notifications
32
32
-
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications];
37
37
+
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications];
33
38
34
39
// Sort by created_at to maintain order
35
40
allHydrated.sort(
···
163
168
bskyPost: bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri)!,
164
169
document: documents?.find((d) => d.uri === notification.data.document_uri)!,
165
170
}));
171
171
+
}
172
172
+
173
173
+
export type HydratedMentionNotification = Awaited<
174
174
+
ReturnType<typeof hydrateMentionNotifications>
175
175
+
>[0];
176
176
+
177
177
+
async function hydrateMentionNotifications(notifications: NotificationRow[]) {
178
178
+
const mentionNotifications = notifications.filter(
179
179
+
(n): n is NotificationRow & { data: ExtractNotificationType<"mention"> } =>
180
180
+
(n.data as NotificationData)?.type === "mention",
181
181
+
);
182
182
+
183
183
+
if (mentionNotifications.length === 0) {
184
184
+
return [];
185
185
+
}
186
186
+
187
187
+
// Fetch document data from the database
188
188
+
const documentUris = mentionNotifications.map((n) => n.data.document_uri);
189
189
+
const { data: documents } = await supabaseServerClient
190
190
+
.from("documents")
191
191
+
.select("*, documents_in_publications(publications(*))")
192
192
+
.in("uri", documentUris);
193
193
+
194
194
+
// Fetch mentioned publications and documents
195
195
+
const mentionedPublicationUris = mentionNotifications
196
196
+
.filter((n) => n.data.mention_type === "publication")
197
197
+
.map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "publication" }>).mentioned_uri);
198
198
+
199
199
+
const mentionedDocumentUris = mentionNotifications
200
200
+
.filter((n) => n.data.mention_type === "document")
201
201
+
.map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "document" }>).mentioned_uri);
202
202
+
203
203
+
const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([
204
204
+
mentionedPublicationUris.length > 0
205
205
+
? supabaseServerClient
206
206
+
.from("publications")
207
207
+
.select("*")
208
208
+
.in("uri", mentionedPublicationUris)
209
209
+
: Promise.resolve({ data: [] }),
210
210
+
mentionedDocumentUris.length > 0
211
211
+
? supabaseServerClient
212
212
+
.from("documents")
213
213
+
.select("*, documents_in_publications(publications(*))")
214
214
+
.in("uri", mentionedDocumentUris)
215
215
+
: Promise.resolve({ data: [] }),
216
216
+
]);
217
217
+
218
218
+
return mentionNotifications.map((notification) => {
219
219
+
const mentionedUri = notification.data.mention_type !== "did"
220
220
+
? (notification.data as Extract<ExtractNotificationType<"mention">, { mentioned_uri: string }>).mentioned_uri
221
221
+
: undefined;
222
222
+
223
223
+
return {
224
224
+
id: notification.id,
225
225
+
recipient: notification.recipient,
226
226
+
created_at: notification.created_at,
227
227
+
type: "mention" as const,
228
228
+
document_uri: notification.data.document_uri,
229
229
+
mention_type: notification.data.mention_type,
230
230
+
mentioned_uri: mentionedUri,
231
231
+
document: documents?.find((d) => d.uri === notification.data.document_uri)!,
232
232
+
mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined,
233
233
+
mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined,
234
234
+
};
235
235
+
});
166
236
}
167
237
168
238
export async function pingIdentityToUpdateNotification(did: string) {