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 comment mentions and notifications
awarm.space
3 months ago
f30b281b
8b8bea78
+529
-20
6 changed files
expand all
collapse all
unified
split
app
(home-pages)
notifications
CommentMentionNotification.tsx
NotificationList.tsx
[leaflet_id]
publish
BskyPostEditorProsemirror.tsx
lish
[did]
[publication]
[rkey]
Interactions
Comments
CommentBox.tsx
commentAction.ts
src
notifications.ts
+98
app/(home-pages)/notifications/CommentMentionNotification.tsx
···
1
1
+
import {
2
2
+
AppBskyActorProfile,
3
3
+
PubLeafletComment,
4
4
+
PubLeafletDocument,
5
5
+
PubLeafletPublication,
6
6
+
} from "lexicons/api";
7
7
+
import { HydratedCommentMentionNotification } from "src/notifications";
8
8
+
import { blobRefToSrc } from "src/utils/blobRefToSrc";
9
9
+
import { MentionTiny } from "components/Icons/MentionTiny";
10
10
+
import {
11
11
+
CommentInNotification,
12
12
+
ContentLayout,
13
13
+
Notification,
14
14
+
} from "./Notification";
15
15
+
import { AtUri } from "@atproto/api";
16
16
+
17
17
+
export const CommentMentionNotification = (
18
18
+
props: HydratedCommentMentionNotification,
19
19
+
) => {
20
20
+
const docRecord = props.commentData.documents
21
21
+
?.data as PubLeafletDocument.Record;
22
22
+
const commentRecord = props.commentData.record as PubLeafletComment.Record;
23
23
+
const profileRecord = props.commentData.bsky_profiles
24
24
+
?.record as AppBskyActorProfile.Record;
25
25
+
const pubRecord = props.commentData.documents?.documents_in_publications[0]
26
26
+
?.publications?.record as PubLeafletPublication.Record | undefined;
27
27
+
const docUri = new AtUri(props.commentData.documents?.uri!);
28
28
+
const rkey = docUri.rkey;
29
29
+
const did = docUri.host;
30
30
+
31
31
+
const href = pubRecord
32
32
+
? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`
33
33
+
: `/p/${did}/${rkey}?interactionDrawer=comments`;
34
34
+
35
35
+
const commenter = props.commenterHandle
36
36
+
? `@${props.commenterHandle}`
37
37
+
: "Someone";
38
38
+
39
39
+
let actionText: React.ReactNode;
40
40
+
let mentionedDocRecord = props.mentionedDocument
41
41
+
?.data as PubLeafletDocument.Record;
42
42
+
43
43
+
if (props.mention_type === "did") {
44
44
+
actionText = <>{commenter} mentioned you in a comment</>;
45
45
+
} else if (
46
46
+
props.mention_type === "publication" &&
47
47
+
props.mentionedPublication
48
48
+
) {
49
49
+
const mentionedPubRecord = props.mentionedPublication
50
50
+
.record as PubLeafletPublication.Record;
51
51
+
actionText = (
52
52
+
<>
53
53
+
{commenter} mentioned your publication{" "}
54
54
+
<span className="italic">{mentionedPubRecord.name}</span> in a comment
55
55
+
</>
56
56
+
);
57
57
+
} else if (props.mention_type === "document" && props.mentionedDocument) {
58
58
+
actionText = (
59
59
+
<>
60
60
+
{commenter} mentioned your post{" "}
61
61
+
<span className="italic">{mentionedDocRecord.title}</span> in a comment
62
62
+
</>
63
63
+
);
64
64
+
} else {
65
65
+
actionText = <>{commenter} mentioned you in a comment</>;
66
66
+
}
67
67
+
68
68
+
return (
69
69
+
<Notification
70
70
+
timestamp={props.created_at}
71
71
+
href={href}
72
72
+
icon={<MentionTiny />}
73
73
+
actionText={actionText}
74
74
+
content={
75
75
+
<ContentLayout postTitle={docRecord?.title} pubRecord={pubRecord}>
76
76
+
<CommentInNotification
77
77
+
className=""
78
78
+
avatar={
79
79
+
profileRecord?.avatar?.ref &&
80
80
+
blobRefToSrc(
81
81
+
profileRecord?.avatar?.ref,
82
82
+
props.commentData.bsky_profiles?.did || "",
83
83
+
)
84
84
+
}
85
85
+
displayName={
86
86
+
profileRecord?.displayName ||
87
87
+
props.commentData.bsky_profiles?.handle ||
88
88
+
"Someone"
89
89
+
}
90
90
+
index={[]}
91
91
+
plaintext={commentRecord.plaintext}
92
92
+
facets={commentRecord.facets}
93
93
+
/>
94
94
+
</ContentLayout>
95
95
+
}
96
96
+
/>
97
97
+
);
98
98
+
};
+4
app/(home-pages)/notifications/NotificationList.tsx
···
9
9
import { FollowNotification } from "./FollowNotification";
10
10
import { QuoteNotification } from "./QuoteNotification";
11
11
import { MentionNotification } from "./MentionNotification";
12
12
+
import { CommentMentionNotification } from "./CommentMentionNotification";
12
13
13
14
export function NotificationList({
14
15
notifications,
···
49
50
}
50
51
if (n.type === "mention") {
51
52
return <MentionNotification key={n.id} {...n} />;
53
53
+
}
54
54
+
if (n.type === "comment_mention") {
55
55
+
return <CommentMentionNotification key={n.id} {...n} />;
52
56
}
53
57
})}
54
58
</div>
+5
-4
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
···
379
379
range: { from: number; to: number },
380
380
view: EditorView,
381
381
) => {
382
382
+
console.log("view", view);
382
383
if (!view) return;
383
384
const { from, to } = range;
384
385
const tr = view.state.tr;
···
393
394
text: mentionText,
394
395
});
395
396
tr.insert(from, didMentionNode);
396
396
-
// Add a space after the mention
397
397
-
tr.insertText(" ", from + 1);
398
397
}
399
398
if (mention.type === "publication" || mention.type === "post") {
400
399
// Delete the @ and any query text
···
406
405
text: name,
407
406
});
408
407
tr.insert(from, atMentionNode);
409
409
-
// Add a space after the mention
410
410
-
tr.insertText(" ", from + 1);
411
408
}
409
409
+
console.log("yo", mention);
410
410
+
411
411
+
// Add a space after the mention
412
412
+
tr.insertText(" ", from + 1);
412
413
413
414
view.dispatch(tr);
414
415
view.focus();
+223
-11
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
···
8
8
import { EditorState, TextSelection } from "prosemirror-state";
9
9
import { EditorView } from "prosemirror-view";
10
10
import { history, redo, undo } from "prosemirror-history";
11
11
+
import { InputRule, inputRules } from "prosemirror-inputrules";
11
12
import {
12
13
MutableRefObject,
13
14
RefObject,
15
15
+
useCallback,
14
16
useEffect,
15
17
useLayoutEffect,
16
18
useRef,
···
36
38
import { CloseTiny } from "components/Icons/CloseTiny";
37
39
import { CloseFillTiny } from "components/Icons/CloseFillTiny";
38
40
import { betterIsUrl } from "src/utils/isURL";
41
41
+
import { Mention, MentionAutocomplete } from "components/Mention";
42
42
+
import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
43
43
+
44
44
+
const addMentionToEditor = (
45
45
+
mention: Mention,
46
46
+
range: { from: number; to: number },
47
47
+
view: EditorView,
48
48
+
) => {
49
49
+
if (!view) return;
50
50
+
const { from, to } = range;
51
51
+
const tr = view.state.tr;
52
52
+
53
53
+
if (mention.type === "did") {
54
54
+
// Delete the @ and any query text
55
55
+
tr.delete(from, to);
56
56
+
// Insert didMention inline node
57
57
+
const mentionText = "@" + mention.handle;
58
58
+
const didMentionNode = multiBlockSchema.nodes.didMention.create({
59
59
+
did: mention.did,
60
60
+
text: mentionText,
61
61
+
});
62
62
+
tr.insert(from, didMentionNode);
63
63
+
// Add a space after the mention
64
64
+
tr.insertText(" ", from + 1);
65
65
+
}
66
66
+
if (mention.type === "publication" || mention.type === "post") {
67
67
+
// Delete the @ and any query text
68
68
+
tr.delete(from, to);
69
69
+
let name = mention.type === "post" ? mention.title : mention.name;
70
70
+
// Insert atMention inline node
71
71
+
const atMentionNode = multiBlockSchema.nodes.atMention.create({
72
72
+
atURI: mention.uri,
73
73
+
text: name,
74
74
+
});
75
75
+
tr.insert(from, atMentionNode);
76
76
+
// Add a space after the mention
77
77
+
tr.insertText(" ", from + 1);
78
78
+
}
79
79
+
80
80
+
view.dispatch(tr);
81
81
+
view.focus();
82
82
+
};
39
83
40
84
export function CommentBox(props: {
41
85
doc_uri: string;
···
50
94
commentBox: { quote },
51
95
} = useInteractionState(props.doc_uri);
52
96
let [loading, setLoading] = useState(false);
97
97
+
let view = useRef<null | EditorView>(null);
98
98
+
99
99
+
// Mention autocomplete state
100
100
+
const [mentionOpen, setMentionOpen] = useState(false);
101
101
+
const [mentionCoords, setMentionCoords] = useState<{
102
102
+
top: number;
103
103
+
left: number;
104
104
+
} | null>(null);
105
105
+
// Use a ref for insert position to avoid stale closure issues
106
106
+
const mentionInsertPosRef = useRef<number | null>(null);
107
107
+
108
108
+
// Use a ref for the callback so input rules can access it
109
109
+
const openMentionAutocompleteRef = useRef<() => void>(() => {});
110
110
+
openMentionAutocompleteRef.current = () => {
111
111
+
if (!view.current) return;
53
112
54
54
-
const handleSubmit = async () => {
113
113
+
const pos = view.current.state.selection.from;
114
114
+
mentionInsertPosRef.current = pos;
115
115
+
116
116
+
// Get coordinates for the popup relative to the positioned parent
117
117
+
const coords = view.current.coordsAtPos(pos - 1);
118
118
+
119
119
+
// Find the relative positioned parent container
120
120
+
const editorEl = view.current.dom;
121
121
+
const container = editorEl.closest(".relative") as HTMLElement | null;
122
122
+
123
123
+
if (container) {
124
124
+
const containerRect = container.getBoundingClientRect();
125
125
+
setMentionCoords({
126
126
+
top: coords.bottom - containerRect.top,
127
127
+
left: coords.left - containerRect.left,
128
128
+
});
129
129
+
} else {
130
130
+
setMentionCoords({
131
131
+
top: coords.bottom,
132
132
+
left: coords.left,
133
133
+
});
134
134
+
}
135
135
+
setMentionOpen(true);
136
136
+
};
137
137
+
138
138
+
const handleMentionSelect = useCallback((mention: Mention) => {
139
139
+
if (!view.current || mentionInsertPosRef.current === null) return;
140
140
+
141
141
+
const from = mentionInsertPosRef.current - 1;
142
142
+
const to = mentionInsertPosRef.current;
143
143
+
144
144
+
addMentionToEditor(mention, { from, to }, view.current);
145
145
+
view.current.focus();
146
146
+
}, []);
147
147
+
148
148
+
const handleMentionOpenChange = useCallback((open: boolean) => {
149
149
+
setMentionOpen(open);
150
150
+
if (!open) {
151
151
+
setMentionCoords(null);
152
152
+
mentionInsertPosRef.current = null;
153
153
+
}
154
154
+
}, []);
155
155
+
156
156
+
// Use a ref for handleSubmit so keyboard shortcuts can access it
157
157
+
const handleSubmitRef = useRef<() => Promise<void>>(async () => {});
158
158
+
handleSubmitRef.current = async () => {
55
159
if (loading || !view.current) return;
56
160
57
161
setLoading(true);
···
114
218
"Mod-y": redo,
115
219
"Shift-Mod-z": redo,
116
220
"Ctrl-Enter": () => {
117
117
-
handleSubmit();
221
221
+
handleSubmitRef.current();
118
222
return true;
119
223
},
120
224
"Meta-Enter": () => {
121
121
-
handleSubmit();
225
225
+
handleSubmitRef.current();
122
226
return true;
123
227
},
124
228
}),
···
128
232
shouldAutoLink: () => true,
129
233
defaultProtocol: "https",
130
234
}),
235
235
+
// Input rules for @ mentions
236
236
+
inputRules({
237
237
+
rules: [
238
238
+
// @ at start of line or after space
239
239
+
new InputRule(/(?:^|\s)@$/, (state, match, start, end) => {
240
240
+
setTimeout(() => openMentionAutocompleteRef.current(), 0);
241
241
+
return null;
242
242
+
}),
243
243
+
],
244
244
+
}),
131
245
history(),
132
246
],
133
247
}),
134
248
);
135
135
-
let view = useRef<null | EditorView>(null);
136
249
useLayoutEffect(() => {
137
250
if (!mountRef.current) return;
138
251
view.current = new EditorView(
···
187
300
handleClickOn: (view, _pos, node, _nodePos, _event, direct) => {
188
301
if (!direct) return;
189
302
if (node.nodeSize - 2 <= _pos) return;
303
303
+
304
304
+
const nodeAt1 = node.nodeAt(_pos - 1);
305
305
+
const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
306
306
+
307
307
+
// Check for link marks
190
308
let mark =
191
191
-
node
192
192
-
.nodeAt(_pos - 1)
193
193
-
?.marks.find((f) => f.type === multiBlockSchema.marks.link) ||
194
194
-
node
195
195
-
.nodeAt(Math.max(_pos - 2, 0))
196
196
-
?.marks.find((f) => f.type === multiBlockSchema.marks.link);
309
309
+
nodeAt1?.marks.find(
310
310
+
(f) => f.type === multiBlockSchema.marks.link,
311
311
+
) ||
312
312
+
nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link);
197
313
if (mark) {
198
314
window.open(mark.attrs.href, "_blank");
315
315
+
return;
316
316
+
}
317
317
+
318
318
+
// Check for didMention inline nodes
319
319
+
if (nodeAt1?.type === multiBlockSchema.nodes.didMention) {
320
320
+
window.open(
321
321
+
didToBlueskyUrl(nodeAt1.attrs.did),
322
322
+
"_blank",
323
323
+
"noopener,noreferrer",
324
324
+
);
325
325
+
return;
326
326
+
}
327
327
+
if (nodeAt2?.type === multiBlockSchema.nodes.didMention) {
328
328
+
window.open(
329
329
+
didToBlueskyUrl(nodeAt2.attrs.did),
330
330
+
"_blank",
331
331
+
"noopener,noreferrer",
332
332
+
);
333
333
+
return;
334
334
+
}
335
335
+
336
336
+
// Check for atMention inline nodes (publications/documents)
337
337
+
if (nodeAt1?.type === multiBlockSchema.nodes.atMention) {
338
338
+
window.open(
339
339
+
atUriToUrl(nodeAt1.attrs.atURI),
340
340
+
"_blank",
341
341
+
"noopener,noreferrer",
342
342
+
);
343
343
+
return;
344
344
+
}
345
345
+
if (nodeAt2?.type === multiBlockSchema.nodes.atMention) {
346
346
+
window.open(
347
347
+
atUriToUrl(nodeAt2.attrs.atURI),
348
348
+
"_blank",
349
349
+
"noopener,noreferrer",
350
350
+
);
351
351
+
return;
199
352
}
200
353
},
201
354
dispatchTransaction(tr) {
···
236
389
<div className="w-full relative group">
237
390
<pre
238
391
ref={mountRef}
392
392
+
onFocus={() => {
393
393
+
// Close mention dropdown when editor gains focus (reset stale state)
394
394
+
handleMentionOpenChange(false);
395
395
+
}}
396
396
+
onBlur={(e) => {
397
397
+
// Close mention dropdown when editor loses focus
398
398
+
// But not if focus moved to the mention autocomplete
399
399
+
const relatedTarget = e.relatedTarget as HTMLElement | null;
400
400
+
if (!relatedTarget?.closest(".dropdownMenu")) {
401
401
+
handleMentionOpenChange(false);
402
402
+
}
403
403
+
}}
239
404
className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`}
240
405
/>
241
406
<IOSBS view={view} />
407
407
+
<MentionAutocomplete
408
408
+
open={mentionOpen}
409
409
+
onOpenChange={handleMentionOpenChange}
410
410
+
view={view}
411
411
+
onSelect={handleMentionSelect}
412
412
+
coords={mentionCoords}
413
413
+
/>
242
414
</div>
243
415
<div className="flex justify-between pt-1">
244
416
<div className="flex gap-1">
···
261
433
view={view}
262
434
/>
263
435
</div>
264
264
-
<ButtonPrimary compact onClick={handleSubmit}>
436
436
+
<ButtonPrimary compact onClick={() => handleSubmitRef.current()}>
265
437
{loading ? <DotLoader /> : <ShareSmall />}
266
438
</ButtonPrimary>
267
439
</div>
···
328
500
facets.push(facet);
329
501
}
330
502
}
503
503
+
504
504
+
fullText += text;
505
505
+
byteOffset += unicodeString.length;
506
506
+
} else if (node.type.name === "didMention") {
507
507
+
// Handle DID mention nodes
508
508
+
const text = node.attrs.text || "";
509
509
+
const unicodeString = new UnicodeString(text);
510
510
+
511
511
+
facets.push({
512
512
+
index: {
513
513
+
byteStart: byteOffset,
514
514
+
byteEnd: byteOffset + unicodeString.length,
515
515
+
},
516
516
+
features: [
517
517
+
{
518
518
+
$type: "pub.leaflet.richtext.facet#didMention",
519
519
+
did: node.attrs.did,
520
520
+
},
521
521
+
],
522
522
+
});
523
523
+
524
524
+
fullText += text;
525
525
+
byteOffset += unicodeString.length;
526
526
+
} else if (node.type.name === "atMention") {
527
527
+
// Handle AT-URI mention nodes (publications and documents)
528
528
+
const text = node.attrs.text || "";
529
529
+
const unicodeString = new UnicodeString(text);
530
530
+
531
531
+
facets.push({
532
532
+
index: {
533
533
+
byteStart: byteOffset,
534
534
+
byteEnd: byteOffset + unicodeString.length,
535
535
+
},
536
536
+
features: [
537
537
+
{
538
538
+
$type: "pub.leaflet.richtext.facet#atMention",
539
539
+
atURI: node.attrs.atURI,
540
540
+
},
541
541
+
],
542
542
+
});
331
543
332
544
fullText += text;
333
545
byteOffset += unicodeString.length;
+98
-1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
···
10
10
import { Json } from "supabase/database.types";
11
11
import {
12
12
Notification,
13
13
+
NotificationData,
13
14
pingIdentityToUpdateNotification,
14
15
} from "src/notifications";
15
16
import { v7 } from "uuid";
···
84
85
parent_uri: args.comment.replyTo,
85
86
},
86
87
});
88
88
+
}
89
89
+
90
90
+
// Create mention notifications from comment facets
91
91
+
const mentionNotifications = createCommentMentionNotifications(
92
92
+
args.comment.facets,
93
93
+
uri.toString(),
94
94
+
credentialSession.did!,
95
95
+
);
96
96
+
notifications.push(...mentionNotifications);
97
97
+
98
98
+
// Insert all notifications and ping recipients
99
99
+
if (notifications.length > 0) {
87
100
// SOMEDAY: move this out the action with inngest or workflows
88
101
await supabaseServerClient.from("notifications").insert(notifications);
89
89
-
await pingIdentityToUpdateNotification(recipient);
102
102
+
103
103
+
// Ping all unique recipients
104
104
+
const uniqueRecipients = [...new Set(notifications.map((n) => n.recipient))];
105
105
+
await Promise.all(
106
106
+
uniqueRecipients.map((r) => pingIdentityToUpdateNotification(r)),
107
107
+
);
90
108
}
91
109
92
110
return {
···
95
113
uri: uri.toString(),
96
114
};
97
115
}
116
116
+
117
117
+
/**
118
118
+
* Creates mention notifications from comment facets
119
119
+
* Handles didMention (people) and atMention (publications/documents)
120
120
+
*/
121
121
+
function createCommentMentionNotifications(
122
122
+
facets: PubLeafletRichtextFacet.Main[],
123
123
+
commentUri: string,
124
124
+
commenterDid: string,
125
125
+
): Notification[] {
126
126
+
const notifications: Notification[] = [];
127
127
+
const notifiedRecipients = new Set<string>(); // Avoid duplicate notifications
128
128
+
129
129
+
for (const facet of facets) {
130
130
+
for (const feature of facet.features) {
131
131
+
if (PubLeafletRichtextFacet.isDidMention(feature)) {
132
132
+
// DID mention - notify the mentioned person directly
133
133
+
const recipientDid = feature.did;
134
134
+
135
135
+
// Don't notify yourself
136
136
+
if (recipientDid === commenterDid) continue;
137
137
+
// Avoid duplicate notifications to the same person
138
138
+
if (notifiedRecipients.has(recipientDid)) continue;
139
139
+
notifiedRecipients.add(recipientDid);
140
140
+
141
141
+
notifications.push({
142
142
+
id: v7(),
143
143
+
recipient: recipientDid,
144
144
+
data: {
145
145
+
type: "comment_mention",
146
146
+
comment_uri: commentUri,
147
147
+
mention_type: "did",
148
148
+
},
149
149
+
});
150
150
+
} else if (PubLeafletRichtextFacet.isAtMention(feature)) {
151
151
+
// AT-URI mention - notify the owner of the publication/document
152
152
+
try {
153
153
+
const mentionedUri = new AtUri(feature.atURI);
154
154
+
const recipientDid = mentionedUri.host;
155
155
+
156
156
+
// Don't notify yourself
157
157
+
if (recipientDid === commenterDid) continue;
158
158
+
// Avoid duplicate notifications to the same person for the same mentioned item
159
159
+
const dedupeKey = `${recipientDid}:${feature.atURI}`;
160
160
+
if (notifiedRecipients.has(dedupeKey)) continue;
161
161
+
notifiedRecipients.add(dedupeKey);
162
162
+
163
163
+
if (mentionedUri.collection === "pub.leaflet.publication") {
164
164
+
notifications.push({
165
165
+
id: v7(),
166
166
+
recipient: recipientDid,
167
167
+
data: {
168
168
+
type: "comment_mention",
169
169
+
comment_uri: commentUri,
170
170
+
mention_type: "publication",
171
171
+
mentioned_uri: feature.atURI,
172
172
+
},
173
173
+
});
174
174
+
} else if (mentionedUri.collection === "pub.leaflet.document") {
175
175
+
notifications.push({
176
176
+
id: v7(),
177
177
+
recipient: recipientDid,
178
178
+
data: {
179
179
+
type: "comment_mention",
180
180
+
comment_uri: commentUri,
181
181
+
mention_type: "document",
182
182
+
mentioned_uri: feature.atURI,
183
183
+
},
184
184
+
});
185
185
+
}
186
186
+
} catch (error) {
187
187
+
console.error("Failed to parse AT-URI for mention:", feature.atURI, error);
188
188
+
}
189
189
+
}
190
190
+
}
191
191
+
}
192
192
+
193
193
+
return notifications;
194
194
+
}
+101
-4
src/notifications.ts
···
17
17
| { type: "quote"; bsky_post_uri: string; document_uri: string }
18
18
| { type: "mention"; document_uri: string; mention_type: "did" }
19
19
| { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string }
20
20
-
| { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string };
20
20
+
| { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string }
21
21
+
| { type: "comment_mention"; comment_uri: string; mention_type: "did" }
22
22
+
| { type: "comment_mention"; comment_uri: string; mention_type: "publication"; mentioned_uri: string }
23
23
+
| { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string };
21
24
22
25
export type HydratedNotification =
23
26
| HydratedCommentNotification
24
27
| HydratedSubscribeNotification
25
28
| HydratedQuoteNotification
26
26
-
| HydratedMentionNotification;
29
29
+
| HydratedMentionNotification
30
30
+
| HydratedCommentMentionNotification;
27
31
export async function hydrateNotifications(
28
32
notifications: NotificationRow[],
29
33
): Promise<Array<HydratedNotification>> {
30
34
// Call all hydrators in parallel
31
31
-
const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications] = await Promise.all([
35
35
+
const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([
32
36
hydrateCommentNotifications(notifications),
33
37
hydrateSubscribeNotifications(notifications),
34
38
hydrateQuoteNotifications(notifications),
35
39
hydrateMentionNotifications(notifications),
40
40
+
hydrateCommentMentionNotifications(notifications),
36
41
]);
37
42
38
43
// Combine all hydrated notifications
39
39
-
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications];
44
44
+
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications];
40
45
41
46
// Sort by created_at to maintain order
42
47
allHydrated.sort(
···
255
260
mentioned_uri: mentionedUri,
256
261
document: documents?.find((d) => d.uri === notification.data.document_uri)!,
257
262
documentCreatorHandle,
263
263
+
mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined,
264
264
+
mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined,
265
265
+
};
266
266
+
});
267
267
+
}
268
268
+
269
269
+
export type HydratedCommentMentionNotification = Awaited<
270
270
+
ReturnType<typeof hydrateCommentMentionNotifications>
271
271
+
>[0];
272
272
+
273
273
+
async function hydrateCommentMentionNotifications(notifications: NotificationRow[]) {
274
274
+
const commentMentionNotifications = notifications.filter(
275
275
+
(n): n is NotificationRow & { data: ExtractNotificationType<"comment_mention"> } =>
276
276
+
(n.data as NotificationData)?.type === "comment_mention",
277
277
+
);
278
278
+
279
279
+
if (commentMentionNotifications.length === 0) {
280
280
+
return [];
281
281
+
}
282
282
+
283
283
+
// Fetch comment data from the database
284
284
+
const commentUris = commentMentionNotifications.map((n) => n.data.comment_uri);
285
285
+
const { data: comments } = await supabaseServerClient
286
286
+
.from("comments_on_documents")
287
287
+
.select(
288
288
+
"*, bsky_profiles(*), documents(*, documents_in_publications(publications(*)))",
289
289
+
)
290
290
+
.in("uri", commentUris);
291
291
+
292
292
+
// Extract unique DIDs from comment URIs to resolve handles
293
293
+
const commenterDids = [...new Set(commentUris.map((uri) => new AtUri(uri).host))];
294
294
+
295
295
+
// Resolve DIDs to handles in parallel
296
296
+
const didToHandleMap = new Map<string, string | null>();
297
297
+
await Promise.all(
298
298
+
commenterDids.map(async (did) => {
299
299
+
try {
300
300
+
const resolved = await idResolver.did.resolve(did);
301
301
+
const handle = resolved?.alsoKnownAs?.[0]
302
302
+
? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix
303
303
+
: null;
304
304
+
didToHandleMap.set(did, handle);
305
305
+
} catch (error) {
306
306
+
console.error(`Failed to resolve DID ${did}:`, error);
307
307
+
didToHandleMap.set(did, null);
308
308
+
}
309
309
+
}),
310
310
+
);
311
311
+
312
312
+
// Fetch mentioned publications and documents
313
313
+
const mentionedPublicationUris = commentMentionNotifications
314
314
+
.filter((n) => n.data.mention_type === "publication")
315
315
+
.map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "publication" }>).mentioned_uri);
316
316
+
317
317
+
const mentionedDocumentUris = commentMentionNotifications
318
318
+
.filter((n) => n.data.mention_type === "document")
319
319
+
.map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "document" }>).mentioned_uri);
320
320
+
321
321
+
const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([
322
322
+
mentionedPublicationUris.length > 0
323
323
+
? supabaseServerClient
324
324
+
.from("publications")
325
325
+
.select("*")
326
326
+
.in("uri", mentionedPublicationUris)
327
327
+
: Promise.resolve({ data: [] }),
328
328
+
mentionedDocumentUris.length > 0
329
329
+
? supabaseServerClient
330
330
+
.from("documents")
331
331
+
.select("*, documents_in_publications(publications(*))")
332
332
+
.in("uri", mentionedDocumentUris)
333
333
+
: Promise.resolve({ data: [] }),
334
334
+
]);
335
335
+
336
336
+
return commentMentionNotifications.map((notification) => {
337
337
+
const mentionedUri = notification.data.mention_type !== "did"
338
338
+
? (notification.data as Extract<ExtractNotificationType<"comment_mention">, { mentioned_uri: string }>).mentioned_uri
339
339
+
: undefined;
340
340
+
341
341
+
const commenterDid = new AtUri(notification.data.comment_uri).host;
342
342
+
const commenterHandle = didToHandleMap.get(commenterDid) ?? null;
343
343
+
const commentData = comments?.find((c) => c.uri === notification.data.comment_uri);
344
344
+
345
345
+
return {
346
346
+
id: notification.id,
347
347
+
recipient: notification.recipient,
348
348
+
created_at: notification.created_at,
349
349
+
type: "comment_mention" as const,
350
350
+
comment_uri: notification.data.comment_uri,
351
351
+
mention_type: notification.data.mention_type,
352
352
+
mentioned_uri: mentionedUri,
353
353
+
commentData: commentData!,
354
354
+
commenterHandle,
258
355
mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined,
259
356
mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined,
260
357
};