grain.social is a photo sharing platform built on atproto.

feat: rich text support

- add facets to gallery and comment lexicons
- add hashtag page for linked galleries
- render facets in profile descriptions
- add notifications for "gallery-mention" and "gallery-comment" for when a user is mentioned in either a comment or gallery description
- add "updatedAt" to gallery lexicon

+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',
+3
__generated__/types/social/grain/comment.ts
··· 5 5 import { CID } from "npm:multiformats/cid" 6 6 import { validate as _validate } from '../../../lexicons.ts' 7 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util.ts' 8 + import type * as AppBskyRichtextFacet from '../../app/bsky/richtext/facet.ts' 8 9 9 10 const is$typed = _is$typed, 10 11 validate = _validate ··· 13 14 export interface Record { 14 15 $type: 'social.grain.comment' 15 16 text: string 17 + /** Annotations of description text (mentions and URLs, hashtags, etc) */ 18 + facets?: AppBskyRichtextFacet.Main[] 16 19 subject: string 17 20 focus?: string 18 21 replyTo?: string
+1
__generated__/types/social/grain/comment/defs.ts
··· 22 22 uri: string 23 23 cid: string 24 24 author: SocialGrainActorDefs.ProfileView 25 + record?: { [_ in string]: unknown } 25 26 text: string 26 27 subject?: $Typed<SocialGrainGalleryDefs.GalleryView> | { $type: string } 27 28 focus?: $Typed<SocialGrainPhotoDefs.PhotoView> | { $type: string }
+4
__generated__/types/social/grain/gallery.ts
··· 5 5 import { CID } from "npm:multiformats/cid" 6 6 import { validate as _validate } from '../../../lexicons.ts' 7 7 import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util.ts' 8 + import type * as AppBskyRichtextFacet from '../../app/bsky/richtext/facet.ts' 8 9 import type * as ComAtprotoLabelDefs from '../../com/atproto/label/defs.ts' 9 10 10 11 const is$typed = _is$typed, ··· 15 16 $type: 'social.grain.gallery' 16 17 title: string 17 18 description?: string 19 + /** Annotations of description text (mentions, URLs, hashtags, etc) */ 20 + facets?: AppBskyRichtextFacet.Main[] 18 21 labels?: $Typed<ComAtprotoLabelDefs.SelfLabels> | { $type: string } 22 + updatedAt?: string 19 23 createdAt: string 20 24 [k: string]: unknown 21 25 }
+2
__generated__/types/social/grain/notification/defs.ts
··· 26 26 | 'gallery-favorite' 27 27 | 'gallery-comment' 28 28 | 'reply' 29 + | 'gallery-mention' 30 + | 'gallery-comment-mention' 29 31 | 'unknown' 30 32 | (string & {}) 31 33 reasonSubject?: string
+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
··· 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
··· 6 6 "app.bsky.actor.defs", 7 7 "app.bsky.graph.follow", 8 8 "com.atproto.label.defs", 9 - "com.atproto.moderation.defs" 9 + "com.atproto.moderation.defs", 10 + "app.bsky.richtext.facet" 10 11 ] 11 12 }
+5
lexicons/social/grain/comment/comment.json
··· 14 14 "maxLength": 3000, 15 15 "maxGraphemes": 300 16 16 }, 17 + "facets": { 18 + "type": "array", 19 + "description": "Annotations of description text (mentions and URLs, hashtags, etc)", 20 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 21 + }, 17 22 "subject": { 18 23 "type": "string", 19 24 "format": "at-uri"
+1
lexicons/social/grain/comment/defs.json
··· 12 12 "type": "ref", 13 13 "ref": "social.grain.actor.defs#profileView" 14 14 }, 15 + "record": { "type": "unknown" }, 15 16 "text": { 16 17 "type": "string", 17 18 "maxLength": 3000,
+6
lexicons/social/grain/gallery/gallery.json
··· 11 11 "properties": { 12 12 "title": { "type": "string", "maxLength": 100 }, 13 13 "description": { "type": "string", "maxLength": 1000 }, 14 + "facets": { 15 + "type": "array", 16 + "description": "Annotations of description text (mentions, URLs, hashtags, etc)", 17 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 18 + }, 14 19 "labels": { 15 20 "type": "union", 16 21 "description": "Self-label values for this post. Effectively content warnings.", 17 22 "refs": ["com.atproto.label.defs#selfLabels"] 18 23 }, 24 + "updatedAt": { "type": "string", "format": "datetime" }, 19 25 "createdAt": { "type": "string", "format": "datetime" } 20 26 } 21 27 }
+2
lexicons/social/grain/notification/defs.json
··· 28 28 "gallery-favorite", 29 29 "gallery-comment", 30 30 "reply", 31 + "gallery-mention", 32 + "gallery-comment-mention", 31 33 "unknown" 32 34 ] 33 35 },
+1 -1
src/components/GalleryDetailsDialog.tsx
··· 14 14 <Dialog.Content> 15 15 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 16 16 <Dialog.Title> 17 - {gallery ? "Edit gallery" : "Create a new gallery"} 17 + {gallery ? "Edit details" : "Create a new gallery"} 18 18 </Dialog.Title> 19 19 <form 20 20 id="gallery-form"
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 28 28 if (profile) { 29 29 ctx.state.profile = profile; 30 30 } 31 - const notifications = getNotifications(ctx.currentUser, ctx); 31 + const notifications = getNotifications(ctx); 32 32 ctx.state.notifications = notifications; 33 33 return ctx.next(); 34 34 }
+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) {