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