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
add notifications for bsky post mentions
awarm.space
3 months ago
07c18f45
09f1e55e
+105
-48
4 changed files
expand all
collapse all
unified
split
app
(home-pages)
notifications
MentionNotification.tsx
NotificationList.tsx
api
inngest
functions
index_post_mention.ts
src
notifications.ts
+28
-39
app/(home-pages)/notifications/MentionNotification.tsx
···
1
1
import { MentionTiny } from "components/Icons/MentionTiny";
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";
3
7
4
4
-
export const DummyPostMentionNotification = (props: {}) => {
5
5
-
return (
6
6
-
<Notification
7
7
-
timestamp={""}
8
8
-
href="/"
9
9
-
icon={<MentionTiny />}
10
10
-
actionText={<>celine mentioned your post</>}
11
11
-
content={
12
12
-
<ContentLayout
13
13
-
postTitle={"Post Title Here"}
14
14
-
pubRecord={{ name: "My Publication" } as any}
15
15
-
>
16
16
-
I'm just gonna put the description here. The surrounding context is
17
17
-
just sort of a pain to figure out
18
18
-
<div className="border border-border-light rounded-md p-1 my-1 text-xs text-secondary">
19
19
-
<div className="font-bold">Title of the Mentioned Post</div>
20
20
-
<div className="text-tertiary">
21
21
-
And here is the description that follows it
22
22
-
</div>
23
23
-
</div>
24
24
-
</ContentLayout>
25
25
-
}
26
26
-
/>
27
27
-
);
28
28
-
};
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;
15
15
+
const rkey = new AtUri(props.document.uri).rkey;
16
16
+
const postText = postView.record?.text || "";
29
17
30
30
-
export const DummyUserMentionNotification = (props: {
31
31
-
cardBorderHidden: boolean;
32
32
-
}) => {
33
18
return (
34
19
<Notification
35
35
-
timestamp={""}
36
36
-
href="/"
20
20
+
timestamp={props.created_at}
21
21
+
href={`https://${pubRecord.base_path}/${rkey}`}
37
22
icon={<MentionTiny />}
38
38
-
actionText={<>celine mentioned you</>}
23
23
+
actionText={<>{displayName} quoted your post</>}
39
24
content={
40
40
-
<ContentLayout
41
41
-
postTitle={"Post Title Here"}
42
42
-
pubRecord={{ name: "My Publication" } as any}
43
43
-
>
44
44
-
<div>
45
45
-
...llo this is the content of a post or whatever here it comes{" "}
46
46
-
<span className="text-accent-contrast">@celine </span> and here it
47
47
-
was! ooooh heck yeah the high is unre...
25
25
+
<ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}>
26
26
+
<div className="flex gap-2 text-sm w-full">
27
27
+
<Avatar
28
28
+
src={author.avatar}
29
29
+
displayName={displayName}
30
30
+
/>
31
31
+
<pre
32
32
+
style={{ wordBreak: "break-word" }}
33
33
+
className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6"
34
34
+
>
35
35
+
{postText}
36
36
+
</pre>
48
37
</div>
49
38
</ContentLayout>
50
39
}
+4
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
11
11
12
export function NotificationList({
12
13
notifications,
···
41
42
}
42
43
if (n.type === "subscribe") {
43
44
return <FollowNotification key={n.id} {...n} />;
45
45
+
}
46
46
+
if (n.type === "quote") {
47
47
+
return <QuoteNotification key={n.id} {...n} />;
44
48
}
45
49
})}
46
50
</div>
+26
-5
app/api/inngest/functions/index_post_mention.ts
···
3
3
import { AtpAgent, AtUri } from "@atproto/api";
4
4
import { Json } from "supabase/database.types";
5
5
import { ids } from "lexicons/api/lexicons";
6
6
+
import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
7
7
+
import { v7 } from "uuid";
6
8
7
9
export const index_post_mention = inngest.createFunction(
8
10
{ id: "index_post_mention" },
···
37
39
return { message: `No post found for ${event.data.post_uri}` };
38
40
}
39
41
42
42
+
const documentUri = AtUri.make(
43
43
+
pub.identity_did,
44
44
+
ids.PubLeafletDocument,
45
45
+
path[0],
46
46
+
).toString();
47
47
+
40
48
await step.run("index-bsky-post", async () => {
41
49
await supabaseServerClient.from("bsky_posts").insert({
42
50
uri: bsky_post.uri,
···
45
53
});
46
54
await supabaseServerClient.from("document_mentions_in_bsky").insert({
47
55
uri: bsky_post.uri,
48
48
-
document: AtUri.make(
49
49
-
pub.identity_did,
50
50
-
ids.PubLeafletDocument,
51
51
-
path[0],
52
52
-
).toString(),
56
56
+
document: documentUri,
53
57
link: event.data.document_link,
54
58
});
59
59
+
});
60
60
+
61
61
+
await step.run("create-notification", async () => {
62
62
+
// Only create notification if the quote is from someone other than the author
63
63
+
if (bsky_post.author.did !== pub.identity_did) {
64
64
+
const notification: Notification = {
65
65
+
id: v7(),
66
66
+
recipient: pub.identity_did,
67
67
+
data: {
68
68
+
type: "quote",
69
69
+
bsky_post_uri: bsky_post.uri,
70
70
+
document_uri: documentUri,
71
71
+
},
72
72
+
};
73
73
+
await supabaseServerClient.from("notifications").insert(notification);
74
74
+
await pingIdentityToUpdateNotification(pub.identity_did);
75
75
+
}
55
76
});
56
77
},
57
78
);
+47
-4
src/notifications.ts
···
11
11
12
12
export type NotificationData =
13
13
| { type: "comment"; comment_uri: string; parent_uri?: string }
14
14
-
| { type: "subscribe"; subscription_uri: string };
14
14
+
| { type: "subscribe"; subscription_uri: string }
15
15
+
| { type: "quote"; bsky_post_uri: string; document_uri: string };
15
16
16
17
export type HydratedNotification =
17
18
| HydratedCommentNotification
18
18
-
| HydratedSubscribeNotification;
19
19
+
| HydratedSubscribeNotification
20
20
+
| HydratedQuoteNotification;
19
21
export async function hydrateNotifications(
20
22
notifications: NotificationRow[],
21
23
): Promise<Array<HydratedNotification>> {
22
24
// Call all hydrators in parallel
23
23
-
const [commentNotifications, subscribeNotifications] = await Promise.all([
25
25
+
const [commentNotifications, subscribeNotifications, quoteNotifications] = await Promise.all([
24
26
hydrateCommentNotifications(notifications),
25
27
hydrateSubscribeNotifications(notifications),
28
28
+
hydrateQuoteNotifications(notifications),
26
29
]);
27
30
28
31
// Combine all hydrated notifications
29
29
-
const allHydrated = [...commentNotifications, ...subscribeNotifications];
32
32
+
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications];
30
33
31
34
// Sort by created_at to maintain order
32
35
allHydrated.sort(
···
119
122
subscriptionData: subscriptions?.find(
120
123
(s) => s.uri === notification.data.subscription_uri,
121
124
)!,
125
125
+
}));
126
126
+
}
127
127
+
128
128
+
export type HydratedQuoteNotification = Awaited<
129
129
+
ReturnType<typeof hydrateQuoteNotifications>
130
130
+
>[0];
131
131
+
132
132
+
async function hydrateQuoteNotifications(notifications: NotificationRow[]) {
133
133
+
const quoteNotifications = notifications.filter(
134
134
+
(n): n is NotificationRow & { data: ExtractNotificationType<"quote"> } =>
135
135
+
(n.data as NotificationData)?.type === "quote",
136
136
+
);
137
137
+
138
138
+
if (quoteNotifications.length === 0) {
139
139
+
return [];
140
140
+
}
141
141
+
142
142
+
// Fetch bsky post data and document data
143
143
+
const bskyPostUris = quoteNotifications.map((n) => n.data.bsky_post_uri);
144
144
+
const documentUris = quoteNotifications.map((n) => n.data.document_uri);
145
145
+
146
146
+
const { data: bskyPosts } = await supabaseServerClient
147
147
+
.from("bsky_posts")
148
148
+
.select("*")
149
149
+
.in("uri", bskyPostUris);
150
150
+
151
151
+
const { data: documents } = await supabaseServerClient
152
152
+
.from("documents")
153
153
+
.select("*, documents_in_publications(publications(*))")
154
154
+
.in("uri", documentUris);
155
155
+
156
156
+
return quoteNotifications.map((notification) => ({
157
157
+
id: notification.id,
158
158
+
recipient: notification.recipient,
159
159
+
created_at: notification.created_at,
160
160
+
type: "quote" as const,
161
161
+
bsky_post_uri: notification.data.bsky_post_uri,
162
162
+
document_uri: notification.data.document_uri,
163
163
+
bskyPost: bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri)!,
164
164
+
document: documents?.find((d) => d.uri === notification.data.document_uri)!,
122
165
}));
123
166
}
124
167