+27
__generated__/lexicons.ts
+27
__generated__/lexicons.ts
···
2450
'gallery-favorite',
2451
'gallery-comment',
2452
'reply',
2453
'unknown',
2454
],
2455
},
···
2490
author: {
2491
type: 'ref',
2492
ref: 'lex:social.grain.actor.defs#profileView',
2493
},
2494
text: {
2495
type: 'string',
···
2537
type: 'string',
2538
maxLength: 3000,
2539
maxGraphemes: 300,
2540
},
2541
subject: {
2542
type: 'string',
···
2676
type: 'string',
2677
maxLength: 1000,
2678
},
2679
labels: {
2680
type: 'union',
2681
description:
2682
'Self-label values for this post. Effectively content warnings.',
2683
refs: ['lex:com.atproto.label.defs#selfLabels'],
2684
},
2685
createdAt: {
2686
type: 'string',
···
2450
'gallery-favorite',
2451
'gallery-comment',
2452
'reply',
2453
+
'gallery-mention',
2454
+
'gallery-comment-mention',
2455
'unknown',
2456
],
2457
},
···
2492
author: {
2493
type: 'ref',
2494
ref: 'lex:social.grain.actor.defs#profileView',
2495
+
},
2496
+
record: {
2497
+
type: 'unknown',
2498
},
2499
text: {
2500
type: 'string',
···
2542
type: 'string',
2543
maxLength: 3000,
2544
maxGraphemes: 300,
2545
+
},
2546
+
facets: {
2547
+
type: 'array',
2548
+
description:
2549
+
'Annotations of description text (mentions and URLs, hashtags, etc)',
2550
+
items: {
2551
+
type: 'ref',
2552
+
ref: 'lex:app.bsky.richtext.facet',
2553
+
},
2554
},
2555
subject: {
2556
type: 'string',
···
2690
type: 'string',
2691
maxLength: 1000,
2692
},
2693
+
facets: {
2694
+
type: 'array',
2695
+
description:
2696
+
'Annotations of description text (mentions, URLs, hashtags, etc)',
2697
+
items: {
2698
+
type: 'ref',
2699
+
ref: 'lex:app.bsky.richtext.facet',
2700
+
},
2701
+
},
2702
labels: {
2703
type: 'union',
2704
description:
2705
'Self-label values for this post. Effectively content warnings.',
2706
refs: ['lex:com.atproto.label.defs#selfLabels'],
2707
+
},
2708
+
updatedAt: {
2709
+
type: 'string',
2710
+
format: 'datetime',
2711
},
2712
createdAt: {
2713
type: 'string',
+2
-1
deno.json
+2
-1
deno.json
···
1
{
2
"imports": {
3
"$lexicon/": "./__generated__/",
4
+
"@atproto/api": "npm:@atproto/api@^0.15.16",
5
"@atproto/syntax": "npm:@atproto/syntax@^0.4.0",
6
+
"@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.41",
7
"@std/http": "jsr:@std/http@^1.0.17",
8
"@std/path": "jsr:@std/path@^1.0.9",
9
"@tailwindcss/cli": "npm:@tailwindcss/cli@^4.1.4",
+28
-15
deno.lock
+28
-15
deno.lock
···
2
"version": "5",
3
"specifiers": {
4
"jsr:@bigmoves/atproto-oauth-client@0.2": "0.2.0",
5
-
"jsr:@bigmoves/bff@0.3.0-beta.40": "0.3.0-beta.40",
6
"jsr:@deno/gfm@0.10": "0.10.0",
7
"jsr:@denosaurs/emoji@0.3": "0.3.1",
8
"jsr:@luca/esbuild-deno-loader@~0.11.1": "0.11.1",
···
32
"jsr:@std/path@^1.1.0": "1.1.0",
33
"jsr:@std/streams@^1.0.10": "1.0.10",
34
"jsr:@std/testing@^1.0.11": "1.0.11",
35
-
"npm:@atproto-labs/handle-resolver-node@~0.1.14": "0.1.16",
36
"npm:@atproto-labs/simple-store@~0.1.2": "0.1.2",
37
-
"npm:@atproto/api@~0.15.7": "0.15.15",
38
"npm:@atproto/common@~0.4.10": "0.4.11",
39
"npm:@atproto/identity@~0.4.7": "0.4.8",
40
"npm:@atproto/jwk@0.1.4": "0.1.4",
···
47
"npm:@atproto/syntax@0.4": "0.4.0",
48
"npm:@atproto/xrpc-server@*": "0.7.19",
49
"npm:@atproto/xrpc-server@0.7.18": "0.7.18",
50
"npm:@tailwindcss/cli@*": "4.1.9",
51
"npm:@tailwindcss/cli@^4.0.12": "4.1.9",
52
"npm:@tailwindcss/cli@^4.1.3": "4.1.9",
···
92
"npm:jose"
93
]
94
},
95
-
"@bigmoves/bff@0.3.0-beta.40": {
96
-
"integrity": "826055f189da5fafb53011ad393e44f605aa5bfb7bc5b016b8663e5c922d7abd",
97
"dependencies": [
98
"jsr:@bigmoves/atproto-oauth-client",
99
"jsr:@std/assert@^1.0.13",
···
101
"jsr:@std/fmt",
102
"jsr:@std/http@^1.0.13",
103
"jsr:@std/path@^1.0.8",
104
-
"npm:@atproto/api",
105
"npm:@atproto/common",
106
"npm:@atproto/identity",
107
"npm:@atproto/lexicon@0.4.11",
108
"npm:@atproto/lexicon@~0.4.11",
109
"npm:@atproto/oauth-client",
110
"npm:@atproto/syntax",
111
-
"npm:@atproto/xrpc-server@0.7.18",
112
"npm:clsx",
113
"npm:multiformats@^13.3.2",
114
"npm:preact",
···
256
"@atproto-labs/pipe"
257
]
258
},
259
-
"@atproto-labs/handle-resolver-node@0.1.16": {
260
-
"integrity": "sha512-i2F989zjyC7b/odrV3/tOpIT1IDIxR3F0khPG4REfOWcmJ89QcP8BiejJ6KFJk3hbTJHq6X9/pTG1vesCvyIKA==",
261
"dependencies": [
262
"@atproto-labs/fetch-node",
263
-
"@atproto-labs/handle-resolver",
264
"@atproto/did"
265
]
266
},
···
273
"zod"
274
]
275
},
276
"@atproto-labs/identity-resolver@0.1.18": {
277
"integrity": "sha512-DArYXP1hzZJIBcojun0CWEF+TjAhlGKcVq/RwLiGfY1mKq2yPjCiXyHj+5L0+z9jBSZiAB7L65JgcjI2+MFiRg==",
278
"dependencies": [
279
"@atproto-labs/did-resolver",
280
-
"@atproto-labs/handle-resolver",
281
"@atproto/syntax"
282
]
283
},
···
297
"@atproto-labs/simple-store@0.2.0": {
298
"integrity": "sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA=="
299
},
300
-
"@atproto/api@0.15.15": {
301
-
"integrity": "sha512-Wn8jv76pCvffnkNj68w0CGZ3PT4DJGM8DUZnYq9kEW2im6jbRBYI0yYrHNhSiE92A5Ox0HjL2jMhalsI2p9VlQ==",
302
"dependencies": [
303
"@atproto/common-web",
304
"@atproto/lexicon",
···
394
"dependencies": [
395
"@atproto-labs/did-resolver",
396
"@atproto-labs/fetch",
397
-
"@atproto-labs/handle-resolver",
398
"@atproto-labs/identity-resolver",
399
"@atproto-labs/simple-store@0.2.0",
400
"@atproto-labs/simple-store-memory",
···
2021
},
2022
"workspace": {
2023
"dependencies": [
2024
-
"jsr:@bigmoves/bff@0.3.0-beta.40",
2025
"jsr:@std/http@^1.0.17",
2026
"jsr:@std/path@^1.0.9",
2027
"npm:@atproto/syntax@0.4",
2028
"npm:@tailwindcss/cli@^4.1.4",
2029
"npm:date-fns@^4.1.0",
···
2
"version": "5",
3
"specifiers": {
4
"jsr:@bigmoves/atproto-oauth-client@0.2": "0.2.0",
5
+
"jsr:@bigmoves/bff@0.3.0-beta.41": "0.3.0-beta.41",
6
"jsr:@deno/gfm@0.10": "0.10.0",
7
"jsr:@denosaurs/emoji@0.3": "0.3.1",
8
"jsr:@luca/esbuild-deno-loader@~0.11.1": "0.11.1",
···
32
"jsr:@std/path@^1.1.0": "1.1.0",
33
"jsr:@std/streams@^1.0.10": "1.0.10",
34
"jsr:@std/testing@^1.0.11": "1.0.11",
35
+
"npm:@atproto-labs/handle-resolver-node@~0.1.14": "0.1.18",
36
"npm:@atproto-labs/simple-store@~0.1.2": "0.1.2",
37
+
"npm:@atproto/api@*": "0.15.16",
38
+
"npm:@atproto/api@~0.15.16": "0.15.16",
39
+
"npm:@atproto/api@~0.15.7": "0.15.16",
40
"npm:@atproto/common@~0.4.10": "0.4.11",
41
"npm:@atproto/identity@~0.4.7": "0.4.8",
42
"npm:@atproto/jwk@0.1.4": "0.1.4",
···
49
"npm:@atproto/syntax@0.4": "0.4.0",
50
"npm:@atproto/xrpc-server@*": "0.7.19",
51
"npm:@atproto/xrpc-server@0.7.18": "0.7.18",
52
+
"npm:@atproto/xrpc-server@0.7.19": "0.7.19",
53
"npm:@tailwindcss/cli@*": "4.1.9",
54
"npm:@tailwindcss/cli@^4.0.12": "4.1.9",
55
"npm:@tailwindcss/cli@^4.1.3": "4.1.9",
···
95
"npm:jose"
96
]
97
},
98
+
"@bigmoves/bff@0.3.0-beta.41": {
99
+
"integrity": "141414a26dcb44d6a08a8a259011e0a7ef01b104ff5664dafc380a6f0f9a22c0",
100
"dependencies": [
101
"jsr:@bigmoves/atproto-oauth-client",
102
"jsr:@std/assert@^1.0.13",
···
104
"jsr:@std/fmt",
105
"jsr:@std/http@^1.0.13",
106
"jsr:@std/path@^1.0.8",
107
+
"npm:@atproto/api@~0.15.7",
108
"npm:@atproto/common",
109
"npm:@atproto/identity",
110
"npm:@atproto/lexicon@0.4.11",
111
"npm:@atproto/lexicon@~0.4.11",
112
"npm:@atproto/oauth-client",
113
"npm:@atproto/syntax",
114
+
"npm:@atproto/xrpc-server@0.7.19",
115
"npm:clsx",
116
"npm:multiformats@^13.3.2",
117
"npm:preact",
···
259
"@atproto-labs/pipe"
260
]
261
},
262
+
"@atproto-labs/handle-resolver-node@0.1.18": {
263
+
"integrity": "sha512-/qo14c3I+kagT1UWSp3lTIzwDetfkxvF3Y3VlX2NyQ2jHwgtIAJ81KFNqe7t82NpQDjWiM5h4bdjvdbFIh5djQ==",
264
"dependencies": [
265
"@atproto-labs/fetch-node",
266
+
"@atproto-labs/handle-resolver@0.3.0",
267
"@atproto/did"
268
]
269
},
···
276
"zod"
277
]
278
},
279
+
"@atproto-labs/handle-resolver@0.3.0": {
280
+
"integrity": "sha512-TREelvXB6P2eHxx6QjINRkBzUZu/aXWrdY9iN57shQe3C8rzsHNEHHuTVvRa33Hc7vFdQbZN0TnCgKveoyiL/A==",
281
+
"dependencies": [
282
+
"@atproto-labs/simple-store@0.2.0",
283
+
"@atproto-labs/simple-store-memory",
284
+
"@atproto/did",
285
+
"zod"
286
+
]
287
+
},
288
"@atproto-labs/identity-resolver@0.1.18": {
289
"integrity": "sha512-DArYXP1hzZJIBcojun0CWEF+TjAhlGKcVq/RwLiGfY1mKq2yPjCiXyHj+5L0+z9jBSZiAB7L65JgcjI2+MFiRg==",
290
"dependencies": [
291
"@atproto-labs/did-resolver",
292
+
"@atproto-labs/handle-resolver@0.1.8",
293
"@atproto/syntax"
294
]
295
},
···
309
"@atproto-labs/simple-store@0.2.0": {
310
"integrity": "sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA=="
311
},
312
+
"@atproto/api@0.15.16": {
313
+
"integrity": "sha512-ZNBrzBg2l0lHreKik1lJn8lrhAktwlY8NUPBU/hO9dwjAnDHQTiSzNFZt65dp9djmqZ75sX/VJ+heNuaJBvnhQ==",
314
"dependencies": [
315
"@atproto/common-web",
316
"@atproto/lexicon",
···
406
"dependencies": [
407
"@atproto-labs/did-resolver",
408
"@atproto-labs/fetch",
409
+
"@atproto-labs/handle-resolver@0.1.8",
410
"@atproto-labs/identity-resolver",
411
"@atproto-labs/simple-store@0.2.0",
412
"@atproto-labs/simple-store-memory",
···
2033
},
2034
"workspace": {
2035
"dependencies": [
2036
+
"jsr:@bigmoves/bff@0.3.0-beta.41",
2037
"jsr:@std/http@^1.0.17",
2038
"jsr:@std/path@^1.0.9",
2039
+
"npm:@atproto/api@~0.15.16",
2040
"npm:@atproto/syntax@0.4",
2041
"npm:@tailwindcss/cli@^4.1.4",
2042
"npm:date-fns@^4.1.0",
+2
-1
lexicons.json
+2
-1
lexicons.json
+1
-1
src/components/GalleryDetailsDialog.tsx
+1
-1
src/components/GalleryDetailsDialog.tsx
+12
-2
src/components/GalleryInfo.tsx
+12
-2
src/components/GalleryInfo.tsx
···
3
import { getGalleryCameras } from "../lib/gallery.ts";
4
import { ActorInfo } from "./ActorInfo.tsx";
5
import { CameraBadges } from "./CameraBadges.tsx";
6
7
-
export function GalleryInfo({ gallery }: Readonly<{ gallery: GalleryView }>) {
8
const description = (gallery.record as Gallery).description;
9
const cameras = getGalleryCameras(gallery);
10
return (
11
<div
···
16
{(gallery.record as Gallery).title}
17
</h1>
18
<ActorInfo profile={gallery.creator} />
19
-
{description ? <p>{description}</p> : null}
20
<CameraBadges class="my-1" cameras={cameras} />
21
</div>
22
);
···
3
import { getGalleryCameras } from "../lib/gallery.ts";
4
import { ActorInfo } from "./ActorInfo.tsx";
5
import { CameraBadges } from "./CameraBadges.tsx";
6
+
import { RenderFacetedText } from "./RenderFacetedText.tsx";
7
8
+
export function GalleryInfo(
9
+
{ gallery }: Readonly<{ gallery: GalleryView }>,
10
+
) {
11
const description = (gallery.record as Gallery).description;
12
+
const facets = (gallery.record as Gallery).facets;
13
const cameras = getGalleryCameras(gallery);
14
return (
15
<div
···
20
{(gallery.record as Gallery).title}
21
</h1>
22
<ActorInfo profile={gallery.creator} />
23
+
{description
24
+
? (
25
+
<p>
26
+
<RenderFacetedText text={description} facets={facets} />
27
+
</p>
28
+
)
29
+
: null}
30
<CameraBadges class="my-1" cameras={cameras} />
31
</div>
32
);
+65
-17
src/components/NotificationsPage.tsx
+65
-17
src/components/NotificationsPage.tsx
···
1
import { Record as Comment } from "$lexicon/types/social/grain/comment.ts";
2
import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts";
3
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
4
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
5
import { Record as Follow } from "$lexicon/types/social/grain/graph/follow.ts";
6
import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts";
···
10
} from "$lexicon/types/social/grain/photo/defs.ts";
11
import { Un$Typed } from "$lexicon/util.ts";
12
import { AtUri } from "@atproto/syntax";
13
import { formatRelativeTime, galleryLink, profileLink } from "../utils.ts";
14
import { ActorAvatar } from "./ActorAvatar.tsx";
15
import { GalleryPreviewLink } from "./GalleryPreviewLink.tsx";
16
import { Header } from "./Header.tsx";
17
18
export function NotificationsPage(
19
{ photosMap, galleriesMap, notifications, commentsMap }: Readonly<
···
68
)}
69
</>
70
)}
71
{notification.reason === "reply" && (
72
<>
73
replied to your comment · {formatRelativeTime(
···
108
commentsMap={commentsMap}
109
/>
110
)}
111
</li>
112
))
113
)
···
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;
···
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;
···
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,
···
195
</a>
196
)
197
: (
198
-
<GalleryPreviewLink
199
-
class="mt-2"
200
-
gallery={gallery}
201
-
size="small"
202
-
/>
203
)}
204
</div>
205
)}
206
-
{comment.text}
207
{comment.focus
208
? (
209
<a
···
234
);
235
}
236
237
-
function GalleryFavoriteNotification({ notification, galleriesMap }: {
238
-
notification: NotificationView;
239
-
galleriesMap: Map<string, GalleryView>;
240
-
}) {
241
const favorite = notification.record as Favorite;
242
const gallery = galleriesMap.get(favorite.subject) as GalleryView | undefined;
243
if (!gallery) return null;
···
250
</div>
251
);
252
}
···
1
import { Record as Comment } from "$lexicon/types/social/grain/comment.ts";
2
import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts";
3
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
4
+
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
5
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
6
import { Record as Follow } from "$lexicon/types/social/grain/graph/follow.ts";
7
import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts";
···
11
} from "$lexicon/types/social/grain/photo/defs.ts";
12
import { Un$Typed } from "$lexicon/util.ts";
13
import { AtUri } from "@atproto/syntax";
14
+
import { WithBffMeta } from "@bigmoves/bff";
15
import { formatRelativeTime, galleryLink, profileLink } from "../utils.ts";
16
import { ActorAvatar } from "./ActorAvatar.tsx";
17
import { GalleryPreviewLink } from "./GalleryPreviewLink.tsx";
18
import { Header } from "./Header.tsx";
19
+
import { RenderFacetedText } from "./RenderFacetedText.tsx";
20
21
export function NotificationsPage(
22
{ photosMap, galleriesMap, notifications, commentsMap }: Readonly<
···
71
)}
72
</>
73
)}
74
+
{notification.reason === "gallery-mention" && (
75
+
<>
76
+
mentioned you in a gallery · {formatRelativeTime(
77
+
new Date((notification.record as Comment).createdAt),
78
+
)}
79
+
</>
80
+
)}
81
{notification.reason === "reply" && (
82
<>
83
replied to your comment · {formatRelativeTime(
···
118
commentsMap={commentsMap}
119
/>
120
)}
121
+
{notification.reason === "gallery-mention" &&
122
+
(
123
+
<GalleryMentionNotification
124
+
notification={notification}
125
+
galleriesMap={galleriesMap}
126
+
/>
127
+
)}
128
</li>
129
))
130
)
···
135
}
136
137
function GalleryCommentNotification(
138
+
{ notification, galleriesMap, photosMap }: Readonly<{
139
notification: NotificationView;
140
galleriesMap: Map<string, GalleryView>;
141
photosMap: Map<string, PhotoView>;
142
+
}>,
143
) {
144
const comment = notification.record as Comment;
145
const gallery = galleriesMap.get(comment.subject) as GalleryView | undefined;
···
176
}
177
178
function ReplyNotification(
179
+
{ notification, galleriesMap, photosMap, commentsMap }: Readonly<{
180
notification: NotificationView;
181
galleriesMap: Map<string, GalleryView>;
182
photosMap: Map<string, PhotoView>;
183
commentsMap: Map<string, CommentView>;
184
+
}>,
185
) {
186
const comment = notification.record as Comment;
187
const gallery = galleriesMap.get(comment.subject) as GalleryView | undefined;
···
193
return (
194
<>
195
{replyToComment && (
196
+
<div class="text-sm border-l-2 border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-500 pl-2">
197
+
<RenderFacetedText
198
+
text={replyToComment.text}
199
+
facets={(replyToComment.record as Comment).facets}
200
+
/>
201
{isPhotoView(replyToComment?.focus)
202
? (
203
<a
204
+
class="block mt-2 max-w-[200px]"
205
href={galleryLink(
206
gallery.creator.handle,
207
new AtUri(gallery.uri).rkey,
···
215
</a>
216
)
217
: (
218
+
<div class="mt-2 max-w-[200px]">
219
+
<GalleryPreviewLink
220
+
class="mt-2"
221
+
gallery={gallery}
222
+
size="small"
223
+
/>
224
+
</div>
225
)}
226
</div>
227
)}
228
+
<RenderFacetedText text={comment.text} facets={comment.facets} />
229
{comment.focus
230
? (
231
<a
···
256
);
257
}
258
259
+
function GalleryFavoriteNotification(
260
+
{ notification, galleriesMap }: Readonly<{
261
+
notification: NotificationView;
262
+
galleriesMap: Map<string, GalleryView>;
263
+
}>,
264
+
) {
265
const favorite = notification.record as Favorite;
266
const gallery = galleriesMap.get(favorite.subject) as GalleryView | undefined;
267
if (!gallery) return null;
···
274
</div>
275
);
276
}
277
+
278
+
function GalleryMentionNotification(
279
+
{ notification, galleriesMap }: Readonly<{
280
+
notification: NotificationView;
281
+
galleriesMap: Map<string, GalleryView>;
282
+
}>,
283
+
) {
284
+
const galleryRecord = notification.record as WithBffMeta<Gallery>;
285
+
const gallery = galleriesMap.get(galleryRecord.uri) as
286
+
| GalleryView
287
+
| undefined;
288
+
if (!gallery) return null;
289
+
return (
290
+
<div class="text-sm border-l-2 border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-500 pl-2">
291
+
{(gallery.record as Gallery).description}
292
+
<div class="mt-2 max-w-[200px]">
293
+
<GalleryPreviewLink
294
+
gallery={gallery}
295
+
size="small"
296
+
/>
297
+
</div>
298
+
</div>
299
+
);
300
+
}
+20
-1
src/components/ProfilePage.tsx
+20
-1
src/components/ProfilePage.tsx
···
4
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
5
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
6
import { Un$Typed } from "$lexicon/util.ts";
7
import { AtUri } from "@atproto/syntax";
8
import { LabelerPolicies } from "@bigmoves/bff";
9
import { getGalleryCameras } from "../lib/gallery.ts";
···
26
import { FollowButton } from "./FollowButton.tsx";
27
import { LabelDefinitionButton } from "./LabelDefinitionButton.tsx";
28
import { LabelerAvatar } from "./LabelerAvatar.tsx";
29
30
export type ProfileTabs = "favs" | "galleries" | "labels";
31
···
36
userProfiles,
37
loggedInUserDid,
38
profile,
39
selectedTab,
40
galleries,
41
galleryFavs,
···
50
actorProfiles: SocialNetwork[];
51
loggedInUserDid?: string;
52
profile: Un$Typed<ProfileView>;
53
selectedTab?: ProfileTabs;
54
galleries?: GalleryView[];
55
galleryFavs?: GalleryView[];
···
97
</>
98
)}
99
{profile.description
100
-
? <p class="mt-2 sm:max-w-[500px]">{profile.description}</p>
101
: null}
102
<p>
103
{userProfiles.includes("bluesky") && (
···
4
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
5
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
6
import { Un$Typed } from "$lexicon/util.ts";
7
+
import { Facet } from "@atproto/api";
8
import { AtUri } from "@atproto/syntax";
9
import { LabelerPolicies } from "@bigmoves/bff";
10
import { getGalleryCameras } from "../lib/gallery.ts";
···
27
import { FollowButton } from "./FollowButton.tsx";
28
import { LabelDefinitionButton } from "./LabelDefinitionButton.tsx";
29
import { LabelerAvatar } from "./LabelerAvatar.tsx";
30
+
import { RenderFacetedText } from "./RenderFacetedText.tsx";
31
32
export type ProfileTabs = "favs" | "galleries" | "labels";
33
···
38
userProfiles,
39
loggedInUserDid,
40
profile,
41
+
descriptionFacets,
42
selectedTab,
43
galleries,
44
galleryFavs,
···
53
actorProfiles: SocialNetwork[];
54
loggedInUserDid?: string;
55
profile: Un$Typed<ProfileView>;
56
+
descriptionFacets?: Facet[];
57
selectedTab?: ProfileTabs;
58
galleries?: GalleryView[];
59
galleryFavs?: GalleryView[];
···
101
</>
102
)}
103
{profile.description
104
+
? (
105
+
descriptionFacets
106
+
? (
107
+
<p class="mt-2 sm:max-w-[500px]">
108
+
<RenderFacetedText
109
+
text={profile.description}
110
+
facets={descriptionFacets}
111
+
/>
112
+
</p>
113
+
)
114
+
: (
115
+
<p class="mt-2 sm:max-w-[500px]">
116
+
{profile.description}
117
+
</p>
118
+
)
119
+
)
120
: null}
121
<p>
122
{userProfiles.includes("bluesky") && (
+57
src/components/RenderFacetedText.tsx
+57
src/components/RenderFacetedText.tsx
···
···
1
+
import { Facet, RichText } from "@atproto/api";
2
+
3
+
type Props = Readonly<{
4
+
text: string;
5
+
facets?: Facet[];
6
+
}>;
7
+
8
+
export function RenderFacetedText({ text, facets }: Props) {
9
+
const rt = new RichText({ text, facets });
10
+
return (
11
+
<>
12
+
{[...rt.segments()].map((segment) => {
13
+
const content = segment.text;
14
+
15
+
if (segment.isMention()) {
16
+
return (
17
+
<a
18
+
key={segment.mention?.did || segment.text}
19
+
href={`/profile/${segment.mention?.did}`}
20
+
class="text-sky-500 hover:underline"
21
+
>
22
+
{content}
23
+
</a>
24
+
);
25
+
}
26
+
27
+
if (segment.isLink()) {
28
+
return (
29
+
<a
30
+
key={segment.link?.uri || segment.text}
31
+
href={segment.link?.uri}
32
+
class="text-sky-500 underline"
33
+
target="_blank"
34
+
rel="noopener noreferrer"
35
+
>
36
+
{content}
37
+
</a>
38
+
);
39
+
}
40
+
41
+
if (segment.isTag() && segment.tag?.tag) {
42
+
return (
43
+
<a
44
+
key={segment.tag.tag}
45
+
href={`/hashtag/${segment.tag.tag}`}
46
+
class="text-sky-500 hover:underline"
47
+
>
48
+
#{segment.tag.tag}
49
+
</a>
50
+
);
51
+
}
52
+
53
+
return <span key={segment.text}>{content}</span>;
54
+
})}
55
+
</>
56
+
);
57
+
}
+5
-1
src/components/TimelineItem.tsx
+5
-1
src/components/TimelineItem.tsx
···
7
import { FavoriteButton } from "./FavoriteButton.tsx";
8
import { GalleryPreviewLink } from "./GalleryPreviewLink.tsx";
9
import { ModerationWrapper } from "./ModerationWrapper.tsx";
10
11
export function TimelineItem({ item }: Readonly<{ item: TimelineItem }>) {
12
const title = (item.gallery.record as Gallery).title;
13
const description = (item.gallery.record as Gallery).description;
14
return (
15
<li>
16
<div class="flex flex-col pb-4 max-w-md">
···
45
)}
46
{description && (
47
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-500">
48
-
{description}
49
</p>
50
)}
51
<div class="flex gap-4">
···
7
import { FavoriteButton } from "./FavoriteButton.tsx";
8
import { GalleryPreviewLink } from "./GalleryPreviewLink.tsx";
9
import { ModerationWrapper } from "./ModerationWrapper.tsx";
10
+
import { RenderFacetedText } from "./RenderFacetedText.tsx";
11
12
export function TimelineItem({ item }: Readonly<{ item: TimelineItem }>) {
13
const title = (item.gallery.record as Gallery).title;
14
const description = (item.gallery.record as Gallery).description;
15
+
const facets = (item.gallery.record as Gallery).facets;
16
return (
17
<li>
18
<div class="flex flex-col pb-4 max-w-md">
···
47
)}
48
{description && (
49
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-500">
50
+
{facets && facets.length > 0
51
+
? <RenderFacetedText text={description} facets={facets} />
52
+
: description}
53
</p>
54
)}
55
<div class="flex gap-4">
+2
-2
src/lib/actor.ts
+2
-2
src/lib/actor.ts
···
10
import { Un$Typed } from "$lexicon/util.ts";
11
import { BffContext, WithBffMeta } from "@bigmoves/bff";
12
import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts";
13
-
import { photoToView } from "./photo.ts";
14
import type { SocialNetwork } from "./timeline.ts";
15
16
export function getActorProfile(did: string, ctx: BffContext) {
···
32
displayName: record.displayName,
33
description: record.description,
34
avatar: record?.avatar
35
-
? `https://cdn.bsky.app/img/feed_thumbnail/plain/${record.did}/${record.avatar.ref.toString()}`
36
: undefined,
37
};
38
}
···
10
import { Un$Typed } from "$lexicon/util.ts";
11
import { BffContext, WithBffMeta } from "@bigmoves/bff";
12
import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts";
13
+
import { photoToView, photoUrl } from "./photo.ts";
14
import type { SocialNetwork } from "./timeline.ts";
15
16
export function getActorProfile(did: string, ctx: BffContext) {
···
32
displayName: record.displayName,
33
description: record.description,
34
avatar: record?.avatar
35
+
? photoUrl(record.did, record.avatar.ref.toString(), "thumbnail")
36
: undefined,
37
};
38
}
+40
src/lib/gallery.ts
+40
src/lib/gallery.ts
···
1
import { Label } from "$lexicon/types/com/atproto/label/defs.ts";
2
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
3
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
4
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
5
import {
···
291
items: galleryPhotosMap.get(gallery.uri) ?? [],
292
labels,
293
})
294
);
295
}
296
···
1
import { Label } from "$lexicon/types/com/atproto/label/defs.ts";
2
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
3
+
import { Record as Comment } from "$lexicon/types/social/grain/comment.ts";
4
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
5
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
6
import {
···
292
items: galleryPhotosMap.get(gallery.uri) ?? [],
293
labels,
294
})
295
+
);
296
+
}
297
+
298
+
export function getGalleryUrisByFacet(
299
+
type: string,
300
+
value: string,
301
+
ctx: BffContext,
302
+
) {
303
+
const { items: galleries } = ctx.indexService.getRecords<
304
+
WithBffMeta<Gallery>
305
+
>(
306
+
"social.grain.gallery",
307
+
{
308
+
facet: {
309
+
"type": type,
310
+
"value": value,
311
+
},
312
+
orderBy: [{ field: "createdAt", direction: "desc" }],
313
+
},
314
+
);
315
+
return galleries.map((g) => g.uri);
316
+
}
317
+
318
+
export function getGalleryUrisByCommentFacet(
319
+
type: string,
320
+
value: string,
321
+
ctx: BffContext,
322
+
) {
323
+
const { items: comments } = ctx.indexService.getRecords<Comment>(
324
+
"social.grain.comment",
325
+
{
326
+
facet: {
327
+
type,
328
+
value,
329
+
},
330
+
},
331
+
);
332
+
return comments.map((comment) => comment.subject).filter((uri) =>
333
+
uri.includes("social.grain.gallery")
334
);
335
}
336
+65
-14
src/lib/notifications.ts
+65
-14
src/lib/notifications.ts
···
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 { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
4
-
import { Record as Follow } from "$lexicon/types/social/grain/graph/follow.ts";
5
import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts";
6
import { Un$Typed } from "$lexicon/util.ts";
7
import { ActorTable, BffContext, WithBffMeta } from "@bigmoves/bff";
8
import { getActorProfile } from "./actor.ts";
9
10
-
export type NotificationRecords = WithBffMeta<Favorite | Follow | Comment>;
11
12
export function getNotifications(
13
-
currentUser: ActorTable,
14
ctx: BffContext,
15
) {
16
-
const { lastSeenNotifs } = currentUser;
17
const notifications = ctx.getNotifications<NotificationRecords>();
18
return notifications
19
.filter(
20
(notification) =>
21
notification.$type === "social.grain.favorite" ||
22
notification.$type === "social.grain.graph.follow" ||
23
-
notification.$type === "social.grain.comment",
24
)
25
.map((notification) => {
26
const actor = ctx.indexService.getActor(notification.did);
···
29
return notificationToView(
30
notification,
31
authorProfile,
32
-
lastSeenNotifs,
33
);
34
})
35
.filter((view): view is Un$Typed<NotificationView> => Boolean(view));
···
38
export function notificationToView(
39
record: NotificationRecords,
40
author: Un$Typed<ProfileView>,
41
-
lastSeenNotifs: string | undefined,
42
): Un$Typed<NotificationView> {
43
let reason: string;
44
-
if (record.$type === "social.grain.favorite") {
45
reason = "gallery-favorite";
46
-
} else if (record.$type === "social.grain.graph.follow") {
47
reason = "follow";
48
} else if (
49
-
record.$type === "social.grain.comment" &&
50
record.replyTo
51
) {
52
reason = "reply";
53
-
} else if (record.$type === "social.grain.comment") {
54
reason = "gallery-comment";
55
} else {
56
reason = "unknown";
57
}
58
const reasonSubject = record.$type === "social.grain.favorite"
59
? record.subject
60
: undefined;
61
-
const isRead = lastSeenNotifs ? record.createdAt <= lastSeenNotifs : false;
62
return {
63
uri: record.uri,
64
cid: record.cid,
···
70
indexedAt: record.indexedAt,
71
};
72
}
···
1
+
import {
2
+
isMention,
3
+
type Main as Facet,
4
+
} from "$lexicon/types/app/bsky/richtext/facet.ts";
5
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
6
+
import {
7
+
isRecord as isComment,
8
+
Record as Comment,
9
+
} from "$lexicon/types/social/grain/comment.ts";
10
+
import {
11
+
isRecord as isFavorite,
12
+
Record as Favorite,
13
+
} from "$lexicon/types/social/grain/favorite.ts";
14
+
import {
15
+
isRecord as isGallery,
16
+
Record as Gallery,
17
+
} from "$lexicon/types/social/grain/gallery.ts";
18
+
import {
19
+
isRecord as isFollow,
20
+
Record as Follow,
21
+
} from "$lexicon/types/social/grain/graph/follow.ts";
22
import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts";
23
import { Un$Typed } from "$lexicon/util.ts";
24
import { ActorTable, BffContext, WithBffMeta } from "@bigmoves/bff";
25
import { getActorProfile } from "./actor.ts";
26
27
+
export type NotificationRecords = WithBffMeta<
28
+
Favorite | Follow | Comment | Gallery
29
+
>;
30
31
export function getNotifications(
32
ctx: BffContext,
33
) {
34
const notifications = ctx.getNotifications<NotificationRecords>();
35
return notifications
36
.filter(
37
(notification) =>
38
notification.$type === "social.grain.favorite" ||
39
notification.$type === "social.grain.graph.follow" ||
40
+
notification.$type === "social.grain.comment" ||
41
+
notification.$type === "social.grain.gallery",
42
)
43
.map((notification) => {
44
const actor = ctx.indexService.getActor(notification.did);
···
47
return notificationToView(
48
notification,
49
authorProfile,
50
+
ctx.currentUser,
51
);
52
})
53
.filter((view): view is Un$Typed<NotificationView> => Boolean(view));
···
56
export function notificationToView(
57
record: NotificationRecords,
58
author: Un$Typed<ProfileView>,
59
+
currentUser?: ActorTable,
60
): Un$Typed<NotificationView> {
61
let reason: string;
62
+
if (isFavorite(record)) {
63
reason = "gallery-favorite";
64
+
} else if (isFollow(record)) {
65
reason = "follow";
66
} else if (
67
+
isComment(record) &&
68
record.replyTo
69
) {
70
reason = "reply";
71
+
// @TODO: check the nsid here if support other types of comments
72
+
} else if (isComment(record)) {
73
reason = "gallery-comment";
74
+
} else if (
75
+
isGallery(record) && recordHasMentionFacet(
76
+
record,
77
+
currentUser?.did,
78
+
)
79
+
) {
80
+
reason = "gallery-mention";
81
} else {
82
reason = "unknown";
83
}
84
const reasonSubject = record.$type === "social.grain.favorite"
85
? record.subject
86
: undefined;
87
+
const isRead = currentUser?.lastSeenNotifs
88
+
? record.createdAt <= currentUser.lastSeenNotifs
89
+
: false;
90
return {
91
uri: record.uri,
92
cid: record.cid,
···
98
indexedAt: record.indexedAt,
99
};
100
}
101
+
102
+
function recordHasMentionFacet(
103
+
record: NotificationRecords,
104
+
currentUserDid?: string,
105
+
): boolean {
106
+
if (
107
+
record.$type === "social.grain.gallery" &&
108
+
Array.isArray(record.facets)
109
+
) {
110
+
return record.facets.some((facet) => {
111
+
if (!currentUserDid) return true;
112
+
const features = (facet as Facet).features;
113
+
// Check if facet features contain the current user's DID
114
+
if (Array.isArray(features)) {
115
+
return features.filter(isMention).some(
116
+
(feature) => feature.did === currentUserDid,
117
+
);
118
+
}
119
+
return false;
120
+
});
121
+
}
122
+
return false;
123
+
}
+27
src/lib/rich_text.ts
+27
src/lib/rich_text.ts
···
···
1
+
import { AppBskyRichtextFacet, Facet, RichText } from "@atproto/api";
2
+
import { BffContext } from "@bigmoves/bff";
3
+
4
+
export function parseFacetedText(
5
+
text: string,
6
+
ctx: BffContext,
7
+
): { text: string; facets: RichText["facets"] } {
8
+
const rt = new RichText({ text });
9
+
rt.detectFacetsWithoutResolution();
10
+
if (rt.facets) {
11
+
for (const facet of rt.facets) {
12
+
for (const feature of facet.features) {
13
+
if (AppBskyRichtextFacet.isMention(feature)) {
14
+
const actor = ctx.indexService.getActorByHandle(feature.did);
15
+
if (actor) {
16
+
feature.did = actor.did;
17
+
} else {
18
+
rt.delete(facet.index.byteStart, facet.index.byteEnd);
19
+
}
20
+
}
21
+
}
22
+
}
23
+
}
24
+
return { text: rt.text, facets: rt.facets?.sort(facetSort) };
25
+
}
26
+
27
+
const facetSort = (a: Facet, b: Facet) => a.index.byteStart - b.index.byteStart;
+3
-1
src/main.tsx
+3
-1
src/main.tsx
···
12
import { handler as followersHandler } from "./routes/followers.tsx";
13
import { handler as followsHandler } from "./routes/follows.tsx";
14
import { handler as galleryHandler } from "./routes/gallery.tsx";
15
import * as legal from "./routes/legal.tsx";
16
import { handler as notificationsHandler } from "./routes/notifications.tsx";
17
import { handler as onboardHandler } from "./routes/onboard.tsx";
···
62
route("/", timelineHandler),
63
route("/explore", exploreHandler),
64
route("/notifications", notificationsHandler),
65
-
route("/profile/:handle", profileHandler),
66
route("/profile/:handle/followers", followersHandler),
67
route("/profile/:handle/follows", followsHandler),
68
route("/profile/:handle/gallery/:rkey", galleryHandler),
69
route("/upload", uploadHandler),
70
route("/onboard", onboardHandler),
71
route("/support", supportHandler),
···
12
import { handler as followersHandler } from "./routes/followers.tsx";
13
import { handler as followsHandler } from "./routes/follows.tsx";
14
import { handler as galleryHandler } from "./routes/gallery.tsx";
15
+
import { handler as hashtagHandler } from "./routes/hashtag.tsx";
16
import * as legal from "./routes/legal.tsx";
17
import { handler as notificationsHandler } from "./routes/notifications.tsx";
18
import { handler as onboardHandler } from "./routes/onboard.tsx";
···
63
route("/", timelineHandler),
64
route("/explore", exploreHandler),
65
route("/notifications", notificationsHandler),
66
+
route("/profile/:handleOrDid", profileHandler),
67
route("/profile/:handle/followers", followersHandler),
68
route("/profile/:handle/follows", followsHandler),
69
route("/profile/:handle/gallery/:rkey", galleryHandler),
70
+
route("/hashtag/:tag", hashtagHandler),
71
route("/upload", uploadHandler),
72
route("/onboard", onboardHandler),
73
route("/support", supportHandler),
+34
-3
src/modules/comments.tsx
+34
-3
src/modules/comments.tsx
···
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";
···
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<{
···
46
? <ActorAvatar profile={comment.author} size={42} />
47
: null}
48
<div class="flex flex-col gap-2">
49
-
{comment && comment.author
50
? <div class="font-semibold">{comment.author.displayName}</div>
51
: (
52
<div class="font-semibold">
53
{gallery?.creator.displayName}
54
</div>
55
)}
56
-
{comment && comment.text}
57
{!comment && !photo && gallery &&
58
(gallery.record as Gallery).title}
59
{!comment && !photo && gallery
···
207
</span>
208
</div>
209
210
-
<div class="mt-1">{comment.text}</div>
211
212
{isPhotoView(comment.focus) && (
213
<img
···
337
return new Response("Text is required", { status: 400 });
338
}
339
340
try {
341
await ctx.createRecord<WithBffMeta<Comment>>(
342
"social.grain.comment",
343
{
344
text,
345
subject: gallery.uri,
346
focus: focus ?? undefined,
347
replyTo: replyTo ?? undefined,
···
350
);
351
} catch (error) {
352
console.error("Error creating comment:", error);
353
}
354
355
const comments = getGalleryComments(gallery.uri, ctx);
···
593
focus: isPhotoView(focus) ? focus : undefined,
594
replyTo: record.replyTo,
595
author,
596
createdAt: record.createdAt,
597
};
598
}
···
10
isPhotoView,
11
PhotoView,
12
} from "$lexicon/types/social/grain/photo/defs.ts";
13
+
import { Facet } from "@atproto/api";
14
import { AtUri } from "@atproto/syntax";
15
import { BffContext, BffMiddleware, route, WithBffMeta } from "@bigmoves/bff";
16
import { cn } from "@bigmoves/bff/components";
···
19
import { ActorInfo } from "../components/ActorInfo.tsx";
20
import { Button } from "../components/Button.tsx";
21
import { GalleryPreviewLink } from "../components/GalleryPreviewLink.tsx";
22
+
import { RenderFacetedText } from "../components/RenderFacetedText.tsx";
23
import { Textarea } from "../components/Textarea.tsx";
24
import { getActorProfile, getActorProfilesBulk } from "../lib/actor.ts";
25
+
import { BadRequestError } from "../lib/errors.ts";
26
import { getGalleriesBulk, getGallery } from "../lib/gallery.ts";
27
import { getPhoto, getPhotosBulk } from "../lib/photo.ts";
28
+
import { parseFacetedText } from "../lib/rich_text.ts";
29
import { formatRelativeTime } from "../utils.ts";
30
31
export function ReplyDialog({ userProfile, gallery, photo, comment }: Readonly<{
···
50
? <ActorAvatar profile={comment.author} size={42} />
51
: null}
52
<div class="flex flex-col gap-2">
53
+
{comment?.author
54
? <div class="font-semibold">{comment.author.displayName}</div>
55
: (
56
<div class="font-semibold">
57
{gallery?.creator.displayName}
58
</div>
59
)}
60
+
{comment?.text && (
61
+
<RenderFacetedText
62
+
text={comment.text}
63
+
facets={(comment.record as Comment).facets}
64
+
/>
65
+
)}
66
{!comment && !photo && gallery &&
67
(gallery.record as Gallery).title}
68
{!comment && !photo && gallery
···
216
</span>
217
</div>
218
219
+
<div class="mt-1">
220
+
<RenderFacetedText
221
+
text={comment.text}
222
+
facets={(comment.record as Comment).facets}
223
+
/>
224
+
</div>
225
226
{isPhotoView(comment.focus) && (
227
<img
···
351
return new Response("Text is required", { status: 400 });
352
}
353
354
+
let facets: Facet[] | undefined = undefined;
355
+
if (text) {
356
+
try {
357
+
const resp = parseFacetedText(text, ctx);
358
+
facets = resp.facets;
359
+
} catch (e) {
360
+
console.error("Failed to parse facets:", e);
361
+
}
362
+
}
363
+
364
try {
365
await ctx.createRecord<WithBffMeta<Comment>>(
366
"social.grain.comment",
367
{
368
text,
369
+
facets,
370
subject: gallery.uri,
371
focus: focus ?? undefined,
372
replyTo: replyTo ?? undefined,
···
375
);
376
} catch (error) {
377
console.error("Error creating comment:", error);
378
+
if (error instanceof Error) {
379
+
throw new BadRequestError(error.message);
380
+
} else {
381
+
throw new BadRequestError("Unknown error");
382
+
}
383
}
384
385
const comments = getGalleryComments(gallery.uri, ctx);
···
623
focus: isPhotoView(focus) ? focus : undefined,
624
replyTo: record.replyTo,
625
author,
626
+
record,
627
createdAt: record.createdAt,
628
};
629
}
+16
src/routes/actions.tsx
+16
src/routes/actions.tsx
···
7
import { Record as Photo } from "$lexicon/types/social/grain/photo.ts";
8
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
9
import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts";
10
import { AtUri } from "@atproto/syntax";
11
import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff";
12
import {
···
23
import { getFollowers } from "../lib/follow.ts";
24
import { deleteGallery, getGallery } from "../lib/gallery.ts";
25
import { getPhoto, photoToView } from "../lib/photo.ts";
26
import type { State } from "../state.ts";
27
import { galleryLink, profileLink, uploadPageLink } from "../utils.ts";
28
···
97
const searchParams = new URLSearchParams(url.search);
98
const uri = searchParams.get("uri");
99
100
if (uri) {
101
const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>(uri);
102
if (!gallery) return ctx.next();
···
105
await ctx.updateRecord<Gallery>("social.grain.gallery", rkey, {
106
title,
107
description,
108
createdAt: gallery.createdAt,
109
});
110
} catch (e) {
···
122
{
123
title,
124
description,
125
createdAt: new Date().toISOString(),
126
},
127
);
···
7
import { Record as Photo } from "$lexicon/types/social/grain/photo.ts";
8
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
9
import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts";
10
+
import { Facet } from "@atproto/api";
11
import { AtUri } from "@atproto/syntax";
12
import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff";
13
import {
···
24
import { getFollowers } from "../lib/follow.ts";
25
import { deleteGallery, getGallery } from "../lib/gallery.ts";
26
import { getPhoto, photoToView } from "../lib/photo.ts";
27
+
import { parseFacetedText } from "../lib/rich_text.ts";
28
import type { State } from "../state.ts";
29
import { galleryLink, profileLink, uploadPageLink } from "../utils.ts";
30
···
99
const searchParams = new URLSearchParams(url.search);
100
const uri = searchParams.get("uri");
101
102
+
let facets: Facet[] | undefined = undefined;
103
+
if (description) {
104
+
try {
105
+
const resp = parseFacetedText(description, ctx);
106
+
facets = resp.facets;
107
+
} catch (e) {
108
+
console.error("Failed to parse facets:", e);
109
+
}
110
+
}
111
+
112
if (uri) {
113
const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>(uri);
114
if (!gallery) return ctx.next();
···
117
await ctx.updateRecord<Gallery>("social.grain.gallery", rkey, {
118
title,
119
description,
120
+
facets,
121
+
updatedAt: new Date().toISOString(),
122
createdAt: gallery.createdAt,
123
});
124
} catch (e) {
···
136
{
137
title,
138
description,
139
+
facets,
140
+
updatedAt: new Date().toISOString(),
141
createdAt: new Date().toISOString(),
142
},
143
);
+98
src/routes/hashtag.tsx
+98
src/routes/hashtag.tsx
···
···
1
+
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
2
+
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
3
+
import { BffContext, RouteHandler } from "@bigmoves/bff";
4
+
import { ActorInfo } from "../components/ActorInfo.tsx";
5
+
import { Breadcrumb } from "../components/Breadcrumb.tsx";
6
+
import { GalleryPreviewLink } from "../components/GalleryPreviewLink.tsx";
7
+
import { Header } from "../components/Header.tsx";
8
+
import { RenderFacetedText } from "../components/RenderFacetedText.tsx";
9
+
import {
10
+
getGalleriesBulk,
11
+
getGalleryUrisByCommentFacet,
12
+
getGalleryUrisByFacet,
13
+
} from "../lib/gallery.ts";
14
+
import { State } from "../state.ts";
15
+
16
+
export const handler: RouteHandler = (
17
+
_req,
18
+
params,
19
+
ctx: BffContext<State>,
20
+
) => {
21
+
const tag = params.tag;
22
+
23
+
const galleryUris = getGalleryUrisByFacet(
24
+
"tag",
25
+
tag,
26
+
ctx,
27
+
);
28
+
29
+
const galleriesUrisInComments = getGalleryUrisByCommentFacet(
30
+
"tag",
31
+
tag,
32
+
ctx,
33
+
);
34
+
35
+
const uniqueGalleryUris = Array.from(
36
+
new Set([...galleryUris, ...galleriesUrisInComments]),
37
+
);
38
+
const galleries = getGalleriesBulk(
39
+
uniqueGalleryUris,
40
+
ctx,
41
+
);
42
+
43
+
ctx.state.meta = [{ title: `Hashtag — Grain` }];
44
+
45
+
return ctx.render(
46
+
<div class="p-4 flex flex-col gap-4">
47
+
<Breadcrumb
48
+
class="m-0"
49
+
items={[{ label: "home", href: "/" }, {
50
+
label: tag,
51
+
}]}
52
+
/>
53
+
<Header>#{tag}</Header>
54
+
55
+
{galleries.length > 0
56
+
? (
57
+
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
58
+
{galleries.map((gallery) => (
59
+
<HashtagGalleryItem gallery={gallery} key={gallery.uri} />
60
+
))}
61
+
</div>
62
+
)
63
+
: <div>No galleries found.</div>}
64
+
</div>,
65
+
);
66
+
};
67
+
68
+
function HashtagGalleryItem(
69
+
{ gallery }: Readonly<{
70
+
gallery: GalleryView;
71
+
}>,
72
+
) {
73
+
const title = (gallery.record as Gallery).title;
74
+
const description = (gallery.record as Gallery).description;
75
+
const facets = (gallery.record as Gallery).facets || [];
76
+
return (
77
+
<div class="flex flex-col gap-2" key={gallery.uri}>
78
+
<ActorInfo profile={gallery.creator} />
79
+
<GalleryPreviewLink gallery={gallery} />
80
+
<div class="font-semibold">
81
+
{title}
82
+
</div>
83
+
{description && (
84
+
<p class="text-sm text-zinc-600 dark:text-zinc-500">
85
+
{facets && Array.isArray(facets) &&
86
+
facets.length > 0
87
+
? (
88
+
<RenderFacetedText
89
+
text={description}
90
+
facets={facets}
91
+
/>
92
+
)
93
+
: description}
94
+
</p>
95
+
)}
96
+
</div>
97
+
);
98
+
}
+16
-7
src/routes/notifications.tsx
+16
-7
src/routes/notifications.tsx
···
66
function getGalleriesUrisForNotifications(
67
notifications: Un$Typed<NotificationView>[],
68
): string[] {
69
-
const uris = notifications
70
-
.filter((n) =>
71
n.record.$type === "social.grain.favorite" ||
72
n.record.$type === "social.grain.comment"
73
-
)
74
-
.filter((n) =>
75
-
(n.record as WithSubject).subject.includes("social.grain.gallery")
76
-
)
77
-
.map((n) => (n.record as WithSubject).subject);
78
return uris;
79
}
80
···
66
function getGalleriesUrisForNotifications(
67
notifications: Un$Typed<NotificationView>[],
68
): string[] {
69
+
const uris: string[] = [];
70
+
for (const n of notifications) {
71
+
if (
72
n.record.$type === "social.grain.favorite" ||
73
n.record.$type === "social.grain.comment"
74
+
) {
75
+
if (
76
+
(n.record as WithSubject).subject &&
77
+
(n.record as WithSubject).subject.includes("social.grain.gallery")
78
+
) {
79
+
uris.push((n.record as WithSubject).subject);
80
+
}
81
+
} else if (n.record.$type === "social.grain.gallery") {
82
+
if (typeof n.record.uri === "string") {
83
+
uris.push(n.record.uri);
84
+
}
85
+
}
86
+
}
87
return uris;
88
}
89
+30
-9
src/routes/profile.tsx
+30
-9
src/routes/profile.tsx
···
1
-
import { BffContext, LabelerPolicies, RouteHandler } from "@bigmoves/bff";
2
import { ProfilePage, ProfileTabs } from "../components/ProfilePage.tsx";
3
import {
4
getActorGalleries,
···
12
moderateGallery,
13
ModerationDecsion,
14
} from "../lib/moderation.ts";
15
import { type SocialNetwork } from "../lib/timeline.ts";
16
import { getPageMeta } from "../meta.ts";
17
import type { State } from "../state.ts";
···
24
) => {
25
const url = new URL(req.url);
26
const tab = url.searchParams.get("tab") as ProfileTabs;
27
-
const handle = params.handle;
28
-
const actor = ctx.indexService.getActorByHandle(handle);
29
const isHxRequest = req.headers.get("hx-request") !== null;
30
const render = isHxRequest ? ctx.html : ctx.render;
31
32
-
if (!actor) return ctx.next();
33
-
34
const profile = getActorProfile(actor.did, ctx);
35
-
const galleries = getActorGalleries(handle, ctx);
36
const followers = getFollowers(actor.did, ctx);
37
const following = getFollowing(actor.did, ctx);
38
39
let labelerDefinitions: LabelerPolicies | undefined = undefined;
40
const isLabeler = await isLabelerFn(actor.did, ctx);
41
if (isLabeler) {
···
69
actorProfiles = getActorProfiles(ctx.currentUser.did, ctx);
70
}
71
72
-
userProfiles = getActorProfiles(handle, ctx);
73
74
ctx.state.meta = [
75
{
···
77
? `${profile.displayName} (${profile.handle}) — Grain`
78
: `${profile.handle} — Grain`,
79
},
80
-
...getPageMeta(profileLink(handle)),
81
];
82
83
if (tab === "favs") {
84
-
const galleryFavs = getActorGalleryFavs(handle, ctx);
85
return render(
86
<ProfilePage
87
followersCount={followers.length}
···
107
followUri={followUri}
108
loggedInUserDid={ctx.currentUser?.did}
109
profile={profile}
110
selectedTab={isLabeler ? "labels" : "galleries"}
111
galleries={galleries}
112
galleryModDecisionsMap={galleryModDecisionsMap}
···
1
+
import { RichText } from "@atproto/api";
2
+
import {
3
+
ActorTable,
4
+
BffContext,
5
+
LabelerPolicies,
6
+
RouteHandler,
7
+
} from "@bigmoves/bff";
8
import { ProfilePage, ProfileTabs } from "../components/ProfilePage.tsx";
9
import {
10
getActorGalleries,
···
18
moderateGallery,
19
ModerationDecsion,
20
} from "../lib/moderation.ts";
21
+
import { parseFacetedText } from "../lib/rich_text.ts";
22
import { type SocialNetwork } from "../lib/timeline.ts";
23
import { getPageMeta } from "../meta.ts";
24
import type { State } from "../state.ts";
···
31
) => {
32
const url = new URL(req.url);
33
const tab = url.searchParams.get("tab") as ProfileTabs;
34
+
const handleOrDid = params.handleOrDid;
35
+
36
+
let actor: ActorTable | undefined;
37
+
if (handleOrDid.includes("did:")) {
38
+
actor = ctx.indexService.getActor(handleOrDid);
39
+
} else {
40
+
actor = ctx.indexService.getActorByHandle(handleOrDid);
41
+
}
42
+
43
+
if (!actor) return ctx.next();
44
+
45
const isHxRequest = req.headers.get("hx-request") !== null;
46
const render = isHxRequest ? ctx.html : ctx.render;
47
48
const profile = getActorProfile(actor.did, ctx);
49
+
const galleries = getActorGalleries(actor.did, ctx);
50
const followers = getFollowers(actor.did, ctx);
51
const following = getFollowing(actor.did, ctx);
52
53
+
let descriptionFacets: RichText["facets"] = undefined;
54
+
if (profile?.description) {
55
+
const resp = parseFacetedText(profile?.description, ctx);
56
+
descriptionFacets = resp.facets;
57
+
}
58
+
59
let labelerDefinitions: LabelerPolicies | undefined = undefined;
60
const isLabeler = await isLabelerFn(actor.did, ctx);
61
if (isLabeler) {
···
89
actorProfiles = getActorProfiles(ctx.currentUser.did, ctx);
90
}
91
92
+
userProfiles = getActorProfiles(actor.did, ctx);
93
94
ctx.state.meta = [
95
{
···
97
? `${profile.displayName} (${profile.handle}) — Grain`
98
: `${profile.handle} — Grain`,
99
},
100
+
...getPageMeta(profileLink(actor.did)),
101
];
102
103
if (tab === "favs") {
104
+
const galleryFavs = getActorGalleryFavs(actor.did, ctx);
105
return render(
106
<ProfilePage
107
followersCount={followers.length}
···
127
followUri={followUri}
128
loggedInUserDid={ctx.currentUser?.did}
129
profile={profile}
130
+
descriptionFacets={descriptionFacets}
131
selectedTab={isLabeler ? "labels" : "galleries"}
132
galleries={galleries}
133
galleryModDecisionsMap={galleryModDecisionsMap}
+1
-1
src/state.ts
+1
-1
src/state.ts
+2
-2
src/utils.ts
+2
-2
src/utils.ts