+166
-51
src/components/NotificationsPage.tsx
+166
-51
src/components/NotificationsPage.tsx
···
1
1
import { Record as Comment } from "$lexicon/types/social/grain/comment.ts";
2
+
import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts";
2
3
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
3
4
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
4
5
import { Record as Follow } from "$lexicon/types/social/grain/graph/follow.ts";
5
6
import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts";
6
-
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
7
+
import {
8
+
isPhotoView,
9
+
PhotoView,
10
+
} from "$lexicon/types/social/grain/photo/defs.ts";
7
11
import { Un$Typed } from "$lexicon/util.ts";
8
-
import { formatRelativeTime, profileLink } from "../utils.ts";
12
+
import { AtUri } from "@atproto/syntax";
13
+
import { formatRelativeTime, galleryLink, profileLink } from "../utils.ts";
9
14
import { ActorAvatar } from "./ActorAvatar.tsx";
10
15
import { GalleryPreviewLink } from "./GalleryPreviewLink.tsx";
11
16
import { Header } from "./Header.tsx";
12
17
13
18
export function NotificationsPage(
14
-
{ photosMap, galleriesMap, notifications }: Readonly<
19
+
{ photosMap, galleriesMap, notifications, commentsMap }: Readonly<
15
20
{
16
21
photosMap: Map<string, Un$Typed<PhotoView>>;
17
22
galleriesMap: Map<string, Un$Typed<GalleryView>>;
23
+
commentsMap: Map<string, Un$Typed<CommentView>>;
18
24
notifications: Un$Typed<NotificationView>[];
19
25
}
20
26
>,
···
78
84
)}
79
85
</span>
80
86
</div>
81
-
{notification.reason === "gallery-favorite" && galleriesMap.get(
82
-
(notification.record as Favorite).subject,
83
-
)
84
-
? (
85
-
<div class="w-[200px]">
86
-
<GalleryPreviewLink
87
-
gallery={galleriesMap.get(
88
-
(notification.record as Favorite).subject,
89
-
) as GalleryView}
90
-
size="small"
91
-
/>
92
-
</div>
93
-
)
94
-
: null}
95
-
{(notification.reason === "gallery-comment" ||
96
-
notification.reason === "reply") && galleriesMap.get(
97
-
(notification.record as Comment).subject,
98
-
)
99
-
? (
100
-
<>
101
-
{(notification.record as Comment).text}
102
-
{(notification.record as Comment).focus
103
-
? (
104
-
<div class="w-[200px] pointer-events-none">
105
-
<img
106
-
src={photosMap.get(
107
-
(notification.record as Comment).focus ?? "",
108
-
)?.thumb}
109
-
alt={photosMap.get(
110
-
(notification.record as Comment).focus ?? "",
111
-
)?.alt}
112
-
class="rounded-md"
113
-
/>
114
-
</div>
115
-
)
116
-
: (
117
-
<div class="w-[200px]">
118
-
<GalleryPreviewLink
119
-
gallery={galleriesMap.get(
120
-
(notification.record as Favorite).subject,
121
-
) as GalleryView}
122
-
size="small"
123
-
/>
124
-
</div>
125
-
)}
126
-
</>
127
-
)
128
-
: null}
87
+
{notification.reason === "gallery-favorite" &&
88
+
(
89
+
<GalleryFavoriteNotification
90
+
notification={notification}
91
+
galleriesMap={galleriesMap}
92
+
/>
93
+
)}
94
+
{notification.reason === "gallery-comment" &&
95
+
(
96
+
<GalleryCommentNotification
97
+
notification={notification}
98
+
galleriesMap={galleriesMap}
99
+
photosMap={photosMap}
100
+
/>
101
+
)}
102
+
{notification.reason === "reply" &&
103
+
(
104
+
<ReplyNotification
105
+
notification={notification}
106
+
galleriesMap={galleriesMap}
107
+
photosMap={photosMap}
108
+
commentsMap={commentsMap}
109
+
/>
110
+
)}
129
111
</li>
130
112
))
131
113
)
···
134
116
</div>
135
117
);
136
118
}
119
+
120
+
function GalleryCommentNotification(
121
+
{ notification, galleriesMap, photosMap }: {
122
+
notification: NotificationView;
123
+
galleriesMap: Map<string, GalleryView>;
124
+
photosMap: Map<string, PhotoView>;
125
+
},
126
+
) {
127
+
const comment = notification.record as Comment;
128
+
const gallery = galleriesMap.get(comment.subject) as GalleryView | undefined;
129
+
if (!gallery) return null;
130
+
return (
131
+
<>
132
+
{comment.text}
133
+
{comment.focus
134
+
? (
135
+
<a
136
+
href={galleryLink(
137
+
gallery.creator.handle,
138
+
new AtUri(gallery.uri).rkey,
139
+
)}
140
+
class="w-[200px]"
141
+
>
142
+
<img
143
+
src={photosMap.get(comment.focus ?? "")?.thumb}
144
+
alt={photosMap.get(comment.focus ?? "")?.alt}
145
+
class="rounded-md"
146
+
/>
147
+
</a>
148
+
)
149
+
: (
150
+
<div class="w-[200px]">
151
+
<GalleryPreviewLink
152
+
gallery={gallery}
153
+
size="small"
154
+
/>
155
+
</div>
156
+
)}
157
+
</>
158
+
);
159
+
}
160
+
161
+
function ReplyNotification(
162
+
{ notification, galleriesMap, photosMap, commentsMap }: {
163
+
notification: NotificationView;
164
+
galleriesMap: Map<string, GalleryView>;
165
+
photosMap: Map<string, PhotoView>;
166
+
commentsMap: Map<string, CommentView>;
167
+
},
168
+
) {
169
+
const comment = notification.record as Comment;
170
+
const gallery = galleriesMap.get(comment.subject) as GalleryView | undefined;
171
+
let replyToComment: CommentView | undefined = undefined;
172
+
if (comment.replyTo) {
173
+
replyToComment = commentsMap.get(comment.replyTo);
174
+
}
175
+
if (!gallery) return null;
176
+
return (
177
+
<>
178
+
{replyToComment && (
179
+
<div class="text-sm border-l-2 border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-500 pl-2 max-w-[200px]">
180
+
{replyToComment.text}
181
+
{isPhotoView(replyToComment?.focus)
182
+
? (
183
+
<a
184
+
class="block mt-2"
185
+
href={galleryLink(
186
+
gallery.creator.handle,
187
+
new AtUri(gallery.uri).rkey,
188
+
)}
189
+
>
190
+
<img
191
+
src={photosMap.get(replyToComment.focus.uri)?.thumb}
192
+
alt={photosMap.get(replyToComment.focus.uri)?.alt}
193
+
class="rounded-md"
194
+
/>
195
+
</a>
196
+
)
197
+
: (
198
+
<GalleryPreviewLink
199
+
gallery={gallery}
200
+
size="small"
201
+
/>
202
+
)}
203
+
</div>
204
+
)}
205
+
{comment.text}
206
+
{comment.focus
207
+
? (
208
+
<a
209
+
href={galleryLink(
210
+
gallery.creator.handle,
211
+
new AtUri(gallery.uri).rkey,
212
+
)}
213
+
class="max-w-[200px]"
214
+
>
215
+
<img
216
+
src={photosMap.get(comment.focus ?? "")?.thumb}
217
+
alt={photosMap.get(comment.focus ?? "")?.alt}
218
+
class="rounded-md"
219
+
/>
220
+
</a>
221
+
)
222
+
: !replyToComment
223
+
? (
224
+
<div class="w-[200px]">
225
+
<GalleryPreviewLink
226
+
gallery={gallery}
227
+
size="small"
228
+
/>
229
+
</div>
230
+
)
231
+
: null}
232
+
</>
233
+
);
234
+
}
235
+
236
+
function GalleryFavoriteNotification({ notification, galleriesMap }: {
237
+
notification: NotificationView;
238
+
galleriesMap: Map<string, GalleryView>;
239
+
}) {
240
+
const favorite = notification.record as Favorite;
241
+
const gallery = galleriesMap.get(favorite.subject) as GalleryView | undefined;
242
+
if (!gallery) return null;
243
+
return (
244
+
<div class="w-[200px]">
245
+
<GalleryPreviewLink
246
+
gallery={gallery}
247
+
size="small"
248
+
/>
249
+
</div>
250
+
);
251
+
}
+15
-10
src/lib/gallery.ts
+15
-10
src/lib/gallery.ts
···
20
20
import { AtUri } from "@atproto/syntax";
21
21
import { BffContext, WithBffMeta } from "@bigmoves/bff";
22
22
import { getGalleryCommentsCount } from "../modules/comments.tsx";
23
-
import { getActorProfile } from "./actor.ts";
23
+
import { getActorProfile, getActorProfilesBulk } from "./actor.ts";
24
24
import { photoToView } from "./photo.ts";
25
25
26
26
type PhotoWithExif = WithBffMeta<Photo> & {
···
370
370
371
371
const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries);
372
372
373
-
const profile = getActorProfile(galleries[0].did, ctx);
374
-
if (!profile) return [];
373
+
const uniqueDids = Array.from(new Set(galleries.map((g) => g.did)));
374
+
const creators = getActorProfilesBulk(uniqueDids, ctx);
375
+
const creatorMap = new Map(creators.map((c) => [c.did, c]));
375
376
376
377
const labels = ctx.indexService.queryLabels({ subjects: uris });
377
378
378
-
return galleries.map((gallery) =>
379
-
galleryToView({
380
-
record: gallery,
381
-
creator: profile,
382
-
items: galleryPhotosMap.get(gallery.uri) ?? [],
383
-
labels,
379
+
return galleries
380
+
.map((gallery) => {
381
+
const creator = creatorMap.get(gallery.did);
382
+
if (!creator) return null;
383
+
return galleryToView({
384
+
record: gallery,
385
+
creator,
386
+
items: galleryPhotosMap.get(gallery.uri) ?? [],
387
+
labels,
388
+
});
384
389
})
385
-
);
390
+
.filter((g): g is ReturnType<typeof galleryToView> => g !== null);
386
391
}
+61
-43
src/modules/comments.tsx
+61
-43
src/modules/comments.tsx
···
466
466
),
467
467
];
468
468
469
-
function groupComments(comments: CommentView[]) {
470
-
const repliesByParent = new Map<string, CommentView[]>();
471
-
const topLevel: CommentView[] = [];
472
-
473
-
for (const comment of comments) {
474
-
if (comment.replyTo) {
475
-
if (!repliesByParent.has(comment.replyTo)) {
476
-
repliesByParent.set(comment.replyTo, []);
477
-
}
478
-
repliesByParent.get(comment.replyTo)!.push(comment);
479
-
} else {
480
-
topLevel.push(comment);
481
-
}
482
-
}
483
-
484
-
return { topLevel, repliesByParent };
485
-
}
486
-
487
-
export function getGalleryCommentsCount(uri: string, ctx: BffContext): number {
488
-
return ctx.indexService.countRecords(
489
-
"social.grain.comment",
490
-
{
491
-
where: {
492
-
"AND": [{ field: "subject", equals: uri }],
493
-
},
494
-
limit: 0,
495
-
},
496
-
);
497
-
}
498
-
499
-
function getGalleryComments(uri: string, ctx: BffContext): CommentView[] {
500
-
const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>(
501
-
"social.grain.comment",
502
-
{
503
-
orderBy: [{ field: "createdAt", direction: "desc" }],
504
-
where: {
505
-
"AND": [{ field: "subject", equals: uri }],
506
-
},
507
-
limit: 100,
508
-
},
509
-
);
510
-
511
-
// Batch fetch all authors, subjects, and focus photos for comments using bulk functions
469
+
function hydrateComments(
470
+
comments: WithBffMeta<Comment>[],
471
+
ctx: BffContext,
472
+
): CommentView[] {
512
473
const authorDids = Array.from(new Set(comments.map((c) => c.did)));
513
474
const subjectUris = Array.from(new Set(comments.map((c) => c.subject)));
514
475
const focusUris: string[] = Array.from(
···
537
498
acc.push(commentToView(comment, author, subject, focus));
538
499
return acc;
539
500
}, []);
501
+
}
502
+
503
+
function getGalleryComments(uri: string, ctx: BffContext): CommentView[] {
504
+
const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>(
505
+
"social.grain.comment",
506
+
{
507
+
orderBy: [{ field: "createdAt", direction: "desc" }],
508
+
where: {
509
+
"AND": [{ field: "subject", equals: uri }],
510
+
},
511
+
limit: 100,
512
+
},
513
+
);
514
+
return hydrateComments(comments, ctx);
515
+
}
516
+
517
+
export function getCommentsBulk(
518
+
uris: string[],
519
+
ctx: BffContext,
520
+
): CommentView[] {
521
+
const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>(
522
+
"social.grain.comment",
523
+
{
524
+
where: [{ field: "uri", in: uris }],
525
+
},
526
+
);
527
+
return hydrateComments(comments, ctx);
528
+
}
529
+
530
+
function groupComments(comments: CommentView[]) {
531
+
const repliesByParent = new Map<string, CommentView[]>();
532
+
const topLevel: CommentView[] = [];
533
+
534
+
for (const comment of comments) {
535
+
if (comment.replyTo) {
536
+
if (!repliesByParent.has(comment.replyTo)) {
537
+
repliesByParent.set(comment.replyTo, []);
538
+
}
539
+
repliesByParent.get(comment.replyTo)!.push(comment);
540
+
} else {
541
+
topLevel.push(comment);
542
+
}
543
+
}
544
+
545
+
return { topLevel, repliesByParent };
546
+
}
547
+
548
+
export function getGalleryCommentsCount(uri: string, ctx: BffContext): number {
549
+
return ctx.indexService.countRecords(
550
+
"social.grain.comment",
551
+
{
552
+
where: {
553
+
"AND": [{ field: "subject", equals: uri }],
554
+
},
555
+
limit: 0,
556
+
},
557
+
);
540
558
}
541
559
542
560
function getComment(uri: string, ctx: BffContext) {
+29
-1
src/routes/notifications.tsx
+29
-1
src/routes/notifications.tsx
···
1
1
import { Record as Comment } from "$lexicon/types/social/grain/comment.ts";
2
+
import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts";
2
3
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
3
4
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
4
5
import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts";
5
-
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
6
+
import {
7
+
isPhotoView,
8
+
PhotoView,
9
+
} from "$lexicon/types/social/grain/photo/defs.ts";
6
10
import { Un$Typed } from "$lexicon/util.ts";
7
11
import { BffContext, RouteHandler } from "@bigmoves/bff";
8
12
import { NotificationsPage } from "../components/NotificationsPage.tsx";
9
13
import { getGalleriesBulk } from "../lib/gallery.ts";
10
14
import { getPhotosBulk } from "../lib/photo.ts";
15
+
import { getCommentsBulk } from "../modules/comments.tsx";
11
16
import type { State } from "../state.ts";
12
17
13
18
export const handler: RouteHandler = (
···
35
40
for (const photo of photos) {
36
41
photosMap.set(photo.uri, photo);
37
42
}
43
+
const commentUris = getReplyToUrisForNotifications(
44
+
ctx.state.notifications ?? [],
45
+
);
46
+
const comments = getCommentsBulk(commentUris, ctx);
47
+
const commentsMap = new Map<string, CommentView>();
48
+
for (const comment of comments) {
49
+
commentsMap.set(comment.uri, comment);
50
+
if (isPhotoView(comment.focus)) {
51
+
photosMap.set(comment.focus.uri, comment.focus);
52
+
}
53
+
}
38
54
return ctx.render(
39
55
<NotificationsPage
40
56
photosMap={photosMap}
41
57
galleriesMap={galleriesMap}
58
+
commentsMap={commentsMap}
42
59
notifications={ctx.state.notifications ?? []}
43
60
/>,
44
61
);
···
69
86
.map((n) => (n.record as Comment).focus)
70
87
.filter((focus): focus is string => typeof focus === "string" && !!focus);
71
88
}
89
+
90
+
function getReplyToUrisForNotifications(
91
+
notifications: Un$Typed<NotificationView>[],
92
+
): string[] {
93
+
return notifications
94
+
.filter((n) => n.record.$type === "social.grain.comment")
95
+
.map((n) => (n.record as Comment).replyTo)
96
+
.filter((replyTo): replyTo is string =>
97
+
typeof replyTo === "string" && !!replyTo
98
+
);
99
+
}