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