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

add grain follows, add follow graph filter, update follow button logic to show other atproto networks your user is connected to (e.g. tangled, bsky, grain, etc)

Changed files
+1085 -94
__generated__
types
sh
tangled
actor
graph
social
grain
graph
lexicons
sh
tangled
social
grain
graph
src
static
+50
__generated__/index.ts
··· 39 39 export class Server { 40 40 xrpc: XrpcServer 41 41 app: AppNS 42 + sh: ShNS 42 43 social: SocialNS 43 44 com: ComNS 44 45 45 46 constructor(options?: XrpcOptions) { 46 47 this.xrpc = createXrpcServer(schemas, options) 47 48 this.app = new AppNS(this) 49 + this.sh = new ShNS(this) 48 50 this.social = new SocialNS(this) 49 51 this.com = new ComNS(this) 50 52 } ··· 118 120 } 119 121 } 120 122 123 + export class ShNS { 124 + _server: Server 125 + tangled: ShTangledNS 126 + 127 + constructor(server: Server) { 128 + this._server = server 129 + this.tangled = new ShTangledNS(server) 130 + } 131 + } 132 + 133 + export class ShTangledNS { 134 + _server: Server 135 + graph: ShTangledGraphNS 136 + actor: ShTangledActorNS 137 + 138 + constructor(server: Server) { 139 + this._server = server 140 + this.graph = new ShTangledGraphNS(server) 141 + this.actor = new ShTangledActorNS(server) 142 + } 143 + } 144 + 145 + export class ShTangledGraphNS { 146 + _server: Server 147 + 148 + constructor(server: Server) { 149 + this._server = server 150 + } 151 + } 152 + 153 + export class ShTangledActorNS { 154 + _server: Server 155 + 156 + constructor(server: Server) { 157 + this._server = server 158 + } 159 + } 160 + 121 161 export class SocialNS { 122 162 _server: Server 123 163 grain: SocialGrainNS ··· 131 171 export class SocialGrainNS { 132 172 _server: Server 133 173 gallery: SocialGrainGalleryNS 174 + graph: SocialGrainGraphNS 134 175 actor: SocialGrainActorNS 135 176 136 177 constructor(server: Server) { 137 178 this._server = server 138 179 this.gallery = new SocialGrainGalleryNS(server) 180 + this.graph = new SocialGrainGraphNS(server) 139 181 this.actor = new SocialGrainActorNS(server) 140 182 } 141 183 } 142 184 143 185 export class SocialGrainGalleryNS { 186 + _server: Server 187 + 188 + constructor(server: Server) { 189 + this._server = server 190 + } 191 + } 192 + 193 + export class SocialGrainGraphNS { 144 194 _server: Server 145 195 146 196 constructor(server: Server) {
+122
__generated__/lexicons.ts
··· 2296 2296 }, 2297 2297 }, 2298 2298 }, 2299 + ShTangledGraphFollow: { 2300 + lexicon: 1, 2301 + id: 'sh.tangled.graph.follow', 2302 + defs: { 2303 + main: { 2304 + type: 'record', 2305 + key: 'tid', 2306 + record: { 2307 + type: 'object', 2308 + required: ['subject', 'createdAt'], 2309 + properties: { 2310 + subject: { 2311 + type: 'string', 2312 + format: 'did', 2313 + }, 2314 + createdAt: { 2315 + type: 'string', 2316 + format: 'datetime', 2317 + }, 2318 + }, 2319 + }, 2320 + }, 2321 + }, 2322 + }, 2323 + ShTangledActorProfile: { 2324 + lexicon: 1, 2325 + id: 'sh.tangled.actor.profile', 2326 + defs: { 2327 + main: { 2328 + type: 'record', 2329 + description: 'A declaration of a Tangled account profile.', 2330 + key: 'literal:self', 2331 + record: { 2332 + type: 'object', 2333 + required: ['bluesky'], 2334 + properties: { 2335 + description: { 2336 + type: 'string', 2337 + description: 'Free-form profile description text.', 2338 + maxGraphemes: 256, 2339 + maxLength: 2560, 2340 + }, 2341 + links: { 2342 + type: 'array', 2343 + minLength: 0, 2344 + maxLength: 5, 2345 + items: { 2346 + type: 'string', 2347 + description: 2348 + 'Any URI, intended for social profiles or websites, can be used to link DIDs/AT-URIs too.', 2349 + }, 2350 + }, 2351 + stats: { 2352 + type: 'array', 2353 + minLength: 0, 2354 + maxLength: 2, 2355 + items: { 2356 + type: 'string', 2357 + description: 'Vanity stats.', 2358 + enum: [ 2359 + 'merged-pull-request-count', 2360 + 'closed-pull-request-count', 2361 + 'open-pull-request-count', 2362 + 'open-issue-count', 2363 + 'closed-issue-count', 2364 + 'repository-count', 2365 + ], 2366 + }, 2367 + }, 2368 + bluesky: { 2369 + type: 'boolean', 2370 + description: 'Include link to this account on Bluesky.', 2371 + }, 2372 + location: { 2373 + type: 'string', 2374 + description: 'Free-form location text.', 2375 + maxGraphemes: 40, 2376 + maxLength: 400, 2377 + }, 2378 + pinnedRepositories: { 2379 + type: 'array', 2380 + description: 2381 + 'Any ATURI, it is up to appviews to validate these fields.', 2382 + minLength: 0, 2383 + maxLength: 6, 2384 + items: { 2385 + type: 'string', 2386 + format: 'at-uri', 2387 + }, 2388 + }, 2389 + }, 2390 + }, 2391 + }, 2392 + }, 2393 + }, 2299 2394 SocialGrainDefs: { 2300 2395 lexicon: 1, 2301 2396 id: 'social.grain.defs', ··· 2458 2553 description: { 2459 2554 type: 'string', 2460 2555 maxLength: 1000, 2556 + }, 2557 + createdAt: { 2558 + type: 'string', 2559 + format: 'datetime', 2560 + }, 2561 + }, 2562 + }, 2563 + }, 2564 + }, 2565 + }, 2566 + SocialGrainGraphFollow: { 2567 + lexicon: 1, 2568 + id: 'social.grain.graph.follow', 2569 + defs: { 2570 + main: { 2571 + key: 'tid', 2572 + type: 'record', 2573 + record: { 2574 + type: 'object', 2575 + required: ['subject', 'createdAt'], 2576 + properties: { 2577 + subject: { 2578 + type: 'string', 2579 + format: 'did', 2461 2580 }, 2462 2581 createdAt: { 2463 2582 type: 'string', ··· 2891 3010 AppBskyActorDefs: 'app.bsky.actor.defs', 2892 3011 AppBskyActorProfile: 'app.bsky.actor.profile', 2893 3012 AppBskyLabelerDefs: 'app.bsky.labeler.defs', 3013 + ShTangledGraphFollow: 'sh.tangled.graph.follow', 3014 + ShTangledActorProfile: 'sh.tangled.actor.profile', 2894 3015 SocialGrainDefs: 'social.grain.defs', 2895 3016 SocialGrainNotificationDefs: 'social.grain.notification.defs', 2896 3017 SocialGrainGalleryItem: 'social.grain.gallery.item', 2897 3018 SocialGrainGalleryDefs: 'social.grain.gallery.defs', 2898 3019 SocialGrainGallery: 'social.grain.gallery', 3020 + SocialGrainGraphFollow: 'social.grain.graph.follow', 2899 3021 SocialGrainFavorite: 'social.grain.favorite', 2900 3022 SocialGrainActorDefs: 'social.grain.actor.defs', 2901 3023 SocialGrainActorProfile: 'social.grain.actor.profile',
+46
__generated__/types/sh/tangled/actor/profile.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 + import { CID } from "npm:multiformats/cid" 6 + import { validate as _validate } from '../../../../lexicons.ts' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.ts' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'sh.tangled.actor.profile' 16 + 17 + export interface Record { 18 + $type: 'sh.tangled.actor.profile' 19 + /** Free-form profile description text. */ 20 + description?: string 21 + links?: string[] 22 + stats?: 23 + | 'merged-pull-request-count' 24 + | 'closed-pull-request-count' 25 + | 'open-pull-request-count' 26 + | 'open-issue-count' 27 + | 'closed-issue-count' 28 + | 'repository-count'[] 29 + /** Include link to this account on Bluesky. */ 30 + bluesky: boolean 31 + /** Free-form location text. */ 32 + location?: string 33 + /** Any ATURI, it is up to appviews to validate these fields. */ 34 + pinnedRepositories?: string[] 35 + [k: string]: unknown 36 + } 37 + 38 + const hashRecord = 'main' 39 + 40 + export function isRecord<V>(v: V) { 41 + return is$typed(v, id, hashRecord) 42 + } 43 + 44 + export function validateRecord<V>(v: V) { 45 + return validate<Record & V>(v, id, hashRecord, true) 46 + }
+32
__generated__/types/sh/tangled/graph/follow.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 + import { CID } from "npm:multiformats/cid" 6 + import { validate as _validate } from '../../../../lexicons.ts' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.ts' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'sh.tangled.graph.follow' 16 + 17 + export interface Record { 18 + $type: 'sh.tangled.graph.follow' 19 + subject: string 20 + createdAt: string 21 + [k: string]: unknown 22 + } 23 + 24 + const hashRecord = 'main' 25 + 26 + export function isRecord<V>(v: V) { 27 + return is$typed(v, id, hashRecord) 28 + } 29 + 30 + export function validateRecord<V>(v: V) { 31 + return validate<Record & V>(v, id, hashRecord, true) 32 + }
+32
__generated__/types/social/grain/graph/follow.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 + import { CID } from "npm:multiformats/cid" 6 + import { validate as _validate } from '../../../../lexicons.ts' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.ts' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'social.grain.graph.follow' 16 + 17 + export interface Record { 18 + $type: 'social.grain.graph.follow' 19 + subject: string 20 + createdAt: string 21 + [k: string]: unknown 22 + } 23 + 24 + const hashRecord = 'main' 25 + 26 + export function isRecord<V>(v: V) { 27 + return is$typed(v, id, hashRecord) 28 + } 29 + 30 + export function validateRecord<V>(v: V) { 31 + return validate<Record & V>(v, id, hashRecord, true) 32 + }
+1 -1
deno.json
··· 17 17 "dev": "deno run \"dev:*\"", 18 18 "dev:server": "deno run -A --env-file=.env --watch ./src/main.tsx", 19 19 "dev:tailwind": "deno run -A --node-modules-dir npm:@tailwindcss/cli -i ./src/input.css -o ./static/styles.css --watch", 20 - "sync": "deno run -A --env=.env jsr:@bigmoves/bff-cli@0.3.0-beta.30 sync --collections=social.grain.gallery,social.grain.actor.profile,social.grain.photo,social.grain.favorite,social.grain.gallery.item --external-collections=app.bsky.actor.profile,app.bsky.graph.follow", 20 + "sync": "deno run -A --env=.env jsr:@bigmoves/bff-cli@0.3.0-beta.30 sync --collections=social.grain.gallery,social.grain.actor.profile,social.grain.photo,social.grain.favorite,social.grain.gallery.item,social.grain.graph.follow --external-collections=app.bsky.actor.profile,app.bsky.graph.follow,sh.tangled.graph.follow,sh.tangled.actor.profile", 21 21 "codegen": "deno run -A jsr:@bigmoves/bff-cli@0.3.0-beta.30 lexgen" 22 22 }, 23 23 "compilerOptions": {
+112 -1
deno.lock
··· 23 23 "npm:@atproto/common@~0.4.10": "0.4.11", 24 24 "npm:@atproto/identity@~0.4.7": "0.4.8", 25 25 "npm:@atproto/jwk@0.1.4": "0.1.4", 26 + "npm:@atproto/lex-cli@*": "0.8.1", 26 27 "npm:@atproto/lexicon@*": "0.4.11", 27 28 "npm:@atproto/lexicon@~0.4.11": "0.4.11", 28 29 "npm:@atproto/oauth-client@~0.3.13": "0.3.16", ··· 275 276 "multiformats@9.9.0", 276 277 "zod" 277 278 ] 279 + }, 280 + "@atproto/lex-cli@0.8.1": { 281 + "integrity": "sha512-0Ns6kX46gum2jU8bpvWCSVqoYhjmJrOGR/NLfLHgPbJtBlyxMGQAxqpy1x6zOi6SkkRGWYhHvRfr5J8lTHbxjA==", 282 + "dependencies": [ 283 + "@atproto/lexicon", 284 + "@atproto/syntax", 285 + "chalk", 286 + "commander", 287 + "prettier", 288 + "ts-morph", 289 + "yesno", 290 + "zod" 291 + ], 292 + "bin": true 278 293 }, 279 294 "@atproto/lexicon@0.4.11": { 280 295 "integrity": "sha512-btefdnvNz2Ao2I+qbmj0F06HC8IlrM/IBz6qOBS50r0S6uDf5tOO+Mv2tSVdimFkdzyDdLtBI1sV36ONxz2cOw==", ··· 644 659 ], 645 660 "scripts": true 646 661 }, 662 + "@ts-morph/common@0.25.0": { 663 + "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", 664 + "dependencies": [ 665 + "minimatch", 666 + "path-browserify", 667 + "tinyglobby" 668 + ] 669 + }, 647 670 "@tybys/wasm-util@0.9.0": { 648 671 "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", 649 672 "dependencies": [ ··· 667 690 "dependencies": [ 668 691 "mime-types", 669 692 "negotiator" 693 + ] 694 + }, 695 + "ansi-styles@4.3.0": { 696 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 697 + "dependencies": [ 698 + "color-convert" 670 699 ] 671 700 }, 672 701 "array-flatten@1.1.1": { ··· 678 707 "await-lock@2.2.2": { 679 708 "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" 680 709 }, 710 + "balanced-match@1.0.2": { 711 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" 712 + }, 681 713 "base64-js@1.5.1": { 682 714 "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" 683 715 }, ··· 696 728 "raw-body", 697 729 "type-is", 698 730 "unpipe" 731 + ] 732 + }, 733 + "brace-expansion@2.0.1": { 734 + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", 735 + "dependencies": [ 736 + "balanced-match" 699 737 ] 700 738 }, 701 739 "braces@3.0.3": { ··· 754 792 "integrity": "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug==", 755 793 "bin": true 756 794 }, 795 + "chalk@4.1.2": { 796 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 797 + "dependencies": [ 798 + "ansi-styles", 799 + "supports-color" 800 + ] 801 + }, 757 802 "chownr@3.0.0": { 758 803 "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" 759 804 }, 760 805 "clsx@2.1.1": { 761 806 "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" 762 807 }, 808 + "code-block-writer@13.0.3": { 809 + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==" 810 + }, 811 + "color-convert@2.0.1": { 812 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 813 + "dependencies": [ 814 + "color-name" 815 + ] 816 + }, 817 + "color-name@1.1.4": { 818 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" 819 + }, 820 + "commander@9.5.0": { 821 + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==" 822 + }, 763 823 "content-disposition@0.5.4": { 764 824 "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 765 825 "dependencies": [ ··· 884 944 "fast-redact@3.5.0": { 885 945 "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==" 886 946 }, 947 + "fdir@6.4.4_picomatch@4.0.2": { 948 + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", 949 + "dependencies": [ 950 + "picomatch@4.0.2" 951 + ], 952 + "optionalPeers": [ 953 + "picomatch@4.0.2" 954 + ] 955 + }, 887 956 "fill-range@7.1.1": { 888 957 "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", 889 958 "dependencies": [ ··· 948 1017 "graphemer@1.4.0": { 949 1018 "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 950 1019 }, 1020 + "has-flag@4.0.0": { 1021 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" 1022 + }, 951 1023 "has-symbols@1.1.0": { 952 1024 "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" 953 1025 }, ··· 1103 1175 "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", 1104 1176 "dependencies": [ 1105 1177 "braces", 1106 - "picomatch" 1178 + "picomatch@2.3.1" 1107 1179 ] 1108 1180 }, 1109 1181 "mime-db@1.52.0": { ··· 1118 1190 "mime@1.6.0": { 1119 1191 "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", 1120 1192 "bin": true 1193 + }, 1194 + "minimatch@9.0.5": { 1195 + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 1196 + "dependencies": [ 1197 + "brace-expansion" 1198 + ] 1121 1199 }, 1122 1200 "minipass@7.1.2": { 1123 1201 "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" ··· 1175 1253 "parseurl@1.3.3": { 1176 1254 "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 1177 1255 }, 1256 + "path-browserify@1.0.1": { 1257 + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" 1258 + }, 1178 1259 "path-to-regexp@0.1.12": { 1179 1260 "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" 1180 1261 }, ··· 1184 1265 "picomatch@2.3.1": { 1185 1266 "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" 1186 1267 }, 1268 + "picomatch@4.0.2": { 1269 + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==" 1270 + }, 1187 1271 "pino-abstract-transport@1.2.0": { 1188 1272 "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", 1189 1273 "dependencies": [ ··· 1228 1312 }, 1229 1313 "preact@10.26.6": { 1230 1314 "integrity": "sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==" 1315 + }, 1316 + "prettier@3.5.3": { 1317 + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", 1318 + "bin": true 1231 1319 }, 1232 1320 "process-warning@3.0.0": { 1233 1321 "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" ··· 1391 1479 "tslib@2.4.0" 1392 1480 ] 1393 1481 }, 1482 + "supports-color@7.2.0": { 1483 + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 1484 + "dependencies": [ 1485 + "has-flag" 1486 + ] 1487 + }, 1394 1488 "tailwind-merge@3.3.0": { 1395 1489 "integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==" 1396 1490 }, ··· 1417 1511 "real-require" 1418 1512 ] 1419 1513 }, 1514 + "tinyglobby@0.2.13_picomatch@4.0.2": { 1515 + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", 1516 + "dependencies": [ 1517 + "fdir", 1518 + "picomatch@4.0.2" 1519 + ] 1520 + }, 1420 1521 "tlds@1.259.0": { 1421 1522 "integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==", 1422 1523 "bin": true ··· 1429 1530 }, 1430 1531 "toidentifier@1.0.1": { 1431 1532 "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" 1533 + }, 1534 + "ts-morph@24.0.0": { 1535 + "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", 1536 + "dependencies": [ 1537 + "@ts-morph/common", 1538 + "code-block-writer" 1539 + ] 1432 1540 }, 1433 1541 "tslib@2.4.0": { 1434 1542 "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" ··· 1478 1586 }, 1479 1587 "yallist@5.0.0": { 1480 1588 "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" 1589 + }, 1590 + "yesno@0.4.0": { 1591 + "integrity": "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA==" 1481 1592 }, 1482 1593 "zod@3.25.7": { 1483 1594 "integrity": "sha512-YGdT1cVRmKkOg6Sq7vY7IkxdphySKnXhaUmFI4r4FcuFVNgpCb9tZfNwXbT6BPjD5oz0nubFsoo9pIqKrDcCvg=="
+71
lexicons/sh/tangled/actor/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.actor.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A declaration of a Tangled account profile.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "bluesky" 13 + ], 14 + "properties": { 15 + "description": { 16 + "type": "string", 17 + "description": "Free-form profile description text.", 18 + "maxGraphemes": 256, 19 + "maxLength": 2560 20 + }, 21 + "links": { 22 + "type": "array", 23 + "minLength": 0, 24 + "maxLength": 5, 25 + "items": { 26 + "type": "string", 27 + "description": "Any URI, intended for social profiles or websites, can be used to link DIDs/AT-URIs too." 28 + } 29 + }, 30 + "stats": { 31 + "type": "array", 32 + "minLength": 0, 33 + "maxLength": 2, 34 + "items": { 35 + "type": "string", 36 + "description": "Vanity stats.", 37 + "enum": [ 38 + "merged-pull-request-count", 39 + "closed-pull-request-count", 40 + "open-pull-request-count", 41 + "open-issue-count", 42 + "closed-issue-count", 43 + "repository-count" 44 + ] 45 + } 46 + }, 47 + "bluesky": { 48 + "type": "boolean", 49 + "description": "Include link to this account on Bluesky." 50 + }, 51 + "location": { 52 + "type": "string", 53 + "description": "Free-form location text.", 54 + "maxGraphemes": 40, 55 + "maxLength": 400 56 + }, 57 + "pinnedRepositories": { 58 + "type": "array", 59 + "description": "Any ATURI, it is up to appviews to validate these fields.", 60 + "minLength": 0, 61 + "maxLength": 6, 62 + "items": { 63 + "type": "string", 64 + "format": "at-uri" 65 + } 66 + } 67 + } 68 + } 69 + } 70 + } 71 + }
+27
lexicons/sh/tangled/graph/follow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.graph.follow", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "subject", 12 + "createdAt" 13 + ], 14 + "properties": { 15 + "subject": { 16 + "type": "string", 17 + "format": "did" 18 + }, 19 + "createdAt": { 20 + "type": "string", 21 + "format": "datetime" 22 + } 23 + } 24 + } 25 + } 26 + } 27 + }
+27
lexicons/social/grain/graph/follow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.graph.follow", 4 + "defs": { 5 + "main": { 6 + "key": "tid", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "subject", 12 + "createdAt" 13 + ], 14 + "properties": { 15 + "subject": { 16 + "type": "string", 17 + "format": "did" 18 + }, 19 + "createdAt": { 20 + "type": "string", 21 + "format": "datetime" 22 + } 23 + } 24 + } 25 + } 26 + } 27 + }
+47 -8
src/components/FollowButton.tsx
··· 1 - import { AtUri } from "@atproto/syntax"; 2 1 import { Button, cn } from "@bigmoves/bff/components"; 2 + import type { SocialNetwork } from "../lib/timeline.ts"; 3 + import { formatGraphName } from "./Timeline.tsx"; 3 4 4 5 export function FollowButton({ 5 6 followeeDid, 6 7 followUri, 7 - }: Readonly<{ followeeDid: string; load?: boolean; followUri?: string }>) { 8 + collection, 9 + class: classProp, 10 + }: Readonly< 11 + { 12 + followeeDid: string; 13 + followUri?: string; 14 + collection?: string; 15 + class?: string; 16 + } 17 + >) { 8 18 const isFollowing = followUri; 19 + let followPostUrl = `/actions/follow/${followeeDid}`; 20 + const hideCollectionParam = !collection ? "&hideCollection=true" : ""; 21 + const followDeleteUrl = followUri 22 + ? `/actions/follow/${followeeDid}?uri=${ 23 + encodeURIComponent(followUri) 24 + }${hideCollectionParam}` 25 + : undefined; 26 + if (collection) { 27 + followPostUrl += `?collection=${encodeURIComponent(collection)}`; 28 + } else { 29 + followPostUrl += `?collection=${ 30 + encodeURIComponent("social.grain.graph.follow") 31 + }${hideCollectionParam}`; 32 + } 33 + const source = formatGraphName(sourceForCollection(collection || "")); 34 + 9 35 return ( 10 36 <Button 37 + id="follow-botton" 11 38 variant="primary" 12 39 class={cn( 13 40 "w-full sm:w-fit whitespace-nowrap", 14 41 isFollowing && 15 42 "bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 text-zinc-950 dark:text-zinc-50", 43 + classProp, 16 44 )} 17 45 {...(isFollowing 18 46 ? { 19 - children: "Following", 20 - "hx-delete": `/actions/follow/${followeeDid}/${ 21 - new AtUri(followUri).rkey 22 - }`, 47 + children: source ? `Following on ${source}` : "Following", 48 + "hx-delete": followDeleteUrl, 23 49 } 24 50 : { 25 51 children: ( 26 52 <> 27 53 <i class="fa-solid fa-plus mr-2" /> 28 - Follow 54 + {source ? `Follow on ${source}` : "Follow"} 29 55 </> 30 56 ), 31 - "hx-post": `/actions/follow/${followeeDid}`, 57 + "hx-post": followPostUrl, 32 58 })} 33 59 hx-trigger="click" 34 60 hx-target="this" ··· 36 62 /> 37 63 ); 38 64 } 65 + 66 + function sourceForCollection(collection: string): SocialNetwork | "" { 67 + switch (collection) { 68 + case "app.bsky.graph.follow": 69 + return "bluesky"; 70 + case "social.grain.graph.follow": 71 + return "grain"; 72 + case "sh.tangled.graph.follow": 73 + return "tangled"; 74 + default: 75 + return ""; 76 + } 77 + }
+59
src/components/FollowsButton.tsx
··· 1 + import { Button, cn } from "@bigmoves/bff/components"; 2 + import { FollowMap } from "../lib/follow.ts"; 3 + import type { SocialNetwork } from "../lib/timeline.ts"; 4 + import { collectionForSource } from "./FollowsDialog.tsx"; 5 + 6 + export function FollowsButton({ 7 + actorProfiles, 8 + followeeDid, 9 + followMap, 10 + }: Readonly< 11 + { 12 + actorProfiles: SocialNetwork[]; 13 + followeeDid: string; 14 + followMap: FollowMap; 15 + } 16 + >) { 17 + const followSources = followMap 18 + ? (Object.keys(followMap) as Array<keyof typeof followMap>).filter( 19 + (source) => followMap[source] !== undefined, 20 + ) 21 + : []; 22 + const isFollowing = followSources.length > 0; 23 + const totalSources = actorProfiles.length; 24 + const followingCount = 25 + actorProfiles.filter((source) => 26 + !!followMap[collectionForSource(source) as keyof typeof followMap] 27 + ).length; 28 + return ( 29 + <Button 30 + id="follows-button" 31 + variant="primary" 32 + class={cn( 33 + "w-full sm:w-fit whitespace-nowrap", 34 + isFollowing && 35 + "bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 text-zinc-950 dark:text-zinc-50", 36 + )} 37 + hx-get={`/dialogs/follows/${followeeDid}`} 38 + hx-trigger="click" 39 + hx-target="#layout" 40 + hx-swap="afterbegin" 41 + {...(isFollowing 42 + ? { 43 + children: ( 44 + <> 45 + Following ({followingCount}/{totalSources}) 46 + </> 47 + ), 48 + } 49 + : { 50 + children: ( 51 + <> 52 + <i class="fa-solid fa-plus mr-2" /> 53 + Follow ({followingCount}/{totalSources}) 54 + </> 55 + ), 56 + })} 57 + /> 58 + ); 59 + }
+50
src/components/FollowsDialog.tsx
··· 1 + import { Dialog } from "@bigmoves/bff/components"; 2 + import { type FollowMap } from "../lib/follow.ts"; 3 + import type { SocialNetwork } from "../lib/timeline.ts"; 4 + import { FollowButton } from "./FollowButton.tsx"; 5 + 6 + export function FollowsDialog( 7 + { sources, followeeDid, followMap }: Readonly< 8 + { sources: SocialNetwork[]; followeeDid: string; followMap: FollowMap } 9 + >, 10 + ) { 11 + return ( 12 + <Dialog class="z-100" _="on closeDialog call window.location.reload()"> 13 + <Dialog.Content class="dark:bg-zinc-950 relative"> 14 + <Dialog.X /> 15 + <Dialog.Title>Follow</Dialog.Title> 16 + <ul class="w-full my-4 space-y-2"> 17 + {sources.map((source) => { 18 + const collection = collectionForSource(source); 19 + return ( 20 + <li key={source} class="w-full"> 21 + <FollowButton 22 + class="sm:w-full" 23 + collection={collection} 24 + followeeDid={followeeDid} 25 + followUri={followMap[collection as keyof FollowMap]} 26 + /> 27 + </li> 28 + ); 29 + })} 30 + </ul> 31 + <Dialog.Close class="w-full mt-2"> 32 + Close 33 + </Dialog.Close> 34 + </Dialog.Content> 35 + </Dialog> 36 + ); 37 + } 38 + 39 + export function collectionForSource(source: SocialNetwork): string { 40 + switch (source) { 41 + case "bluesky": 42 + return "app.bsky.graph.follow"; 43 + case "grain": 44 + return "social.grain.graph.follow"; 45 + case "tangled": 46 + return "sh.tangled.graph.follow"; 47 + default: 48 + return ""; 49 + } 50 + }
+37 -10
src/components/ProfilePage.tsx
··· 5 5 import { Un$Typed } from "$lexicon/util.ts"; 6 6 import { AtUri } from "@atproto/syntax"; 7 7 import { Button, cn } from "@bigmoves/bff/components"; 8 - import { TimelineItem } from "../lib/timeline.ts"; 8 + import { FollowMap } from "../lib/follow.ts"; 9 + import type { SocialNetwork, TimelineItem } from "../lib/timeline.ts"; 9 10 import { bskyProfileLink, galleryLink, profileLink } from "../utils.ts"; 10 11 import { ActorAvatar } from "./ActorAvatar.tsx"; 11 12 import { AvatarButton } from "./AvatarButton.tsx"; 12 13 import { FollowButton } from "./FollowButton.tsx"; 14 + import { FollowsButton } from "./FollowsButton.tsx"; 13 15 import { TimelineItem as Item } from "./TimelineItem.tsx"; 14 16 15 17 export type ProfileTabs = "favs" | "galleries" | null; 16 18 17 19 export function ProfilePage({ 18 - followUri, 20 + userProfiles, 21 + actorProfiles, 22 + followMap, 19 23 loggedInUserDid, 20 24 timelineItems, 21 25 profile, ··· 23 27 galleries, 24 28 galleryFavs, 25 29 }: Readonly<{ 26 - followUri?: string; 30 + userProfiles: SocialNetwork[]; 31 + actorProfiles: SocialNetwork[]; 32 + followMap: FollowMap; 27 33 loggedInUserDid?: string; 28 34 timelineItems: TimelineItem[]; 29 35 profile: Un$Typed<ProfileView>; ··· 33 39 }>) { 34 40 const isCreator = loggedInUserDid === profile.did; 35 41 const displayName = profile.displayName || profile.handle; 42 + const grainOnly = actorProfiles.length === 1 && 43 + actorProfiles.includes("grain"); 44 + const profilesIntersection = userProfiles.filter((p) => 45 + actorProfiles.includes(p) 46 + ); 36 47 return ( 37 48 <div class="px-4 mb-4" id="profile-page"> 38 49 <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4"> ··· 44 55 ? <p class="mt-2 sm:max-w-[500px]">{profile.description}</p> 45 56 : null} 46 57 <p> 47 - <a 48 - href={bskyProfileLink(profile.handle)} 49 - class="text-xs hover:underline" 50 - > 51 - <i class="fa-brands fa-bluesky text-sky-500" /> @{profile.handle} 52 - </a> 58 + {userProfiles.includes("bluesky") && ( 59 + <a 60 + href={bskyProfileLink(profile.handle)} 61 + class="text-xs hover:underline" 62 + > 63 + <i class="fa-brands fa-bluesky text-sky-500" />{" "} 64 + @{profile.handle} 65 + </a> 66 + )} 53 67 </p> 54 68 </div> 55 69 {!isCreator && loggedInUserDid 56 70 ? ( 57 71 <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 58 - <FollowButton followeeDid={profile.did} followUri={followUri} /> 72 + {grainOnly 73 + ? ( 74 + <FollowButton 75 + followeeDid={profile.did} 76 + followUri={followMap["social.grain.graph.follow"]} 77 + /> 78 + ) 79 + : ( 80 + <FollowsButton 81 + actorProfiles={profilesIntersection} 82 + followeeDid={profile.did} 83 + followMap={followMap} 84 + /> 85 + )} 59 86 </div> 60 87 ) 61 88 : null}
+59 -6
src/components/Timeline.tsx
··· 4 4 import { TimelineItem as Item } from "./TimelineItem.tsx"; 5 5 6 6 export function Timeline( 7 - { isLoggedIn, selectedTab, items }: Readonly< 8 - { isLoggedIn: boolean; selectedTab: string; items: TimelineItem[] } 7 + { isLoggedIn, selectedTab, items, actorProfiles, selectedGraph }: Readonly< 8 + { 9 + isLoggedIn: boolean; 10 + selectedTab: string; 11 + items: TimelineItem[]; 12 + actorProfiles: string[]; 13 + selectedGraph: string; 14 + } 9 15 >, 10 16 ) { 11 17 return ( ··· 17 23 <div class="flex sm:w-fit"> 18 24 <button 19 25 type="button" 20 - hx-get="/" 21 - hx-target="body" 26 + hx-get={`/?graph=${selectedGraph}`} 27 + hx-target="#timeline-page" 22 28 hx-swap="outerHTML" 23 29 class={cn( 24 30 "flex-1 py-2 sm:min-w-[120px] px-4 cursor-pointer font-semibold", ··· 33 39 </button> 34 40 <button 35 41 type="button" 36 - hx-get="/?tab=following" 42 + hx-get={`/?tab=following&graph=${selectedGraph}`} 37 43 hx-target="#timeline-page" 38 44 hx-swap="outerHTML" 39 45 class={cn( ··· 51 57 </div> 52 58 </div> 53 59 <div id="tab-content" role="tabpanel"> 60 + {actorProfiles.length > 1 && selectedTab === "following" 61 + ? ( 62 + <form 63 + hx-get="/" 64 + hx-target="#timeline-page" 65 + hx-swap="outerHTML" 66 + hx-trigger="change from:#graph-filter" 67 + class="mb-4 flex flex-col border-b border-zinc-200 dark:border-zinc-800 pb-4" 68 + > 69 + <label 70 + htmlFor="graph-filter" 71 + class="mb-1 font-medium sr-only" 72 + > 73 + Filter by AT Protocol Social Network 74 + </label> 75 + 76 + <input type="hidden" name="tab" value={selectedTab || ""} /> 77 + 78 + <select 79 + id="graph-filter" 80 + name="graph" 81 + class="border rounded px-2 py-1 dark:bg-zinc-900 dark:border-zinc-700 max-w-md" 82 + > 83 + {actorProfiles.map((graph) => ( 84 + <option 85 + value={graph} 86 + key={graph} 87 + selected={graph === selectedGraph} 88 + > 89 + {formatGraphName(graph)} 90 + </option> 91 + ))} 92 + </select> 93 + </form> 94 + ) 95 + : null} 54 96 <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y w-fit"> 55 - {items.map((item) => <Item item={item} key={item.itemUri} />)} 97 + {items.length > 0 98 + ? items.map((item) => <Item item={item} key={item.itemUri} />) 99 + : ( 100 + <li class="text-center"> 101 + No galleries by people you follow on{" "} 102 + {formatGraphName(selectedGraph)} yet. 103 + </li> 104 + )} 56 105 </ul> 57 106 </div> 58 107 </> ··· 70 119 </div> 71 120 ); 72 121 } 122 + 123 + export function formatGraphName(graph: string): string { 124 + return graph.charAt(0).toUpperCase() + graph.slice(1); 125 + }
+72 -4
src/lib/actor.ts
··· 1 + import { Record as BskyProfile } from "$lexicon/types/app/bsky/actor/profile.ts"; 2 + import { Record as TangledProfile } from "$lexicon/types/sh/tangled/actor/profile.ts"; 1 3 import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 - import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 4 + import { Record as GrainProfile } from "$lexicon/types/social/grain/actor/profile.ts"; 3 5 import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 4 6 import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 5 7 import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; ··· 7 9 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 8 10 import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts"; 9 11 import { photoToView } from "./photo.ts"; 12 + import type { SocialNetwork } from "./timeline.ts"; 10 13 11 14 export function getActorProfile(did: string, ctx: BffContext) { 12 15 const actor = ctx.indexService.getActor(did); 13 16 if (!actor) return null; 14 - const profileRecord = ctx.indexService.getRecord<WithBffMeta<Profile>>( 17 + const profileRecord = ctx.indexService.getRecord<WithBffMeta<GrainProfile>>( 15 18 `at://${did}/social.grain.actor.profile/self`, 16 19 ); 17 20 return profileRecord ? profileToView(profileRecord, actor.handle) : null; 18 21 } 19 22 20 23 export function profileToView( 21 - record: WithBffMeta<Profile>, 24 + record: WithBffMeta<GrainProfile>, 22 25 handle: string, 23 26 ): Un$Typed<ProfileView> { 24 27 return { ··· 124 127 new Set(galleries.map((gallery) => gallery.did)), 125 128 ); 126 129 127 - const { items: profiles } = ctx.indexService.getRecords<WithBffMeta<Profile>>( 130 + const { items: profiles } = ctx.indexService.getRecords< 131 + WithBffMeta<GrainProfile> 132 + >( 128 133 "social.grain.actor.profile", 129 134 { 130 135 where: [{ field: "did", in: uniqueDids }], ··· 151 156 }) 152 157 .filter((g) => g !== null); 153 158 } 159 + 160 + export function getActorProfiles( 161 + handleOrDid: string, 162 + ctx: BffContext, 163 + ): SocialNetwork[] { 164 + let did: string; 165 + 166 + if (handleOrDid.includes("did:")) { 167 + did = handleOrDid; 168 + } else { 169 + const actor = ctx.indexService.getActorByHandle(handleOrDid); 170 + if (!actor) return []; 171 + did = actor.did; 172 + } 173 + 174 + const { items: grainProfiles } = ctx.indexService.getRecords< 175 + WithBffMeta<GrainProfile> 176 + >( 177 + "social.grain.actor.profile", 178 + { 179 + where: { 180 + AND: [ 181 + { field: "did", equals: did }, 182 + { field: "uri", contains: "self" }, 183 + ], 184 + }, 185 + }, 186 + ); 187 + 188 + const { items: tangledProfiles } = ctx.indexService.getRecords< 189 + WithBffMeta<TangledProfile> 190 + >( 191 + "sh.tangled.actor.profile", 192 + { 193 + where: { 194 + AND: [ 195 + { field: "did", equals: did }, 196 + { field: "uri", contains: "self" }, 197 + ], 198 + }, 199 + }, 200 + ); 201 + 202 + const { items: bskyProfiles } = ctx.indexService.getRecords< 203 + WithBffMeta<BskyProfile> 204 + >( 205 + "app.bsky.actor.profile", 206 + { 207 + where: { 208 + AND: [ 209 + { field: "did", equals: did }, 210 + { field: "uri", contains: "self" }, 211 + ], 212 + }, 213 + }, 214 + ); 215 + 216 + const profiles: SocialNetwork[] = []; 217 + if (grainProfiles.length) profiles.push("grain"); 218 + if (bskyProfiles.length) profiles.push("bluesky"); 219 + if (tangledProfiles.length) profiles.push("tangled"); 220 + return profiles; 221 + }
+30 -9
src/lib/follow.ts
··· 1 1 import { Record as BskyFollow } from "$lexicon/types/app/bsky/graph/follow.ts"; 2 + import { Record as TangledFollow } from "$lexicon/types/sh/tangled/graph/follow.ts"; 3 + import { Record as GrainFollow } from "$lexicon/types/social/grain/graph/follow.ts"; 2 4 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 3 5 4 - export function getFollow( 6 + export type FollowSource = 7 + | "app.bsky.graph.follow" 8 + | "sh.tangled.graph.follow" 9 + | "social.grain.graph.follow"; 10 + 11 + export type FollowMap = Record<FollowSource, string>; 12 + 13 + export function getFollows( 5 14 followeeDid: string, 6 15 followerDid: string, 7 16 ctx: BffContext, 8 - ) { 9 - const { 10 - items: [follow], 11 - } = ctx.indexService.getRecords<WithBffMeta<BskyFollow>>( 17 + ): FollowMap { 18 + const sources: FollowSource[] = [ 12 19 "app.bsky.graph.follow", 13 - { 20 + "sh.tangled.graph.follow", 21 + "social.grain.graph.follow", 22 + ]; 23 + 24 + const result: FollowMap = {} as FollowMap; 25 + 26 + for (const source of sources) { 27 + const { 28 + items: [follow], 29 + } = ctx.indexService.getRecords< 30 + WithBffMeta<BskyFollow | GrainFollow | TangledFollow> 31 + >(source, { 14 32 where: [ 15 33 { 16 34 field: "did", ··· 21 39 equals: followeeDid, 22 40 }, 23 41 ], 24 - }, 25 - ); 26 - return follow; 42 + }); 43 + if (follow && "uri" in follow) { 44 + result[source] = follow.uri; 45 + } 46 + } 47 + return result; 27 48 }
+29 -11
src/lib/timeline.ts
··· 1 1 import { Record as BskyFollow } from "$lexicon/types/app/bsky/graph/follow.ts"; 2 + import { Record as TangledFollow } from "$lexicon/types/sh/tangled/graph/follow.ts"; 2 3 import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 3 4 import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 4 5 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 6 + import { Record as GrainFollow } from "$lexicon/types/social/grain/graph/follow.ts"; 5 7 import { Un$Typed } from "$lexicon/util.ts"; 6 8 import { AtUri } from "@atproto/syntax"; 7 9 import { BffContext, QueryOptions, WithBffMeta } from "@bigmoves/bff"; 8 10 import { getActorProfile } from "./actor.ts"; 9 11 import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts"; 10 12 11 - type TimelineItemType = "gallery"; 13 + export type TimelineItemType = "gallery"; 14 + 15 + export type SocialNetwork = "bluesky" | "grain" | "tangled"; 12 16 13 17 export type TimelineItem = { 14 18 createdAt: string; ··· 33 37 ? [{ field: "did", equals: options.actorDid }] 34 38 : undefined; 35 39 36 - if (options?.followingDids && options.followingDids.size > 0) { 37 - whereClause = [ 38 - ...(whereClause ?? []), 39 - { field: "did", in: Array.from(options.followingDids) }, 40 - ]; 40 + if (options?.followingDids) { 41 + if (options.followingDids.size > 0) { 42 + whereClause = [ 43 + ...(whereClause ?? []), 44 + { field: "did", in: Array.from(options.followingDids) }, 45 + ]; 46 + } else { 47 + return []; 48 + } 41 49 } 42 50 43 51 const { items: galleries } = ctx.indexService.getRecords< ··· 86 94 ); 87 95 } 88 96 89 - function getFollowingDids(ctx: BffContext): Set<string> { 97 + function getFollowingDids(type: SocialNetwork, ctx: BffContext): Set<string> { 90 98 if (!ctx.currentUser?.did) return new Set(); 99 + const typeToCollection: Record<SocialNetwork, string> = { 100 + bluesky: "app.bsky.graph.follow", 101 + grain: "social.grain.graph.follow", 102 + tangled: "sh.tangled.graph.follow", 103 + }; 104 + const collection = typeToCollection[type]; 105 + if (!collection) { 106 + throw new Error(`Unsupported social graph type: ${type}`); 107 + } 91 108 const { items: follows } = ctx.indexService.getRecords< 92 - WithBffMeta<BskyFollow> 109 + WithBffMeta<BskyFollow | GrainFollow | TangledFollow> 93 110 >( 94 - "app.bsky.graph.follow", 111 + collection, 95 112 { where: [{ field: "did", equals: ctx.currentUser.did }] }, 96 113 ); 97 114 return new Set(follows.map((f) => f.subject).filter(Boolean)); ··· 99 116 100 117 export function getTimeline( 101 118 ctx: BffContext, 102 - type: "timeline" | "following" = "timeline", 119 + type: "timeline" | "following", 120 + graph: SocialNetwork, 103 121 ): TimelineItem[] { 104 122 let followingDids: Set<string> | undefined = undefined; 105 123 if (type === "following") { 106 - followingDids = getFollowingDids(ctx); 124 + followingDids = getFollowingDids(graph, ctx); 107 125 } 108 126 const galleryItems = processGalleries(ctx, { followingDids }); 109 127 return galleryItems.sort(
+5 -1
src/main.tsx
··· 29 29 "social.grain.photo", 30 30 "social.grain.favorite", 31 31 "social.grain.gallery.item", 32 + "social.grain.graph.follow", 32 33 ], 33 34 externalCollections: [ 34 35 "app.bsky.actor.profile", 35 36 "app.bsky.graph.follow", 37 + "sh.tangled.actor.profile", 38 + "sh.tangled.graph.follow", 36 39 ], 37 40 jetstreamUrl: JETSTREAM.WEST_1, 38 41 lexicons, ··· 75 78 "/dialogs/photo-select/:galleryRkey", 76 79 dialogHandlers.galleryPhotoSelect, 77 80 ), 81 + route("/dialogs/follows/:followeeDid", dialogHandlers.follows), 78 82 route("/actions/update-seen", ["POST"], actionHandlers.updateSeen), 79 83 route("/actions/follow/:did", ["POST"], actionHandlers.follow), 80 84 route( 81 - "/actions/follow/:followeeDid/:rkey", 85 + "/actions/follow/:followeeDid", 82 86 ["DELETE"], 83 87 actionHandlers.unfollow, 84 88 ),
+39 -8
src/routes/actions.tsx
··· 12 12 import { PhotoButton } from "../components/PhotoButton.tsx"; 13 13 import { PhotoPreview } from "../components/PhotoPreview.tsx"; 14 14 import { PhotoSelectButton } from "../components/PhotoSelectButton.tsx"; 15 + import { BadRequestError } from "../lib/errors.ts"; 15 16 import { deleteGallery, getGallery, getGalleryFavs } from "../lib/gallery.ts"; 16 17 import { photoThumb, photoToView } from "../lib/photo.ts"; 17 18 import type { State } from "../state.ts"; ··· 28 29 }; 29 30 30 31 export const follow: RouteHandler = async ( 31 - _req, 32 + req, 32 33 params, 33 34 ctx: BffContext<State>, 34 35 ) => { 35 36 ctx.requireAuth(); 36 37 const did = params.did; 37 - if (!did) return ctx.next(); 38 + const url = new URL(req.url); 39 + const collection = url.searchParams.get("collection") || undefined; 40 + const hideCollection = url.searchParams.get("hideCollection") === "true"; 41 + // TODO: check for supported collections 42 + if (!did || !collection) { 43 + throw new BadRequestError("Missing did or collection"); 44 + } 38 45 const followUri = await ctx.createRecord<BskyFollow>( 39 - "app.bsky.graph.follow", 46 + collection, 40 47 { 41 48 subject: did, 42 49 createdAt: new Date().toISOString(), 43 50 }, 44 51 ); 52 + if (collection) { 53 + return ctx.html( 54 + <FollowButton 55 + {...!hideCollection && { 56 + class: "sm:w-full", 57 + collection, 58 + }} 59 + followeeDid={did} 60 + followUri={followUri} 61 + />, 62 + ); 63 + } 45 64 return ctx.html(<FollowButton followeeDid={did} followUri={followUri} />); 46 65 }; 47 66 48 67 export const unfollow: RouteHandler = async ( 49 - _req, 68 + req, 50 69 params, 51 70 ctx: BffContext<State>, 52 71 ) => { 53 - const { did } = ctx.requireAuth(); 72 + ctx.requireAuth(); 54 73 const followeeDid = params.followeeDid; 55 - const rkey = params.rkey; 74 + const url = new URL(req.url); 75 + const uri = url.searchParams.get("uri") || undefined; 76 + const hideCollection = url.searchParams.get("hideCollection") === "true"; 77 + if (!followeeDid || !uri) { 78 + throw new BadRequestError("Missing followeeDid or uri"); 79 + } 56 80 await ctx.deleteRecord( 57 - `at://${did}/app.bsky.graph.follow/${rkey}`, 81 + uri, 58 82 ); 59 83 return ctx.html( 60 - <FollowButton followeeDid={followeeDid} followUri={undefined} />, 84 + <FollowButton 85 + {...!hideCollection && { 86 + class: "sm:w-full", 87 + collection: new AtUri(uri).collection, 88 + }} 89 + followeeDid={followeeDid} 90 + followUri={undefined} 91 + />, 61 92 ); 62 93 }; 63 94
+29 -1
src/routes/dialogs.tsx
··· 7 7 import { wrap } from "popmotion"; 8 8 import { AvatarDialog } from "../components/AvatarDialog.tsx"; 9 9 import { CreateAccountDialog } from "../components/CreateAccountDialog.tsx"; 10 + import { FollowsDialog } from "../components/FollowsDialog.tsx"; 10 11 import { GalleryCreateEditDialog } from "../components/GalleryCreateEditDialog.tsx"; 11 12 import { GallerySortDialog } from "../components/GallerySortDialog.tsx"; 12 13 import { PhotoAltDialog } from "../components/PhotoAltDialog.tsx"; 13 14 import { PhotoDialog } from "../components/PhotoDialog.tsx"; 14 15 import { PhotoSelectDialog } from "../components/PhotoSelectDialog.tsx"; 15 16 import { ProfileDialog } from "../components/ProfileDialog.tsx"; 16 - import { getActorPhotos, getActorProfile } from "../lib/actor.ts"; 17 + import { 18 + getActorPhotos, 19 + getActorProfile, 20 + getActorProfiles, 21 + } from "../lib/actor.ts"; 22 + import { BadRequestError } from "../lib/errors.ts"; 23 + import { getFollows } from "../lib/follow.ts"; 17 24 import { getGallery, getGalleryItemsAndPhotos } from "../lib/gallery.ts"; 18 25 import { photoToView } from "../lib/photo.ts"; 19 26 import type { State } from "../state.ts"; ··· 162 169 ) => { 163 170 return ctx.html(<CreateAccountDialog />); 164 171 }; 172 + 173 + export const follows: RouteHandler = ( 174 + _req, 175 + params, 176 + ctx: BffContext<State>, 177 + ) => { 178 + const { did } = ctx.requireAuth(); 179 + const followeeDid = params.followeeDid; 180 + if (!followeeDid) { 181 + throw new BadRequestError("Missing followeeDid parameter"); 182 + } 183 + const followMap = getFollows(followeeDid, did, ctx); 184 + const sources = getActorProfiles(followeeDid, ctx); 185 + return ctx.html( 186 + <FollowsDialog 187 + sources={sources} 188 + followeeDid={followeeDid} 189 + followMap={followMap} 190 + />, 191 + ); 192 + };
+24 -9
src/routes/profile.tsx
··· 1 - import { Record as BskyFollow } from "$lexicon/types/app/bsky/graph/follow.ts"; 2 - import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff"; 1 + import { BffContext, RouteHandler } from "@bigmoves/bff"; 3 2 import { ProfilePage, ProfileTabs } from "../components/ProfilePage.tsx"; 4 3 import { 5 4 getActorGalleries, 6 5 getActorGalleryFavs, 7 6 getActorProfile, 7 + getActorProfiles, 8 8 } from "../lib/actor.ts"; 9 - import { getFollow } from "../lib/follow.ts"; 10 - import { getActorTimeline } from "../lib/timeline.ts"; 9 + import { type FollowMap, getFollows } from "../lib/follow.ts"; 10 + import { getActorTimeline, type SocialNetwork } from "../lib/timeline.ts"; 11 11 import { getPageMeta } from "../meta.ts"; 12 12 import type { State } from "../state.ts"; 13 13 import { profileLink } from "../utils.ts"; ··· 31 31 32 32 if (!profile) return ctx.next(); 33 33 34 - let follow: WithBffMeta<BskyFollow> | undefined; 34 + let followMap: FollowMap = { 35 + "social.grain.graph.follow": "", 36 + "app.bsky.graph.follow": "", 37 + "sh.tangled.graph.follow": "", 38 + }; 39 + let actorProfiles: SocialNetwork[] = []; 40 + let userProfiles: SocialNetwork[] = []; 35 41 36 42 if (ctx.currentUser) { 37 - follow = getFollow(profile.did, ctx.currentUser.did, ctx); 43 + followMap = getFollows(profile.did, ctx.currentUser.did, ctx); 44 + actorProfiles = getActorProfiles(ctx.currentUser.did, ctx); 38 45 } 46 + 47 + userProfiles = getActorProfiles(handle, ctx); 39 48 40 49 ctx.state.meta = [ 41 50 { ··· 52 61 const galleryFavs = getActorGalleryFavs(handle, ctx); 53 62 return render( 54 63 <ProfilePage 55 - followUri={follow?.uri} 64 + userProfiles={userProfiles} 65 + actorProfiles={actorProfiles} 66 + followMap={followMap} 56 67 loggedInUserDid={ctx.currentUser?.did} 57 68 timelineItems={timelineItems} 58 69 profile={profile} ··· 66 77 const galleries = getActorGalleries(handle, ctx); 67 78 return render( 68 79 <ProfilePage 69 - followUri={follow?.uri} 80 + userProfiles={userProfiles} 81 + actorProfiles={actorProfiles} 82 + followMap={followMap} 70 83 loggedInUserDid={ctx.currentUser?.did} 71 84 timelineItems={timelineItems} 72 85 profile={profile} ··· 77 90 } 78 91 return ctx.render( 79 92 <ProfilePage 80 - followUri={follow?.uri} 93 + userProfiles={userProfiles} 94 + actorProfiles={actorProfiles} 95 + followMap={followMap} 81 96 loggedInUserDid={ctx.currentUser?.did} 82 97 timelineItems={timelineItems} 83 98 profile={profile}
+58 -25
src/routes/timeline.tsx
··· 1 1 import { BffContext, RouteHandler } from "@bigmoves/bff"; 2 2 import { getCookies, setCookie } from "@std/http"; 3 3 import { Timeline } from "../components/Timeline.tsx"; 4 - import { getTimeline } from "../lib/timeline.ts"; 4 + import { getActorProfiles } from "../lib/actor.ts"; 5 + import { getTimeline, SocialNetwork } from "../lib/timeline.ts"; 5 6 import { getPageMeta } from "../meta.ts"; 6 7 import type { State } from "../state.ts"; 7 8 ··· 12 13 ) => { 13 14 const url = new URL(req.url); 14 15 const tabSearchParam = url.searchParams.get("tab") || ""; 15 - const cookieState = getCookieState(req.headers); 16 + const graphSearchParam = url.searchParams.get("graph") as SocialNetwork || 17 + "grain"; 18 + const cookieState = getCookieState(ctx?.currentUser?.did, req.headers); 19 + const isHxRequest = req.headers.get("hx-request") !== null; 20 + const render = isHxRequest ? ctx.html : ctx.render; 21 + 16 22 let tab; 23 + let graph: SocialNetwork = "grain"; 17 24 let headers: Record<string, string> = {}; 18 25 26 + const actorProfiles = getActorProfiles(ctx?.currentUser?.did ?? "", ctx); 27 + 19 28 if (!ctx.currentUser) { 20 29 tab = ""; 21 - } else if (!req.headers.get("hx-request")) { 30 + } else if (!isHxRequest) { 22 31 tab = cookieState.lastSelectedHomeFeed || ""; 32 + graph = cookieState.lastSelectedFollowGraph || "grain"; 23 33 } else { 24 34 tab = tabSearchParam || ""; 35 + graph = graphSearchParam; 25 36 headers = setCookieState(url.hostname, { 37 + did: ctx.currentUser.did, 26 38 lastSelectedHomeFeed: tab, 39 + lastSelectedFollowGraph: graph, 27 40 }); 28 41 } 29 42 43 + if (!graph && actorProfiles.length > 0) { 44 + graph = actorProfiles[0]; 45 + } 46 + 30 47 const items = getTimeline( 31 48 ctx, 32 49 tab === "following" ? "following" : "timeline", 50 + graph, 33 51 ); 34 52 35 53 if (tab === "following") { 36 - if (!req.headers.get("hx-request")) { 37 - ctx.state.meta = [{ title: "Following — Grain" }, ...getPageMeta("")]; 38 - return ctx.render( 39 - <Timeline 40 - isLoggedIn={!!ctx.currentUser} 41 - selectedTab={tab} 42 - items={items} 43 - />, 44 - headers, 45 - ); 46 - } 47 - return ctx.html( 54 + ctx.state.meta = [{ title: "Following — Grain" }, ...getPageMeta("")]; 55 + return render( 48 56 <Timeline 49 57 isLoggedIn={!!ctx.currentUser} 50 58 selectedTab={tab} 51 59 items={items} 60 + selectedGraph={graph} 61 + actorProfiles={actorProfiles} 52 62 />, 53 63 headers, 54 64 ); ··· 56 66 57 67 ctx.state.meta = [{ title: "Timeline — Grain" }, ...getPageMeta("")]; 58 68 59 - return ctx.render( 60 - <Timeline isLoggedIn={!!ctx.currentUser} selectedTab={tab} items={items} />, 69 + return render( 70 + <Timeline 71 + isLoggedIn={!!ctx.currentUser} 72 + selectedTab={tab} 73 + items={items} 74 + selectedGraph={graph} 75 + actorProfiles={actorProfiles} 76 + />, 61 77 headers, 62 78 ); 63 79 }; 64 80 65 81 type GrainStorageState = { 82 + did?: string; 66 83 lastSelectedHomeFeed?: string; 67 - }; 68 - 69 - const defaultGrainStorageState: GrainStorageState = { 70 - lastSelectedHomeFeed: undefined, 84 + lastSelectedFollowGraph?: SocialNetwork; 71 85 }; 72 86 73 87 function setCookieState( ··· 92 106 } 93 107 94 108 function getCookieState( 109 + did: string | undefined, 95 110 headers: Headers, 96 111 ): GrainStorageState { 97 112 const cookies = getCookies(headers); 98 113 if (!cookies.grain_storage) { 99 - return defaultGrainStorageState; 114 + return createDefaultCookieState(did); 100 115 } 101 116 const grainStorage = atob(cookies.grain_storage); 102 117 if (grainStorage) { 103 118 try { 104 - return JSON.parse(grainStorage); 119 + const parsed = JSON.parse(grainStorage); 120 + if (parsed.did && parsed.did !== did) { 121 + // If the did in the cookie doesn't match the current user, reset the state 122 + return createDefaultCookieState(did); 123 + } 124 + if (!parsed.did && did) { 125 + return createDefaultCookieState(did); 126 + } 127 + return parsed as GrainStorageState; 105 128 } catch { 106 - return defaultGrainStorageState; 129 + return createDefaultCookieState(did); 107 130 } 108 131 } 109 - return defaultGrainStorageState; 132 + return createDefaultCookieState(did); 133 + } 134 + 135 + function createDefaultCookieState( 136 + did: string | undefined, 137 + ): GrainStorageState { 138 + return { 139 + did, 140 + lastSelectedHomeFeed: "", 141 + lastSelectedFollowGraph: "grain", 142 + }; 110 143 }
+27
static/styles.css
··· 36 36 --text-3xl--line-height: calc(2.25 / 1.875); 37 37 --text-4xl: 2.25rem; 38 38 --text-4xl--line-height: calc(2.5 / 2.25); 39 + --font-weight-medium: 500; 39 40 --font-weight-semibold: 600; 40 41 --font-weight-bold: 700; 41 42 --default-font-family: var(--font-sans); ··· 316 317 .mr-2 { 317 318 margin-right: calc(var(--spacing) * 2); 318 319 } 320 + .mb-1 { 321 + margin-bottom: calc(var(--spacing) * 1); 322 + } 319 323 .mb-2 { 320 324 margin-bottom: calc(var(--spacing) * 2); 321 325 } ··· 564 568 } 565 569 .overflow-x-auto { 566 570 overflow-x: auto; 571 + } 572 + .rounded { 573 + border-radius: 0.25rem; 567 574 } 568 575 .rounded-full { 569 576 border-radius: calc(infinity * 1px); ··· 633 640 .p-4 { 634 641 padding: calc(var(--spacing) * 4); 635 642 } 643 + .px-2 { 644 + padding-inline: calc(var(--spacing) * 2); 645 + } 636 646 .px-4 { 637 647 padding-inline: calc(var(--spacing) * 4); 638 648 } 639 649 .px-\[3px\] { 640 650 padding-inline: 3px; 651 + } 652 + .py-1 { 653 + padding-block: calc(var(--spacing) * 1); 641 654 } 642 655 .py-2 { 643 656 padding-block: calc(var(--spacing) * 2); ··· 710 723 --tw-font-weight: var(--font-weight-bold); 711 724 font-weight: var(--font-weight-bold); 712 725 } 726 + .font-medium { 727 + --tw-font-weight: var(--font-weight-medium); 728 + font-weight: var(--font-weight-medium); 729 + } 713 730 .font-semibold { 714 731 --tw-font-weight: var(--font-weight-semibold); 715 732 font-weight: var(--font-weight-semibold); ··· 858 875 width: fit-content; 859 876 } 860 877 } 878 + .sm\:w-full { 879 + @media (width >= 40rem) { 880 + width: 100%; 881 + } 882 + } 861 883 .sm\:max-w-\[400px\] { 862 884 @media (width >= 40rem) { 863 885 max-width: 400px; ··· 948 970 :where(& > :not(:last-child)) { 949 971 border-color: var(--color-zinc-800); 950 972 } 973 + } 974 + } 975 + .dark\:border-zinc-700 { 976 + @media (prefers-color-scheme: dark) { 977 + border-color: var(--color-zinc-700); 951 978 } 952 979 } 953 980 .dark\:border-zinc-800 {