+99
-1
__generated__/lexicons.ts
+99
-1
__generated__/lexicons.ts
···
2445
2445
type: 'string',
2446
2446
description:
2447
2447
'The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower.',
2448
-
knownValues: ['follow', 'gallery-favorite', 'unknown'],
2448
+
knownValues: [
2449
+
'follow',
2450
+
'gallery-favorite',
2451
+
'gallery-comment',
2452
+
'unknown',
2453
+
],
2449
2454
},
2450
2455
reasonSubject: {
2451
2456
type: 'string',
···
2465
2470
},
2466
2471
},
2467
2472
},
2473
+
SocialGrainCommentDefs: {
2474
+
lexicon: 1,
2475
+
id: 'social.grain.comment.defs',
2476
+
defs: {
2477
+
commentView: {
2478
+
type: 'object',
2479
+
required: ['uri', 'cid', 'author', 'text', 'createdAt'],
2480
+
properties: {
2481
+
uri: {
2482
+
type: 'string',
2483
+
format: 'at-uri',
2484
+
},
2485
+
cid: {
2486
+
type: 'string',
2487
+
format: 'cid',
2488
+
},
2489
+
author: {
2490
+
type: 'ref',
2491
+
ref: 'lex:social.grain.actor.defs#profileView',
2492
+
},
2493
+
text: {
2494
+
type: 'string',
2495
+
maxLength: 3000,
2496
+
maxGraphemes: 300,
2497
+
},
2498
+
subject: {
2499
+
type: 'union',
2500
+
refs: ['lex:social.grain.gallery.defs#galleryView'],
2501
+
description:
2502
+
'The subject of the comment, which can be a gallery or a photo.',
2503
+
},
2504
+
focus: {
2505
+
type: 'union',
2506
+
refs: ['lex:social.grain.photo.defs#photoView'],
2507
+
description:
2508
+
'The photo that the comment is focused on, if applicable.',
2509
+
},
2510
+
replyTo: {
2511
+
type: 'string',
2512
+
format: 'at-uri',
2513
+
description:
2514
+
'The URI of the comment this comment is replying to, if applicable.',
2515
+
},
2516
+
createdAt: {
2517
+
type: 'string',
2518
+
format: 'datetime',
2519
+
},
2520
+
},
2521
+
},
2522
+
},
2523
+
},
2524
+
SocialGrainComment: {
2525
+
lexicon: 1,
2526
+
id: 'social.grain.comment',
2527
+
defs: {
2528
+
main: {
2529
+
type: 'record',
2530
+
key: 'tid',
2531
+
record: {
2532
+
type: 'object',
2533
+
required: ['text', 'subject', 'createdAt'],
2534
+
properties: {
2535
+
text: {
2536
+
type: 'string',
2537
+
maxLength: 3000,
2538
+
maxGraphemes: 300,
2539
+
},
2540
+
subject: {
2541
+
type: 'string',
2542
+
format: 'at-uri',
2543
+
},
2544
+
focus: {
2545
+
type: 'string',
2546
+
format: 'at-uri',
2547
+
},
2548
+
replyTo: {
2549
+
type: 'string',
2550
+
format: 'at-uri',
2551
+
},
2552
+
createdAt: {
2553
+
type: 'string',
2554
+
format: 'datetime',
2555
+
},
2556
+
},
2557
+
},
2558
+
},
2559
+
},
2560
+
},
2468
2561
SocialGrainGalleryItem: {
2469
2562
lexicon: 1,
2470
2563
id: 'social.grain.gallery.item',
···
2528
2621
},
2529
2622
},
2530
2623
favCount: {
2624
+
type: 'integer',
2625
+
},
2626
+
commentCount: {
2531
2627
type: 'integer',
2532
2628
},
2533
2629
labels: {
···
3418
3514
ShTangledActorProfile: 'sh.tangled.actor.profile',
3419
3515
SocialGrainDefs: 'social.grain.defs',
3420
3516
SocialGrainNotificationDefs: 'social.grain.notification.defs',
3517
+
SocialGrainCommentDefs: 'social.grain.comment.defs',
3518
+
SocialGrainComment: 'social.grain.comment',
3421
3519
SocialGrainGalleryItem: 'social.grain.gallery.item',
3422
3520
SocialGrainGalleryDefs: 'social.grain.gallery.defs',
3423
3521
SocialGrainGallery: 'social.grain.gallery',
+2
-2
deno.json
+2
-2
deno.json
···
27
27
"dev:server": "deno run -A --env-file --watch ./src/main.tsx",
28
28
"dev:tailwind": "deno run -A --node-modules-dir npm:@tailwindcss/cli -i ./src/input.css -o ./build/styles.css --watch",
29
29
"dev:fonts": "rm -rf ./build/fonts && cp -r ./static/fonts/. ./build/fonts",
30
-
"sync": "deno run -A --env=.env jsr:@bigmoves/bff-cli@0.3.0-beta.40 sync --collections=social.grain.gallery,social.grain.actor.profile,social.grain.photo,social.grain.favorite,social.grain.gallery.item,social.grain.graph.follow,social.grain.photo.exif --external-collections=app.bsky.actor.profile,app.bsky.graph.follow,sh.tangled.graph.follow,sh.tangled.actor.profile --collection-key-map=\"{\"social.grain.favorite\":[\"subject\"],\"social.grain.graph.follow\":[\"subject\"],\"social.grain.gallery.item\":[\"gallery\",\"item\"],\"social.grain.photo.exif\":[\"photo\"]}\"",
31
-
"codegen": "deno run -A jsr:@bigmoves/bff-cli@0.3.0-beta.37 lexgen"
30
+
"sync": "deno run -A --env=.env jsr:@bigmoves/bff-cli@0.3.0-beta.40 sync --collections=social.grain.gallery,social.grain.actor.profile,social.grain.photo,social.grain.favorite,social.grain.gallery.item,social.grain.graph.follow,social.grain.photo.exif,social.grain.comment --external-collections=app.bsky.actor.profile,app.bsky.graph.follow,sh.tangled.graph.follow,sh.tangled.actor.profile --collection-key-map=\"{\\\"social.grain.favorite\\\":[\\\"subject\\\"],\\\"social.grain.graph.follow\\\":[\\\"subject\\\"],\\\"social.grain.gallery.item\\\":[\\\"gallery\\\",\\\"item\\\"],\\\"social.grain.photo.exif\\\":[\\\"photo\\\"],\\\"social.grain.comment\\\":[\\\"subject\\\"]}\"",
31
+
"codegen": "deno run -A jsr:@bigmoves/bff-cli@0.3.0-beta.40 lexgen"
32
32
},
33
33
"compilerOptions": {
34
34
"jsx": "precompile",
+6
-3
src/components/ActorInfo.tsx
+6
-3
src/components/ActorInfo.tsx
···
1
1
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
2
2
import { Un$Typed } from "$lexicon/util.ts";
3
+
import { cn } from "@bigmoves/bff/components";
3
4
import { profileLink } from "../utils.ts";
4
5
import { ActorAvatar } from "./ActorAvatar.tsx";
5
6
6
7
export function ActorInfo(
7
-
{ profile }: Readonly<{ profile: Un$Typed<ProfileView> }>,
8
+
{ class: classProp, profile, avatarSize = 28 }: Readonly<
9
+
{ class?: string; profile: Un$Typed<ProfileView>; avatarSize?: number }
10
+
>,
8
11
) {
9
12
return (
10
-
<div class="flex items-center gap-2 min-w-0 flex-1">
11
-
<ActorAvatar profile={profile} size={28} class="shrink-0" />
13
+
<div class={cn("flex items-center gap-2 min-w-0", classProp)}>
14
+
<ActorAvatar profile={profile} size={avatarSize} class="shrink-0" />
12
15
<a
13
16
href={profileLink(profile.handle)}
14
17
class="hover:underline text-zinc-600 dark:text-zinc-500 truncate max-w-[300px] sm:max-w-[400px]"
+3
-3
src/components/Dialog.tsx
+3
-3
src/components/Dialog.tsx
···
38
38
{...{
39
39
_: `on closeDialog
40
40
remove me
41
-
remove .tw:pointer-events-none from document.body
41
+
remove .pointer-events-none from document.body
42
42
remove [@data-scroll-locked] from document.body
43
43
on keyup[key is 'Escape'] from <body/> trigger closeDialog
44
44
init
45
-
add .tw:pointer-events-none to document.body
46
-
add .tw:pointer-events-auto to me
45
+
add .pointer-events-none to document.body
46
+
add .pointer-events-auto to me
47
47
add [@data-scroll-locked=true] to document.body
48
48
${_}`,
49
49
}}
+3
-5
src/components/FavoriteButton.tsx
+3
-5
src/components/FavoriteButton.tsx
···
23
23
: undefined;
24
24
return (
25
25
<Button
26
-
variant={variant === "icon-button" ? "ghost" : "primary"}
26
+
variant={variant === "icon-button" ? "ghost" : "secondary"}
27
27
class={cn(
28
-
"self-start w-full sm:w-fit whitespace-nowrap",
29
-
variant === "icon-button" && gallery.viewer?.fav
30
-
? "text-pink-500"
31
-
: undefined,
28
+
"whitespace-nowrap",
29
+
gallery.viewer?.fav ? "text-pink-500" : undefined,
32
30
variantClass,
33
31
classProp,
34
32
)}
+2
-2
src/components/GalleryLayout.tsx
+2
-2
src/components/GalleryLayout.tsx
···
74
74
type="button"
75
75
hx-get={photoDialogLink(gallery, photo)}
76
76
hx-trigger="click"
77
-
hx-target="#layout"
78
-
hx-swap="afterbegin"
77
+
hx-target="#dialog-target"
78
+
hx-swap="innerHTML"
79
79
class="gallery-item absolute cursor-pointer"
80
80
data-width={photo.aspectRatio?.width}
81
81
data-height={photo.aspectRatio?.height}
+11
-6
src/components/GalleryPage.tsx
+11
-6
src/components/GalleryPage.tsx
···
2
2
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
3
3
import { AtUri } from "@atproto/syntax";
4
4
import { ModerationDecsion } from "../lib/moderation.ts";
5
+
import { CommentsButton } from "../modules/comments.tsx";
5
6
import { EditGalleryButton } from "./EditGalleryDialog.tsx";
6
7
import { FavoriteButton } from "./FavoriteButton.tsx";
7
8
import { GalleryInfo } from "./GalleryInfo.tsx";
···
28
29
<GalleryInfo gallery={gallery} />
29
30
{isLoggedIn && isCreator
30
31
? (
31
-
<div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row sm:flex-wrap sm:justify-end">
32
+
<div class="flex self-start gap-2 w-full flex-col sm:flex-row sm:justify-end">
32
33
<EditGalleryButton gallery={gallery} />
33
-
<ShareGalleryDialogButton gallery={gallery} />
34
-
<FavoriteButton gallery={gallery} />
34
+
<div class="flex flex-row gap-2">
35
+
<FavoriteButton class="flex-1" gallery={gallery} />
36
+
<CommentsButton class="flex-1" gallery={gallery} />
37
+
<ShareGalleryDialogButton class="flex-1" gallery={gallery} />
38
+
</div>
35
39
</div>
36
40
)
37
41
: null}
38
42
{!isCreator
39
43
? (
40
-
<div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row">
41
-
<ShareGalleryDialogButton gallery={gallery} />
42
-
<FavoriteButton gallery={gallery} />
44
+
<div class="flex self-start gap-2 flex-row">
45
+
<FavoriteButton class="flex-1" gallery={gallery} />
46
+
<CommentsButton class="flex-1" gallery={gallery} />
47
+
<ShareGalleryDialogButton class="flex-1" gallery={gallery} />
43
48
</div>
44
49
)
45
50
: null}
+45
-2
src/components/NotificationsPage.tsx
+45
-2
src/components/NotificationsPage.tsx
···
1
+
import { Record as Comment } from "$lexicon/types/social/grain/comment.ts";
1
2
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
2
3
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
3
4
import { Record as Follow } from "$lexicon/types/social/grain/graph/follow.ts";
4
5
import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts";
6
+
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
5
7
import { Un$Typed } from "$lexicon/util.ts";
6
8
import { formatRelativeTime, profileLink } from "../utils.ts";
7
9
import { ActorAvatar } from "./ActorAvatar.tsx";
···
9
11
import { Header } from "./Header.tsx";
10
12
11
13
export function NotificationsPage(
12
-
{ galleriesMap, notifications }: Readonly<
14
+
{ photosMap, galleriesMap, notifications }: Readonly<
13
15
{
16
+
photosMap: Map<string, Un$Typed<PhotoView>>;
14
17
galleriesMap: Map<string, Un$Typed<GalleryView>>;
15
18
notifications: Un$Typed<NotificationView>[];
16
19
}
···
49
52
<>
50
53
favorited your gallery · {formatRelativeTime(
51
54
new Date((notification.record as Favorite).createdAt),
55
+
)}
56
+
</>
57
+
)}
58
+
{notification.reason === "gallery-comment" && (
59
+
<>
60
+
commented on your gallery · {formatRelativeTime(
61
+
new Date((notification.record as Comment).createdAt),
52
62
)}
53
63
</>
54
64
)}
···
69
79
<GalleryPreviewLink
70
80
gallery={galleriesMap.get(
71
81
(notification.record as Favorite).subject,
72
-
) as Un$Typed<GalleryView>}
82
+
) as GalleryView}
73
83
size="small"
74
84
/>
75
85
</div>
86
+
)
87
+
: null}
88
+
{notification.reason === "gallery-comment" && galleriesMap.get(
89
+
(notification.record as Comment).subject,
90
+
)
91
+
? (
92
+
<>
93
+
{(notification.record as Comment).text}
94
+
{(notification.record as Comment).focus
95
+
? (
96
+
<div class="w-[200px] pointer-events-none">
97
+
<img
98
+
src={photosMap.get(
99
+
(notification.record as Comment).focus ?? "",
100
+
)?.thumb}
101
+
alt={photosMap.get(
102
+
(notification.record as Comment).focus ?? "",
103
+
)?.alt}
104
+
class="rounded-md"
105
+
/>
106
+
</div>
107
+
)
108
+
: (
109
+
<div class="w-[200px]">
110
+
<GalleryPreviewLink
111
+
gallery={galleriesMap.get(
112
+
(notification.record as Favorite).subject,
113
+
) as GalleryView}
114
+
size="small"
115
+
/>
116
+
</div>
117
+
)}
118
+
</>
76
119
)
77
120
: null}
78
121
</li>
+18
-19
src/components/PhotoDialog.tsx
+18
-19
src/components/PhotoDialog.tsx
···
1
+
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
1
2
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
2
3
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
3
4
import { AtUri } from "@atproto/syntax";
4
5
import { cn } from "@bigmoves/bff/components";
6
+
import { ReplyButton } from "../modules/comments.tsx";
5
7
import { photoDialogLink } from "../utils.ts";
6
8
import { Dialog } from "./Dialog.tsx";
7
9
8
10
export function PhotoDialog({
11
+
userProfile,
9
12
gallery,
10
13
image,
11
14
nextImage,
12
15
prevImage,
13
16
}: Readonly<{
17
+
userProfile?: ProfileView;
14
18
gallery: GalleryView;
15
19
image: PhotoView;
16
20
nextImage?: PhotoView;
···
50
54
class="absolute inset-0 w-full h-full object-contain"
51
55
/>
52
56
</div>
53
-
{image.exif
57
+
{image.alt
54
58
? (
55
-
<div class="hidden sm:block absolute bottom-2 right-2">
56
-
<ExifButton photo={image} />
59
+
<div class="px-4 sm:px-0 py-4 bg-black text-white text-left flex">
60
+
<span class="flex-1 mr-2">{image.alt}</span>
57
61
</div>
58
62
)
59
63
: null}
60
-
{image.alt
64
+
{(userProfile || image.exif)
61
65
? (
62
-
<div class="px-4 sm:px-0 py-4 bg-black text-white text-left flex">
63
-
<span class="flex-1 mr-2">{image.alt}</span>
64
-
{image.exif
66
+
<div class="flex w-full gap-2 p-2 sm:px-0 sm:py-2">
67
+
{userProfile
65
68
? (
66
-
<div class="block sm:hidden self-end justify-end -m-2">
67
-
<ExifButton photo={image} />
68
-
</div>
69
+
<ReplyButton
70
+
class="flex-1 bg-zinc-800 sm:bg-transparent sm:hover:bg-zinc-800 text-zinc-50"
71
+
gallery={gallery}
72
+
photo={image}
73
+
userProfile={userProfile}
74
+
/>
69
75
)
70
-
: null}
76
+
: <div class="flex-1" />}
77
+
{image.exif ? <ExifButton photo={image} /> : null}
71
78
</div>
72
-
)
73
-
: null}
74
-
{!image.alt && image.exif
75
-
? (
76
-
<ExifButton
77
-
photo={image}
78
-
class="block sm:hidden absolute bottom-2 right-2 z-100"
79
-
/>
80
79
)
81
80
: null}
82
81
</div>
+1
src/components/Timeline.tsx
+1
src/components/Timeline.tsx
+12
-5
src/components/TimelineItem.tsx
+12
-5
src/components/TimelineItem.tsx
···
1
1
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
2
2
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
3
3
import { type TimelineItem } from "../lib/timeline.ts";
4
+
import { CommentsButton } from "../modules/comments.tsx";
4
5
import { formatRelativeTime } from "../utils.ts";
5
6
import { ActorInfo } from "./ActorInfo.tsx";
6
7
import { FavoriteButton } from "./FavoriteButton.tsx";
···
14
15
<li>
15
16
<div class="flex flex-col pb-4 max-w-md">
16
17
<div class="flex items-center justify-between gap-2 w-full mb-4">
17
-
<ActorInfo profile={item.actor} />
18
+
<ActorInfo profile={item.actor} class="flex-1" />
18
19
<span class="shrink-0">
19
20
{formatRelativeTime(new Date(item.createdAt))}
20
21
</span>
···
47
48
{description}
48
49
</p>
49
50
)}
50
-
<FavoriteButton
51
-
gallery={item.gallery}
52
-
variant="icon-button"
53
-
/>
51
+
<div class="flex gap-4">
52
+
<FavoriteButton
53
+
gallery={item.gallery}
54
+
variant="icon-button"
55
+
/>
56
+
<CommentsButton
57
+
gallery={item.gallery}
58
+
variant="icon-button"
59
+
/>
60
+
</div>
54
61
</div>
55
62
</li>
56
63
);
+15
src/input.css
+15
src/input.css
···
165
165
margin-top: 0;
166
166
margin-right: 0px !important;
167
167
}
168
+
169
+
.grain-scroll-area {
170
+
overflow-y: auto;
171
+
scrollbar-gutter: stable;
172
+
scrollbar-width: thin; /* Firefox */
173
+
}
174
+
175
+
.grain-scroll-area::-webkit-scrollbar {
176
+
width: 8px; /* Chrome/Safari */
177
+
}
178
+
179
+
.grain-scroll-area::-webkit-scrollbar-thumb {
180
+
background: rgba(100, 100, 100, 0.4);
181
+
border-radius: 4px;
182
+
}
168
183
}
+23
src/lib/actor.ts
+23
src/lib/actor.ts
···
250
250
if (tangledProfiles.length) profiles.push("tangled");
251
251
return profiles;
252
252
}
253
+
254
+
export function getActorProfilesBulk(
255
+
dids: string[],
256
+
ctx: BffContext,
257
+
) {
258
+
const { items: profiles } = ctx.indexService.getRecords<
259
+
WithBffMeta<GrainProfile>
260
+
>(
261
+
"social.grain.actor.profile",
262
+
{
263
+
where: {
264
+
AND: [
265
+
{ field: "did", in: dids },
266
+
],
267
+
},
268
+
},
269
+
);
270
+
271
+
return profiles.map((profile) => {
272
+
const handle = ctx.indexService.getActor(profile.did)?.handle ?? "";
273
+
return profileToView(profile, handle);
274
+
});
275
+
}
+42
-2
src/lib/gallery.ts
+42
-2
src/lib/gallery.ts
···
16
16
PhotoView,
17
17
} from "$lexicon/types/social/grain/photo/defs.ts";
18
18
import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts";
19
-
import { Un$Typed } from "$lexicon/util.ts";
19
+
import { $Typed, Un$Typed } from "$lexicon/util.ts";
20
20
import { AtUri } from "@atproto/syntax";
21
21
import { BffContext, WithBffMeta } from "@bigmoves/bff";
22
+
import { getGalleryCommentsCount } from "../modules/comments.tsx";
22
23
import { getActorProfile } from "./actor.ts";
23
24
import { photoToView } from "./photo.ts";
24
25
···
113
114
114
115
const favs = getGalleryFavs(gallery.uri, ctx);
115
116
117
+
const comments = getGalleryCommentsCount(gallery.uri, ctx);
118
+
116
119
let viewerFav: string | undefined = undefined;
117
120
if (ctx.currentUser?.did) {
118
121
const fav = getGalleryFav(ctx.currentUser?.did, gallery.uri, ctx);
···
127
130
items: galleryPhotosMap.get(gallery.uri) ?? [],
128
131
labels,
129
132
favCount: favs,
133
+
commentCount: comments,
130
134
viewerState: {
131
135
fav: viewerFav,
132
136
},
···
197
201
items,
198
202
labels = [],
199
203
favCount,
204
+
commentCount,
200
205
viewerState,
201
206
}: {
202
207
record: WithBffMeta<Gallery>;
···
204
209
items: PhotoWithExif[];
205
210
labels: Label[];
206
211
favCount?: number;
212
+
commentCount?: number;
207
213
viewerState?: ViewerState;
208
-
}): Un$Typed<GalleryView> {
214
+
}): $Typed<GalleryView> {
209
215
return {
216
+
$type: "social.grain.gallery.defs#galleryView",
210
217
uri: record.uri,
211
218
cid: record.cid,
212
219
creator,
···
217
224
labels,
218
225
indexedAt: record.indexedAt,
219
226
favCount,
227
+
commentCount,
220
228
viewer: viewerState,
221
229
};
222
230
}
···
344
352
})
345
353
.filter(isPhotoView);
346
354
}
355
+
356
+
export function getGalleriesBulk(
357
+
uris: string[],
358
+
ctx: BffContext,
359
+
) {
360
+
if (!uris.length) return [];
361
+
const { items: galleries } = ctx.indexService.getRecords<
362
+
WithBffMeta<Gallery>
363
+
>(
364
+
"social.grain.gallery",
365
+
{
366
+
where: [{ field: "uri", in: uris }],
367
+
},
368
+
);
369
+
if (!galleries.length) return [];
370
+
371
+
const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries);
372
+
373
+
const profile = getActorProfile(galleries[0].did, ctx);
374
+
if (!profile) return [];
375
+
376
+
const labels = ctx.indexService.queryLabels({ subjects: uris });
377
+
378
+
return galleries.map((gallery) =>
379
+
galleryToView({
380
+
record: gallery,
381
+
creator: profile,
382
+
items: galleryPhotosMap.get(gallery.uri) ?? [],
383
+
labels,
384
+
})
385
+
);
386
+
}
+6
-2
src/lib/notifications.ts
+6
-2
src/lib/notifications.ts
···
1
1
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
2
+
import { Record as Comment } from "$lexicon/types/social/grain/comment.ts";
2
3
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
3
4
import { Record as Follow } from "$lexicon/types/social/grain/graph/follow.ts";
4
5
import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts";
···
6
7
import { ActorTable, BffContext, WithBffMeta } from "@bigmoves/bff";
7
8
import { getActorProfile } from "./actor.ts";
8
9
9
-
export type NotificationRecords = WithBffMeta<Favorite | Follow>;
10
+
export type NotificationRecords = WithBffMeta<Favorite | Follow | Comment>;
10
11
11
12
export function getNotifications(
12
13
currentUser: ActorTable,
···
18
19
.filter(
19
20
(notification) =>
20
21
notification.$type === "social.grain.favorite" ||
21
-
notification.$type === "social.grain.graph.follow",
22
+
notification.$type === "social.grain.graph.follow" ||
23
+
notification.$type === "social.grain.comment",
22
24
)
23
25
.map((notification) => {
24
26
const actor = ctx.indexService.getActor(notification.did);
···
43
45
reason = "gallery-favorite";
44
46
} else if (record.$type === "social.grain.graph.follow") {
45
47
reason = "follow";
48
+
} else if (record.$type === "social.grain.comment") {
49
+
reason = "gallery-comment";
46
50
} else {
47
51
reason = "unknown";
48
52
}
+30
-1
src/lib/photo.ts
+30
-1
src/lib/photo.ts
···
241
241
labels: labels ?? [],
242
242
});
243
243
})
244
-
.filter((g): g is GalleryView => Boolean(g));
244
+
.filter((g): g is $Typed<GalleryView> => Boolean(g));
245
+
}
246
+
247
+
export function getPhotosBulk(
248
+
uris: string[],
249
+
ctx: BffContext,
250
+
) {
251
+
if (!uris.length) return [];
252
+
const { items: photos } = ctx.indexService.getRecords<WithBffMeta<Photo>>(
253
+
"social.grain.photo",
254
+
{
255
+
where: [{ field: "uri", in: uris }],
256
+
},
257
+
);
258
+
if (!photos.length) return [];
259
+
const { items: exifItems } = ctx.indexService.getRecords<
260
+
WithBffMeta<PhotoExif>
261
+
>(
262
+
"social.grain.photo.exif",
263
+
{
264
+
where: [{ field: "photo", in: uris }],
265
+
},
266
+
);
267
+
const exifMap = new Map<string, WithBffMeta<PhotoExif>>();
268
+
for (const exif of exifItems) {
269
+
exifMap.set(exif.photo, exif);
270
+
}
271
+
return photos.map((photo) =>
272
+
photoToView(photo.did, photo, exifMap.get(photo.uri))
273
+
);
245
274
}
+4
src/lib/timeline.ts
+4
src/lib/timeline.ts
···
7
7
import { Un$Typed } from "$lexicon/util.ts";
8
8
import { AtUri } from "@atproto/syntax";
9
9
import { BffContext, QueryOptions, WithBffMeta } from "@bigmoves/bff";
10
+
import { getGalleryCommentsCount } from "../modules/comments.tsx";
10
11
import { getActorProfile } from "./actor.ts";
11
12
import {
12
13
galleryToView,
···
91
92
}
92
93
}
93
94
95
+
const comments = getGalleryCommentsCount(gallery.uri, ctx);
96
+
94
97
const galleryView = galleryToView({
95
98
record: gallery,
96
99
creator: profile,
97
100
items: galleryPhotos,
98
101
labels,
99
102
favCount: favs,
103
+
commentCount: comments,
100
104
viewerState: {
101
105
fav: viewerFav,
102
106
},
+3
src/main.tsx
+3
src/main.tsx
···
4
4
import { LoginPage } from "./components/LoginPage.tsx";
5
5
import { PDS_HOST_URL } from "./env.ts";
6
6
import { onError } from "./lib/errors.ts";
7
+
import { middlewares as comments } from "./modules/comments.tsx";
7
8
import * as actions from "./routes/actions.tsx";
8
9
import { handler as communityGuidelinesHandler } from "./routes/community_guidelines.tsx";
9
10
import * as dialogs from "./routes/dialogs.tsx";
···
46
47
"social.grain.graph.follow": ["subject"],
47
48
"social.grain.gallery.item": ["gallery", "item"],
48
49
"social.grain.photo.exif": ["photo"],
50
+
"social.grain.comment": ["subject"],
49
51
},
50
52
lexicons,
51
53
rootElement: Root,
···
125
127
route("/actions/profile", ["PUT"], actions.profileUpdate),
126
128
route("/actions/gallery/:rkey/sort", ["POST"], actions.gallerySort),
127
129
route("/actions/get-blob", ["GET"], actions.getBlob),
130
+
...comments,
128
131
route("/:did/:collection/:rkey", recordHandler),
129
132
],
130
133
});
+515
src/modules/comments.tsx
+515
src/modules/comments.tsx
···
1
+
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
2
+
import { Record as Comment } from "$lexicon/types/social/grain/comment.ts";
3
+
import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts";
4
+
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
5
+
import {
6
+
GalleryView,
7
+
isGalleryView,
8
+
} from "$lexicon/types/social/grain/gallery/defs.ts";
9
+
import {
10
+
isPhotoView,
11
+
PhotoView,
12
+
} from "$lexicon/types/social/grain/photo/defs.ts";
13
+
import { AtUri } from "@atproto/syntax";
14
+
import { BffContext, BffMiddleware, route, WithBffMeta } from "@bigmoves/bff";
15
+
import { cn } from "@bigmoves/bff/components";
16
+
import { Dialog } from "..//components/Dialog.tsx";
17
+
import { ActorAvatar } from "../components/ActorAvatar.tsx";
18
+
import { ActorInfo } from "../components/ActorInfo.tsx";
19
+
import { Button } from "../components/Button.tsx";
20
+
import { GalleryPreviewLink } from "../components/GalleryPreviewLink.tsx";
21
+
import { Textarea } from "../components/Textarea.tsx";
22
+
import { getActorProfile, getActorProfilesBulk } from "../lib/actor.ts";
23
+
import { getGalleriesBulk, getGallery } from "../lib/gallery.ts";
24
+
import { getPhoto, getPhotosBulk } from "../lib/photo.ts";
25
+
import { formatRelativeTime } from "../utils.ts";
26
+
27
+
export function ReplyDialog({ userProfile, gallery, photo, comment }: Readonly<{
28
+
userProfile: ProfileView;
29
+
gallery?: GalleryView;
30
+
photo?: PhotoView;
31
+
comment?: CommentView;
32
+
}>) {
33
+
const galleryRkey = gallery ? new AtUri(gallery.uri).rkey : undefined;
34
+
const profile = gallery?.creator;
35
+
return (
36
+
<Dialog class="z-101">
37
+
<Dialog.Content class="gap-4">
38
+
<Dialog.Title>Add a comment</Dialog.Title>
39
+
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
40
+
<div class="divide-y divide-zinc-200 dark:divide-zinc-800 space-y-4">
41
+
<div class="flex gap-4 pb-4">
42
+
{!comment && profile
43
+
? <ActorAvatar profile={profile} size={42} />
44
+
: null}
45
+
{comment
46
+
? <ActorAvatar profile={comment.author} size={42} />
47
+
: null}
48
+
<div class="flex flex-col gap-2">
49
+
{profile
50
+
? <div class="font-semibold">{profile.displayName}</div>
51
+
: null}
52
+
{comment && comment.text}
53
+
{!comment && !photo && gallery &&
54
+
(gallery.record as Gallery).title}
55
+
{!comment && !photo && gallery
56
+
? (
57
+
<div class="w-[200px] pointer-events-none">
58
+
<GalleryPreviewLink
59
+
gallery={gallery}
60
+
size="small"
61
+
/>
62
+
</div>
63
+
)
64
+
: null}
65
+
{photo
66
+
? (
67
+
<div class="w-[200px] pointer-events-none">
68
+
<img src={photo.thumb} alt={photo.alt} class="rounded-md" />
69
+
</div>
70
+
)
71
+
: null}
72
+
</div>
73
+
</div>
74
+
<form
75
+
id="reply-form"
76
+
class="flex gap-4"
77
+
hx-post={`/actions/comments/${gallery?.creator.did}/gallery/${galleryRkey}`}
78
+
hx-target="#dialog-target"
79
+
hx-swap="innerHTML"
80
+
_="on htmx:afterOnLoad
81
+
if event.detail.xhr.status != 200
82
+
alert('Error: ' + event.detail.xhr.responseText)"
83
+
>
84
+
<ActorAvatar profile={userProfile} size={42} />
85
+
{!comment && photo
86
+
? <input type="hidden" name="focus" value={photo.uri} />
87
+
: null}
88
+
{comment
89
+
? <input type="hidden" name="replyTo" value={comment.uri} />
90
+
: null}
91
+
<Textarea
92
+
class="flex-1"
93
+
name="text"
94
+
placeholder="Add a comment"
95
+
rows={5}
96
+
autoFocus
97
+
/>
98
+
</form>
99
+
</div>
100
+
<div class="flex flex-col gap-2">
101
+
<Button type="submit" form="reply-form" variant="primary">
102
+
Reply
103
+
</Button>
104
+
<Dialog.Close variant="secondary">Cancel</Dialog.Close>
105
+
</div>
106
+
</Dialog.Content>
107
+
</Dialog>
108
+
);
109
+
}
110
+
111
+
export function GalleryCommentsDialog(
112
+
{ userProfile, comments, gallery }: Readonly<{
113
+
userProfile: ProfileView;
114
+
comments: CommentView[];
115
+
gallery: GalleryView;
116
+
}>,
117
+
) {
118
+
const { topLevel, repliesByParent } = groupComments(comments);
119
+
return (
120
+
<Dialog>
121
+
<Dialog.Content class="flex flex-col max-h-[90vh] overflow-hidden">
122
+
<div>
123
+
<Dialog.Title>Comments</Dialog.Title>
124
+
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
125
+
</div>
126
+
<div>
127
+
<div class="flex gap-4 pb-4 border-b border-zinc-200 dark:border-zinc-800">
128
+
{gallery.creator
129
+
? <ActorAvatar profile={gallery.creator} size={42} />
130
+
: null}
131
+
<div class="flex flex-col gap-2">
132
+
{gallery.creator
133
+
? <div class="font-semibold">{gallery.creator.displayName}</div>
134
+
: null}
135
+
{(gallery.record as Gallery).title}
136
+
<div class="w-[200px] pointer-events-none">
137
+
<GalleryPreviewLink
138
+
gallery={gallery}
139
+
size="small"
140
+
/>
141
+
</div>
142
+
</div>
143
+
</div>
144
+
<div class="py-1 border-b border-zinc-200 dark:border-zinc-800">
145
+
{gallery
146
+
? (
147
+
<ReplyButton
148
+
class="w-full bg-zinc-100 dark:bg-zinc-800 sm:bg-transparent dark:sm:bg-transparent sm:hover:bg-zinc-100 dark:sm:hover:bg-zinc-800"
149
+
userProfile={userProfile}
150
+
gallery={gallery}
151
+
/>
152
+
)
153
+
: null}
154
+
</div>
155
+
</div>
156
+
{topLevel && topLevel.length > 0
157
+
? (
158
+
<div class="flex-1 flex flex-col py-4 gap-6 overflow-y-scroll grain-scroll-area">
159
+
{topLevel.map((comment) => (
160
+
<div key={comment.cid} class="flex flex-col gap-4">
161
+
<CommentBlock comment={comment} />
162
+
163
+
{repliesByParent.get(comment.uri)?.map((reply) => (
164
+
<div key={reply.cid} class="ml-6">
165
+
<CommentBlock comment={reply} />
166
+
</div>
167
+
))}
168
+
</div>
169
+
))}
170
+
</div>
171
+
)
172
+
: <div class="py-4">No comments yet.</div>}
173
+
<div class="pt-2 border-t border-zinc-200 dark:border-zinc-800">
174
+
<Dialog.Close
175
+
variant="secondary"
176
+
class="w-full"
177
+
>
178
+
Close
179
+
</Dialog.Close>
180
+
</div>
181
+
</Dialog.Content>
182
+
</Dialog>
183
+
);
184
+
}
185
+
186
+
function CommentBlock({ comment }: Readonly<{ comment: CommentView }>) {
187
+
const gallery = isGalleryView(comment.subject) ? comment.subject : undefined;
188
+
const rkey = gallery ? new AtUri(gallery.uri).rkey : undefined;
189
+
return (
190
+
<div class="flex gap-3 items-start">
191
+
<div class="flex flex-col flex-1 min-w-0">
192
+
<div class="flex items-center gap-2 min-w-0 text-sm text-zinc-500">
193
+
<ActorInfo profile={comment.author} avatarSize={22} />
194
+
<span class="shrink-0">·</span>
195
+
<span class="shrink-0">
196
+
{comment.createdAt
197
+
? formatRelativeTime(new Date(comment.createdAt))
198
+
: ""}
199
+
</span>
200
+
</div>
201
+
202
+
<div class="mt-1">{comment.text}</div>
203
+
204
+
{isPhotoView(comment.focus) && (
205
+
<img
206
+
src={comment.focus.thumb}
207
+
alt={comment.focus.alt}
208
+
class="mt-2 rounded-md max-w-[200px] max-h-[150px] object-contain w-fit"
209
+
/>
210
+
)}
211
+
212
+
{!comment.replyTo
213
+
? (
214
+
<button
215
+
type="button"
216
+
class="w-fit p-0 mt-2 cursor-pointer text-zinc-600 dark:text-zinc-500 font-semibold text-sm"
217
+
hx-get={`/ui/comments/${gallery?.creator.did}/gallery/${rkey}/reply?comment=${
218
+
encodeURIComponent(comment.uri)
219
+
}`}
220
+
hx-trigger="click"
221
+
hx-target="#dialog-target"
222
+
hx-swap="innerHTML"
223
+
>
224
+
Reply
225
+
</button>
226
+
)
227
+
: null}
228
+
</div>
229
+
</div>
230
+
);
231
+
}
232
+
233
+
export function CommentsButton(
234
+
{ class: classProp, variant, gallery }: Readonly<{
235
+
class?: string;
236
+
variant: "button" | "icon-button";
237
+
gallery: GalleryView;
238
+
}>,
239
+
) {
240
+
const variantClass = variant === "icon-button"
241
+
? "flex w-fit items-center gap-2 m-0 p-0 mt-2"
242
+
: undefined;
243
+
const rkey = new AtUri(gallery.uri).rkey;
244
+
return (
245
+
<Button
246
+
type="button"
247
+
variant={variant === "icon-button" ? "ghost" : "secondary"}
248
+
class={cn("whitespace-nowrap", variantClass, classProp)}
249
+
hx-get={`/ui/comments/${gallery.creator.did}/gallery/${rkey}`}
250
+
hx-trigger="click"
251
+
hx-target="#dialog-target"
252
+
hx-swap="innerHTML"
253
+
>
254
+
<i class="fa-regular fa-comment" /> {gallery.commentCount ?? 0}
255
+
</Button>
256
+
);
257
+
}
258
+
259
+
export function ReplyButton(
260
+
{ class: classProp, userProfile, gallery, photo }: Readonly<{
261
+
class?: string;
262
+
userProfile: ProfileView;
263
+
gallery: GalleryView;
264
+
photo?: PhotoView;
265
+
}>,
266
+
) {
267
+
const rkey = new AtUri(gallery.uri).rkey;
268
+
return (
269
+
<button
270
+
type="button"
271
+
class={cn(
272
+
"flex items-center gap-4 p-3 rounded-full cursor-pointer",
273
+
classProp,
274
+
)}
275
+
hx-get={`/ui/comments/${gallery.creator.did}/gallery/${rkey}/reply${
276
+
photo ? `?photo=${encodeURIComponent(photo.uri)}` : ""
277
+
}`}
278
+
hx-trigger="click"
279
+
hx-target="#dialog-target"
280
+
hx-swap="innerHTML"
281
+
_="on click halt"
282
+
>
283
+
<ActorAvatar profile={userProfile} size={22} />
284
+
Add a comment
285
+
</button>
286
+
);
287
+
}
288
+
289
+
export const middlewares: BffMiddleware[] = [
290
+
// Actions
291
+
route(
292
+
"/actions/comments/:creatorDid/gallery/:rkey",
293
+
["POST"],
294
+
async (req, params, ctx) => {
295
+
const { did } = ctx.requireAuth();
296
+
const profile = getActorProfile(did, ctx);
297
+
if (!profile) return ctx.next();
298
+
299
+
const creatorDid = params.creatorDid;
300
+
const rkey = params.rkey;
301
+
302
+
const gallery = getGallery(creatorDid, rkey, ctx);
303
+
if (!gallery) return ctx.next();
304
+
305
+
const form = await req.formData();
306
+
const text = form.get("text") as string;
307
+
const focus = form.get("focus") as string;
308
+
const replyTo = form.get("replyTo") as string;
309
+
310
+
if (typeof text !== "string" || text.length === 0) {
311
+
return new Response("Text is required", { status: 400 });
312
+
}
313
+
314
+
try {
315
+
await ctx.createRecord<WithBffMeta<Comment>>(
316
+
"social.grain.comment",
317
+
{
318
+
text,
319
+
subject: gallery.uri,
320
+
focus: focus ?? undefined,
321
+
replyTo: replyTo ?? undefined,
322
+
createdAt: new Date().toISOString(),
323
+
},
324
+
);
325
+
} catch (error) {
326
+
console.error("Error creating comment:", error);
327
+
}
328
+
329
+
const comments = getGalleryComments(gallery.uri, ctx);
330
+
331
+
return ctx.html(
332
+
<GalleryCommentsDialog
333
+
userProfile={profile}
334
+
comments={comments}
335
+
gallery={gallery}
336
+
/>,
337
+
);
338
+
},
339
+
),
340
+
341
+
// UI
342
+
route(
343
+
"/ui/comments/:creatorDid/gallery/:rkey",
344
+
(_req, params, ctx) => {
345
+
const { did } = ctx.requireAuth();
346
+
const profile = getActorProfile(did, ctx);
347
+
if (!profile) return ctx.next();
348
+
const creatorDid = params.creatorDid;
349
+
const rkey = params.rkey;
350
+
const gallery = getGallery(creatorDid, rkey, ctx);
351
+
if (!gallery) return ctx.next();
352
+
const comments = getGalleryComments(gallery.uri, ctx);
353
+
return ctx.html(
354
+
<GalleryCommentsDialog
355
+
userProfile={profile}
356
+
comments={comments}
357
+
gallery={gallery}
358
+
/>,
359
+
);
360
+
},
361
+
),
362
+
route(
363
+
"/ui/comments/:creatorDid/gallery/:rkey/reply",
364
+
(req, params, ctx) => {
365
+
const { did } = ctx.requireAuth();
366
+
const profile = getActorProfile(did, ctx);
367
+
if (!profile) return ctx.next();
368
+
const url = new URL(req.url);
369
+
const photoUri = url.searchParams.get("photo");
370
+
const commentUri = url.searchParams.get("comment");
371
+
if (commentUri) {
372
+
const comment = getComment(commentUri, ctx);
373
+
if (comment) {
374
+
const gallery = isGalleryView(comment.subject)
375
+
? comment.subject
376
+
: undefined;
377
+
const photo = isPhotoView(comment.focus) ? comment.focus : undefined;
378
+
return ctx.html(
379
+
<ReplyDialog
380
+
userProfile={profile}
381
+
comment={comment}
382
+
gallery={gallery}
383
+
photo={photo}
384
+
/>,
385
+
);
386
+
}
387
+
}
388
+
const creatorDid = params.creatorDid;
389
+
const rkey = params.rkey;
390
+
let photo: PhotoView | undefined;
391
+
if (photoUri) {
392
+
const p = getPhoto(photoUri, ctx);
393
+
photo = p ?? undefined;
394
+
}
395
+
const gallery = getGallery(creatorDid, rkey, ctx);
396
+
if (!gallery) return ctx.next();
397
+
return ctx.html(
398
+
<ReplyDialog userProfile={profile} photo={photo} gallery={gallery} />,
399
+
);
400
+
},
401
+
),
402
+
];
403
+
404
+
function groupComments(comments: CommentView[]) {
405
+
const repliesByParent = new Map<string, CommentView[]>();
406
+
const topLevel: CommentView[] = [];
407
+
408
+
for (const comment of comments) {
409
+
if (comment.replyTo) {
410
+
if (!repliesByParent.has(comment.replyTo)) {
411
+
repliesByParent.set(comment.replyTo, []);
412
+
}
413
+
repliesByParent.get(comment.replyTo)!.push(comment);
414
+
} else {
415
+
topLevel.push(comment);
416
+
}
417
+
}
418
+
419
+
return { topLevel, repliesByParent };
420
+
}
421
+
422
+
export function getGalleryCommentsCount(uri: string, ctx: BffContext): number {
423
+
return ctx.indexService.countRecords(
424
+
"social.grain.comment",
425
+
{
426
+
where: {
427
+
"AND": [{ field: "subject", equals: uri }],
428
+
},
429
+
limit: 0,
430
+
},
431
+
);
432
+
}
433
+
434
+
function getGalleryComments(uri: string, ctx: BffContext): CommentView[] {
435
+
const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>(
436
+
"social.grain.comment",
437
+
{
438
+
orderBy: [{ field: "createdAt", direction: "desc" }],
439
+
where: {
440
+
"AND": [{ field: "subject", equals: uri }],
441
+
},
442
+
limit: 100,
443
+
},
444
+
);
445
+
446
+
// Batch fetch all authors, subjects, and focus photos for comments using bulk functions
447
+
const authorDids = Array.from(new Set(comments.map((c) => c.did)));
448
+
const subjectUris = Array.from(new Set(comments.map((c) => c.subject)));
449
+
const focusUris: string[] = Array.from(
450
+
new Set(
451
+
comments.map((c) => typeof c.focus === "string" ? c.focus : undefined)
452
+
.filter((uri): uri is string => !!uri),
453
+
),
454
+
);
455
+
456
+
const authorProfiles = getActorProfilesBulk(authorDids, ctx);
457
+
const authorMap = new Map(authorProfiles.map((p) => [p.did, p]));
458
+
const subjectViews = getGalleriesBulk(subjectUris, ctx);
459
+
const subjectMap = new Map(subjectViews.map((g) => [g.uri, g]));
460
+
const focusViews = getPhotosBulk(focusUris, ctx);
461
+
const focusMap = new Map(focusViews.map((p) => [p.uri, p]));
462
+
463
+
return comments.reduce<CommentView[]>((acc, comment) => {
464
+
const author = authorMap.get(comment.did);
465
+
if (!author) return acc;
466
+
const subject = subjectMap.get(comment.subject);
467
+
if (!subject) return acc;
468
+
let focus: PhotoView | undefined = undefined;
469
+
if (comment.focus) {
470
+
focus = focusMap.get(comment.focus);
471
+
}
472
+
acc.push(commentToView(comment, author, subject, focus));
473
+
return acc;
474
+
}, []);
475
+
}
476
+
477
+
function getComment(uri: string, ctx: BffContext) {
478
+
const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>(
479
+
"social.grain.comment",
480
+
{
481
+
where: [{ field: "uri", equals: uri }],
482
+
},
483
+
);
484
+
if (comments.length === 0) return undefined;
485
+
const comment = comments[0];
486
+
const author = getActorProfile(comment.did, ctx);
487
+
if (!author) return undefined;
488
+
const subjectDid = new AtUri(comment.subject).hostname;
489
+
const subjectRkey = new AtUri(comment.subject).rkey;
490
+
const subject = getGallery(subjectDid, subjectRkey, ctx);
491
+
if (!subject) return undefined;
492
+
let focus: PhotoView | undefined = undefined;
493
+
if (comment.focus) {
494
+
focus = getPhoto(comment.focus, ctx) ?? undefined;
495
+
}
496
+
return commentToView(comment, author, subject, focus);
497
+
}
498
+
499
+
function commentToView(
500
+
record: WithBffMeta<Comment>,
501
+
author: ProfileView,
502
+
subject?: GalleryView,
503
+
focus?: PhotoView,
504
+
): CommentView {
505
+
return {
506
+
uri: record.uri,
507
+
cid: record.cid,
508
+
text: record.text,
509
+
subject: isGalleryView(subject) ? subject : undefined,
510
+
focus: isPhotoView(focus) ? focus : undefined,
511
+
replyTo: record.replyTo,
512
+
author,
513
+
createdAt: record.createdAt,
514
+
};
515
+
}
+7
src/routes/dialogs.tsx
+7
src/routes/dialogs.tsx
···
1
+
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
1
2
import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts";
2
3
import { Record as Photo } from "$lexicon/types/social/grain/photo.ts";
3
4
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
···
134
135
const next = wrap(0, gallery.items.length, imageAtIndex + 1);
135
136
const prev = wrap(0, gallery.items.length, imageAtIndex - 1);
136
137
if (!image) return ctx.next();
138
+
let userProfile: ProfileView | undefined;
139
+
if (ctx.currentUser) {
140
+
const profile = getActorProfile(ctx.currentUser.did, ctx);
141
+
userProfile = profile ?? undefined;
142
+
}
137
143
return ctx.html(
138
144
<PhotoDialog
145
+
userProfile={userProfile}
139
146
gallery={gallery}
140
147
image={image}
141
148
nextImage={gallery.items.filter(isPhotoView).at(next)}
+34
-15
src/routes/notifications.tsx
+34
-15
src/routes/notifications.tsx
···
1
+
import { Record as Comment } from "$lexicon/types/social/grain/comment.ts";
1
2
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
2
3
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
3
4
import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts";
5
+
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
4
6
import { Un$Typed } from "$lexicon/util.ts";
5
-
import { AtUri } from "@atproto/syntax";
6
7
import { BffContext, RouteHandler } from "@bigmoves/bff";
7
8
import { NotificationsPage } from "../components/NotificationsPage.tsx";
8
-
import { getGallery } from "../lib/gallery.ts";
9
+
import { getGalleriesBulk } from "../lib/gallery.ts";
10
+
import { getPhotosBulk } from "../lib/photo.ts";
9
11
import type { State } from "../state.ts";
10
12
11
13
export const handler: RouteHandler = (
···
17
19
ctx.state.meta = [
18
20
{ title: "Notifications — Grain" },
19
21
];
22
+
const galleryUris = getGalleriesUrisForNotifications(
23
+
ctx.state.notifications ?? [],
24
+
);
25
+
const galleries = getGalleriesBulk(galleryUris, ctx);
20
26
const galleriesMap = new Map<string, GalleryView>();
21
-
const galleryUris = getGalleriesUrisForNotifications(
27
+
for (const gallery of galleries) {
28
+
galleriesMap.set(gallery.uri, gallery);
29
+
}
30
+
const photoUris = getPhotoUrisForNotifications(
22
31
ctx.state.notifications ?? [],
23
32
);
24
-
for (const uri of galleryUris) {
25
-
const gallery = getGallery(
26
-
new AtUri(uri).hostname,
27
-
new AtUri(uri).rkey,
28
-
ctx,
29
-
);
30
-
if (gallery) {
31
-
galleriesMap.set(uri, gallery);
32
-
}
33
+
const photos = getPhotosBulk(photoUris, ctx);
34
+
const photosMap = new Map<string, PhotoView>();
35
+
for (const photo of photos) {
36
+
photosMap.set(photo.uri, photo);
33
37
}
34
38
return ctx.render(
35
39
<NotificationsPage
40
+
photosMap={photosMap}
36
41
galleriesMap={galleriesMap}
37
42
notifications={ctx.state.notifications ?? []}
38
43
/>,
39
44
);
40
45
};
46
+
47
+
type WithSubject = Favorite | Comment;
41
48
42
49
function getGalleriesUrisForNotifications(
43
50
notifications: Un$Typed<NotificationView>[],
44
51
): string[] {
45
52
const uris = notifications
46
-
.filter((n) => n.record.$type === "social.grain.favorite")
47
53
.filter((n) =>
48
-
(n.record as Favorite).subject.includes("social.grain.gallery")
54
+
n.record.$type === "social.grain.favorite" ||
55
+
n.record.$type === "social.grain.comment"
49
56
)
50
-
.map((n) => (n.record as Favorite).subject);
57
+
.filter((n) =>
58
+
(n.record as WithSubject).subject.includes("social.grain.gallery")
59
+
)
60
+
.map((n) => (n.record as WithSubject).subject);
51
61
return uris;
52
62
}
63
+
64
+
function getPhotoUrisForNotifications(
65
+
notifications: Un$Typed<NotificationView>[],
66
+
): string[] {
67
+
return notifications
68
+
.filter((n) => n.record.$type === "social.grain.comment")
69
+
.map((n) => (n.record as Comment).focus)
70
+
.filter((focus): focus is string => typeof focus === "string" && !!focus);
71
+
}
+4
-4
sync.sh
+4
-4
sync.sh
···
2
2
3
3
# Helpful when running local-infra. Specify the repos you've created on a local pds instance.
4
4
5
-
DB="backup-2025-06-18.db"
6
-
REPOS=""
7
-
COLLECTIONS="social.grain.gallery,social.grain.actor.profile,social.grain.photo,social.grain.favorite,social.grain.gallery.item,social.grain.graph.follow,social.grain.photo.exif"
5
+
DB="grain.db"
6
+
REPOS="did:plc:gdvspmipkels2qp43m4czqhp"
7
+
COLLECTIONS="social.grain.gallery,social.grain.actor.profile,social.grain.photo,social.grain.favorite,social.grain.gallery.item,social.grain.graph.follow,social.grain.photo.exif,social.grain.comment"
8
8
EXTERNAL_COLLECTIONS="app.bsky.actor.profile,app.bsky.graph.follow,sh.tangled.graph.follow,sh.tangled.actor.profile"
9
-
COLLECTION_KEY_MAP='{"social.grain.favorite":["subject"],"social.grain.graph.follow":["subject"],"social.grain.gallery.item":["gallery","item"],"social.grain.photo.exif":["photo"]}'
9
+
COLLECTION_KEY_MAP='{"social.grain.favorite":["subject"],"social.grain.graph.follow":["subject"],"social.grain.gallery.item":["gallery","item"],"social.grain.photo.exif":["photo"],"social.grain.comment":["subject"]}'
10
10
11
11
deno run -A --env=.env jsr:@bigmoves/bff-cli@0.3.0-beta.40 sync \
12
12
--db="$DB" \