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 export class Server { 40 xrpc: XrpcServer 41 app: AppNS 42 social: SocialNS 43 com: ComNS 44 45 constructor(options?: XrpcOptions) { 46 this.xrpc = createXrpcServer(schemas, options) 47 this.app = new AppNS(this) 48 this.social = new SocialNS(this) 49 this.com = new ComNS(this) 50 } ··· 118 } 119 } 120 121 export class SocialNS { 122 _server: Server 123 grain: SocialGrainNS ··· 131 export class SocialGrainNS { 132 _server: Server 133 gallery: SocialGrainGalleryNS 134 actor: SocialGrainActorNS 135 136 constructor(server: Server) { 137 this._server = server 138 this.gallery = new SocialGrainGalleryNS(server) 139 this.actor = new SocialGrainActorNS(server) 140 } 141 } 142 143 export class SocialGrainGalleryNS { 144 _server: Server 145 146 constructor(server: Server) {
··· 39 export class Server { 40 xrpc: XrpcServer 41 app: AppNS 42 + sh: ShNS 43 social: SocialNS 44 com: ComNS 45 46 constructor(options?: XrpcOptions) { 47 this.xrpc = createXrpcServer(schemas, options) 48 this.app = new AppNS(this) 49 + this.sh = new ShNS(this) 50 this.social = new SocialNS(this) 51 this.com = new ComNS(this) 52 } ··· 120 } 121 } 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 + 161 export class SocialNS { 162 _server: Server 163 grain: SocialGrainNS ··· 171 export class SocialGrainNS { 172 _server: Server 173 gallery: SocialGrainGalleryNS 174 + graph: SocialGrainGraphNS 175 actor: SocialGrainActorNS 176 177 constructor(server: Server) { 178 this._server = server 179 this.gallery = new SocialGrainGalleryNS(server) 180 + this.graph = new SocialGrainGraphNS(server) 181 this.actor = new SocialGrainActorNS(server) 182 } 183 } 184 185 export class SocialGrainGalleryNS { 186 + _server: Server 187 + 188 + constructor(server: Server) { 189 + this._server = server 190 + } 191 + } 192 + 193 + export class SocialGrainGraphNS { 194 _server: Server 195 196 constructor(server: Server) {
+122
__generated__/lexicons.ts
··· 2296 }, 2297 }, 2298 }, 2299 SocialGrainDefs: { 2300 lexicon: 1, 2301 id: 'social.grain.defs', ··· 2458 description: { 2459 type: 'string', 2460 maxLength: 1000, 2461 }, 2462 createdAt: { 2463 type: 'string', ··· 2891 AppBskyActorDefs: 'app.bsky.actor.defs', 2892 AppBskyActorProfile: 'app.bsky.actor.profile', 2893 AppBskyLabelerDefs: 'app.bsky.labeler.defs', 2894 SocialGrainDefs: 'social.grain.defs', 2895 SocialGrainNotificationDefs: 'social.grain.notification.defs', 2896 SocialGrainGalleryItem: 'social.grain.gallery.item', 2897 SocialGrainGalleryDefs: 'social.grain.gallery.defs', 2898 SocialGrainGallery: 'social.grain.gallery', 2899 SocialGrainFavorite: 'social.grain.favorite', 2900 SocialGrainActorDefs: 'social.grain.actor.defs', 2901 SocialGrainActorProfile: 'social.grain.actor.profile',
··· 2296 }, 2297 }, 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 + }, 2394 SocialGrainDefs: { 2395 lexicon: 1, 2396 id: 'social.grain.defs', ··· 2553 description: { 2554 type: 'string', 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', 2580 }, 2581 createdAt: { 2582 type: 'string', ··· 3010 AppBskyActorDefs: 'app.bsky.actor.defs', 3011 AppBskyActorProfile: 'app.bsky.actor.profile', 3012 AppBskyLabelerDefs: 'app.bsky.labeler.defs', 3013 + ShTangledGraphFollow: 'sh.tangled.graph.follow', 3014 + ShTangledActorProfile: 'sh.tangled.actor.profile', 3015 SocialGrainDefs: 'social.grain.defs', 3016 SocialGrainNotificationDefs: 'social.grain.notification.defs', 3017 SocialGrainGalleryItem: 'social.grain.gallery.item', 3018 SocialGrainGalleryDefs: 'social.grain.gallery.defs', 3019 SocialGrainGallery: 'social.grain.gallery', 3020 + SocialGrainGraphFollow: 'social.grain.graph.follow', 3021 SocialGrainFavorite: 'social.grain.favorite', 3022 SocialGrainActorDefs: 'social.grain.actor.defs', 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 "dev": "deno run \"dev:*\"", 18 "dev:server": "deno run -A --env-file=.env --watch ./src/main.tsx", 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", 21 "codegen": "deno run -A jsr:@bigmoves/bff-cli@0.3.0-beta.30 lexgen" 22 }, 23 "compilerOptions": {
··· 17 "dev": "deno run \"dev:*\"", 18 "dev:server": "deno run -A --env-file=.env --watch ./src/main.tsx", 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,social.grain.graph.follow --external-collections=app.bsky.actor.profile,app.bsky.graph.follow,sh.tangled.graph.follow,sh.tangled.actor.profile", 21 "codegen": "deno run -A jsr:@bigmoves/bff-cli@0.3.0-beta.30 lexgen" 22 }, 23 "compilerOptions": {
+112 -1
deno.lock
··· 23 "npm:@atproto/common@~0.4.10": "0.4.11", 24 "npm:@atproto/identity@~0.4.7": "0.4.8", 25 "npm:@atproto/jwk@0.1.4": "0.1.4", 26 "npm:@atproto/lexicon@*": "0.4.11", 27 "npm:@atproto/lexicon@~0.4.11": "0.4.11", 28 "npm:@atproto/oauth-client@~0.3.13": "0.3.16", ··· 275 "multiformats@9.9.0", 276 "zod" 277 ] 278 }, 279 "@atproto/lexicon@0.4.11": { 280 "integrity": "sha512-btefdnvNz2Ao2I+qbmj0F06HC8IlrM/IBz6qOBS50r0S6uDf5tOO+Mv2tSVdimFkdzyDdLtBI1sV36ONxz2cOw==", ··· 644 ], 645 "scripts": true 646 }, 647 "@tybys/wasm-util@0.9.0": { 648 "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", 649 "dependencies": [ ··· 667 "dependencies": [ 668 "mime-types", 669 "negotiator" 670 ] 671 }, 672 "array-flatten@1.1.1": { ··· 678 "await-lock@2.2.2": { 679 "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" 680 }, 681 "base64-js@1.5.1": { 682 "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" 683 }, ··· 696 "raw-body", 697 "type-is", 698 "unpipe" 699 ] 700 }, 701 "braces@3.0.3": { ··· 754 "integrity": "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug==", 755 "bin": true 756 }, 757 "chownr@3.0.0": { 758 "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" 759 }, 760 "clsx@2.1.1": { 761 "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" 762 }, 763 "content-disposition@0.5.4": { 764 "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 765 "dependencies": [ ··· 884 "fast-redact@3.5.0": { 885 "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==" 886 }, 887 "fill-range@7.1.1": { 888 "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", 889 "dependencies": [ ··· 948 "graphemer@1.4.0": { 949 "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 950 }, 951 "has-symbols@1.1.0": { 952 "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" 953 }, ··· 1103 "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", 1104 "dependencies": [ 1105 "braces", 1106 - "picomatch" 1107 ] 1108 }, 1109 "mime-db@1.52.0": { ··· 1118 "mime@1.6.0": { 1119 "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", 1120 "bin": true 1121 }, 1122 "minipass@7.1.2": { 1123 "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" ··· 1175 "parseurl@1.3.3": { 1176 "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 1177 }, 1178 "path-to-regexp@0.1.12": { 1179 "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" 1180 }, ··· 1184 "picomatch@2.3.1": { 1185 "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" 1186 }, 1187 "pino-abstract-transport@1.2.0": { 1188 "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", 1189 "dependencies": [ ··· 1228 }, 1229 "preact@10.26.6": { 1230 "integrity": "sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==" 1231 }, 1232 "process-warning@3.0.0": { 1233 "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" ··· 1391 "tslib@2.4.0" 1392 ] 1393 }, 1394 "tailwind-merge@3.3.0": { 1395 "integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==" 1396 }, ··· 1417 "real-require" 1418 ] 1419 }, 1420 "tlds@1.259.0": { 1421 "integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==", 1422 "bin": true ··· 1429 }, 1430 "toidentifier@1.0.1": { 1431 "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" 1432 }, 1433 "tslib@2.4.0": { 1434 "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" ··· 1478 }, 1479 "yallist@5.0.0": { 1480 "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" 1481 }, 1482 "zod@3.25.7": { 1483 "integrity": "sha512-YGdT1cVRmKkOg6Sq7vY7IkxdphySKnXhaUmFI4r4FcuFVNgpCb9tZfNwXbT6BPjD5oz0nubFsoo9pIqKrDcCvg=="
··· 23 "npm:@atproto/common@~0.4.10": "0.4.11", 24 "npm:@atproto/identity@~0.4.7": "0.4.8", 25 "npm:@atproto/jwk@0.1.4": "0.1.4", 26 + "npm:@atproto/lex-cli@*": "0.8.1", 27 "npm:@atproto/lexicon@*": "0.4.11", 28 "npm:@atproto/lexicon@~0.4.11": "0.4.11", 29 "npm:@atproto/oauth-client@~0.3.13": "0.3.16", ··· 276 "multiformats@9.9.0", 277 "zod" 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 293 }, 294 "@atproto/lexicon@0.4.11": { 295 "integrity": "sha512-btefdnvNz2Ao2I+qbmj0F06HC8IlrM/IBz6qOBS50r0S6uDf5tOO+Mv2tSVdimFkdzyDdLtBI1sV36ONxz2cOw==", ··· 659 ], 660 "scripts": true 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 + }, 670 "@tybys/wasm-util@0.9.0": { 671 "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", 672 "dependencies": [ ··· 690 "dependencies": [ 691 "mime-types", 692 "negotiator" 693 + ] 694 + }, 695 + "ansi-styles@4.3.0": { 696 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 697 + "dependencies": [ 698 + "color-convert" 699 ] 700 }, 701 "array-flatten@1.1.1": { ··· 707 "await-lock@2.2.2": { 708 "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" 709 }, 710 + "balanced-match@1.0.2": { 711 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" 712 + }, 713 "base64-js@1.5.1": { 714 "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" 715 }, ··· 728 "raw-body", 729 "type-is", 730 "unpipe" 731 + ] 732 + }, 733 + "brace-expansion@2.0.1": { 734 + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", 735 + "dependencies": [ 736 + "balanced-match" 737 ] 738 }, 739 "braces@3.0.3": { ··· 792 "integrity": "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug==", 793 "bin": true 794 }, 795 + "chalk@4.1.2": { 796 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 797 + "dependencies": [ 798 + "ansi-styles", 799 + "supports-color" 800 + ] 801 + }, 802 "chownr@3.0.0": { 803 "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" 804 }, 805 "clsx@2.1.1": { 806 "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" 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 + }, 823 "content-disposition@0.5.4": { 824 "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 825 "dependencies": [ ··· 944 "fast-redact@3.5.0": { 945 "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==" 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 + }, 956 "fill-range@7.1.1": { 957 "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", 958 "dependencies": [ ··· 1017 "graphemer@1.4.0": { 1018 "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 1019 }, 1020 + "has-flag@4.0.0": { 1021 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" 1022 + }, 1023 "has-symbols@1.1.0": { 1024 "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" 1025 }, ··· 1175 "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", 1176 "dependencies": [ 1177 "braces", 1178 + "picomatch@2.3.1" 1179 ] 1180 }, 1181 "mime-db@1.52.0": { ··· 1190 "mime@1.6.0": { 1191 "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", 1192 "bin": true 1193 + }, 1194 + "minimatch@9.0.5": { 1195 + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 1196 + "dependencies": [ 1197 + "brace-expansion" 1198 + ] 1199 }, 1200 "minipass@7.1.2": { 1201 "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" ··· 1253 "parseurl@1.3.3": { 1254 "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 1255 }, 1256 + "path-browserify@1.0.1": { 1257 + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" 1258 + }, 1259 "path-to-regexp@0.1.12": { 1260 "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" 1261 }, ··· 1265 "picomatch@2.3.1": { 1266 "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" 1267 }, 1268 + "picomatch@4.0.2": { 1269 + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==" 1270 + }, 1271 "pino-abstract-transport@1.2.0": { 1272 "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", 1273 "dependencies": [ ··· 1312 }, 1313 "preact@10.26.6": { 1314 "integrity": "sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==" 1315 + }, 1316 + "prettier@3.5.3": { 1317 + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", 1318 + "bin": true 1319 }, 1320 "process-warning@3.0.0": { 1321 "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" ··· 1479 "tslib@2.4.0" 1480 ] 1481 }, 1482 + "supports-color@7.2.0": { 1483 + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 1484 + "dependencies": [ 1485 + "has-flag" 1486 + ] 1487 + }, 1488 "tailwind-merge@3.3.0": { 1489 "integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==" 1490 }, ··· 1511 "real-require" 1512 ] 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 + }, 1521 "tlds@1.259.0": { 1522 "integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==", 1523 "bin": true ··· 1530 }, 1531 "toidentifier@1.0.1": { 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 + ] 1540 }, 1541 "tslib@2.4.0": { 1542 "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" ··· 1586 }, 1587 "yallist@5.0.0": { 1588 "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" 1589 + }, 1590 + "yesno@0.4.0": { 1591 + "integrity": "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA==" 1592 }, 1593 "zod@3.25.7": { 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 import { Button, cn } from "@bigmoves/bff/components"; 3 4 export function FollowButton({ 5 followeeDid, 6 followUri, 7 - }: Readonly<{ followeeDid: string; load?: boolean; followUri?: string }>) { 8 const isFollowing = followUri; 9 return ( 10 <Button 11 variant="primary" 12 class={cn( 13 "w-full sm:w-fit whitespace-nowrap", 14 isFollowing && 15 "bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 text-zinc-950 dark:text-zinc-50", 16 )} 17 {...(isFollowing 18 ? { 19 - children: "Following", 20 - "hx-delete": `/actions/follow/${followeeDid}/${ 21 - new AtUri(followUri).rkey 22 - }`, 23 } 24 : { 25 children: ( 26 <> 27 <i class="fa-solid fa-plus mr-2" /> 28 - Follow 29 </> 30 ), 31 - "hx-post": `/actions/follow/${followeeDid}`, 32 })} 33 hx-trigger="click" 34 hx-target="this" ··· 36 /> 37 ); 38 }
··· 1 import { Button, cn } from "@bigmoves/bff/components"; 2 + import type { SocialNetwork } from "../lib/timeline.ts"; 3 + import { formatGraphName } from "./Timeline.tsx"; 4 5 export function FollowButton({ 6 followeeDid, 7 followUri, 8 + collection, 9 + class: classProp, 10 + }: Readonly< 11 + { 12 + followeeDid: string; 13 + followUri?: string; 14 + collection?: string; 15 + class?: string; 16 + } 17 + >) { 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 + 35 return ( 36 <Button 37 + id="follow-botton" 38 variant="primary" 39 class={cn( 40 "w-full sm:w-fit whitespace-nowrap", 41 isFollowing && 42 "bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 text-zinc-950 dark:text-zinc-50", 43 + classProp, 44 )} 45 {...(isFollowing 46 ? { 47 + children: source ? `Following on ${source}` : "Following", 48 + "hx-delete": followDeleteUrl, 49 } 50 : { 51 children: ( 52 <> 53 <i class="fa-solid fa-plus mr-2" /> 54 + {source ? `Follow on ${source}` : "Follow"} 55 </> 56 ), 57 + "hx-post": followPostUrl, 58 })} 59 hx-trigger="click" 60 hx-target="this" ··· 62 /> 63 ); 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 import { Un$Typed } from "$lexicon/util.ts"; 6 import { AtUri } from "@atproto/syntax"; 7 import { Button, cn } from "@bigmoves/bff/components"; 8 - import { TimelineItem } from "../lib/timeline.ts"; 9 import { bskyProfileLink, galleryLink, profileLink } from "../utils.ts"; 10 import { ActorAvatar } from "./ActorAvatar.tsx"; 11 import { AvatarButton } from "./AvatarButton.tsx"; 12 import { FollowButton } from "./FollowButton.tsx"; 13 import { TimelineItem as Item } from "./TimelineItem.tsx"; 14 15 export type ProfileTabs = "favs" | "galleries" | null; 16 17 export function ProfilePage({ 18 - followUri, 19 loggedInUserDid, 20 timelineItems, 21 profile, ··· 23 galleries, 24 galleryFavs, 25 }: Readonly<{ 26 - followUri?: string; 27 loggedInUserDid?: string; 28 timelineItems: TimelineItem[]; 29 profile: Un$Typed<ProfileView>; ··· 33 }>) { 34 const isCreator = loggedInUserDid === profile.did; 35 const displayName = profile.displayName || profile.handle; 36 return ( 37 <div class="px-4 mb-4" id="profile-page"> 38 <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4"> ··· 44 ? <p class="mt-2 sm:max-w-[500px]">{profile.description}</p> 45 : null} 46 <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> 53 </p> 54 </div> 55 {!isCreator && loggedInUserDid 56 ? ( 57 <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 58 - <FollowButton followeeDid={profile.did} followUri={followUri} /> 59 </div> 60 ) 61 : null}
··· 5 import { Un$Typed } from "$lexicon/util.ts"; 6 import { AtUri } from "@atproto/syntax"; 7 import { Button, cn } from "@bigmoves/bff/components"; 8 + import { FollowMap } from "../lib/follow.ts"; 9 + import type { SocialNetwork, TimelineItem } from "../lib/timeline.ts"; 10 import { bskyProfileLink, galleryLink, profileLink } from "../utils.ts"; 11 import { ActorAvatar } from "./ActorAvatar.tsx"; 12 import { AvatarButton } from "./AvatarButton.tsx"; 13 import { FollowButton } from "./FollowButton.tsx"; 14 + import { FollowsButton } from "./FollowsButton.tsx"; 15 import { TimelineItem as Item } from "./TimelineItem.tsx"; 16 17 export type ProfileTabs = "favs" | "galleries" | null; 18 19 export function ProfilePage({ 20 + userProfiles, 21 + actorProfiles, 22 + followMap, 23 loggedInUserDid, 24 timelineItems, 25 profile, ··· 27 galleries, 28 galleryFavs, 29 }: Readonly<{ 30 + userProfiles: SocialNetwork[]; 31 + actorProfiles: SocialNetwork[]; 32 + followMap: FollowMap; 33 loggedInUserDid?: string; 34 timelineItems: TimelineItem[]; 35 profile: Un$Typed<ProfileView>; ··· 39 }>) { 40 const isCreator = loggedInUserDid === profile.did; 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 + ); 47 return ( 48 <div class="px-4 mb-4" id="profile-page"> 49 <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4"> ··· 55 ? <p class="mt-2 sm:max-w-[500px]">{profile.description}</p> 56 : null} 57 <p> 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 + )} 67 </p> 68 </div> 69 {!isCreator && loggedInUserDid 70 ? ( 71 <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 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 + )} 86 </div> 87 ) 88 : null}
+59 -6
src/components/Timeline.tsx
··· 4 import { TimelineItem as Item } from "./TimelineItem.tsx"; 5 6 export function Timeline( 7 - { isLoggedIn, selectedTab, items }: Readonly< 8 - { isLoggedIn: boolean; selectedTab: string; items: TimelineItem[] } 9 >, 10 ) { 11 return ( ··· 17 <div class="flex sm:w-fit"> 18 <button 19 type="button" 20 - hx-get="/" 21 - hx-target="body" 22 hx-swap="outerHTML" 23 class={cn( 24 "flex-1 py-2 sm:min-w-[120px] px-4 cursor-pointer font-semibold", ··· 33 </button> 34 <button 35 type="button" 36 - hx-get="/?tab=following" 37 hx-target="#timeline-page" 38 hx-swap="outerHTML" 39 class={cn( ··· 51 </div> 52 </div> 53 <div id="tab-content" role="tabpanel"> 54 <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} />)} 56 </ul> 57 </div> 58 </> ··· 70 </div> 71 ); 72 }
··· 4 import { TimelineItem as Item } from "./TimelineItem.tsx"; 5 6 export function Timeline( 7 + { isLoggedIn, selectedTab, items, actorProfiles, selectedGraph }: Readonly< 8 + { 9 + isLoggedIn: boolean; 10 + selectedTab: string; 11 + items: TimelineItem[]; 12 + actorProfiles: string[]; 13 + selectedGraph: string; 14 + } 15 >, 16 ) { 17 return ( ··· 23 <div class="flex sm:w-fit"> 24 <button 25 type="button" 26 + hx-get={`/?graph=${selectedGraph}`} 27 + hx-target="#timeline-page" 28 hx-swap="outerHTML" 29 class={cn( 30 "flex-1 py-2 sm:min-w-[120px] px-4 cursor-pointer font-semibold", ··· 39 </button> 40 <button 41 type="button" 42 + hx-get={`/?tab=following&graph=${selectedGraph}`} 43 hx-target="#timeline-page" 44 hx-swap="outerHTML" 45 class={cn( ··· 57 </div> 58 </div> 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} 96 <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y w-fit"> 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 + )} 105 </ul> 106 </div> 107 </> ··· 119 </div> 120 ); 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 { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 - import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.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 { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; ··· 7 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 8 import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts"; 9 import { photoToView } from "./photo.ts"; 10 11 export function getActorProfile(did: string, ctx: BffContext) { 12 const actor = ctx.indexService.getActor(did); 13 if (!actor) return null; 14 - const profileRecord = ctx.indexService.getRecord<WithBffMeta<Profile>>( 15 `at://${did}/social.grain.actor.profile/self`, 16 ); 17 return profileRecord ? profileToView(profileRecord, actor.handle) : null; 18 } 19 20 export function profileToView( 21 - record: WithBffMeta<Profile>, 22 handle: string, 23 ): Un$Typed<ProfileView> { 24 return { ··· 124 new Set(galleries.map((gallery) => gallery.did)), 125 ); 126 127 - const { items: profiles } = ctx.indexService.getRecords<WithBffMeta<Profile>>( 128 "social.grain.actor.profile", 129 { 130 where: [{ field: "did", in: uniqueDids }], ··· 151 }) 152 .filter((g) => g !== null); 153 }
··· 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"; 3 import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 4 + import { Record as GrainProfile } from "$lexicon/types/social/grain/actor/profile.ts"; 5 import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 6 import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 7 import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; ··· 9 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 10 import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts"; 11 import { photoToView } from "./photo.ts"; 12 + import type { SocialNetwork } from "./timeline.ts"; 13 14 export function getActorProfile(did: string, ctx: BffContext) { 15 const actor = ctx.indexService.getActor(did); 16 if (!actor) return null; 17 + const profileRecord = ctx.indexService.getRecord<WithBffMeta<GrainProfile>>( 18 `at://${did}/social.grain.actor.profile/self`, 19 ); 20 return profileRecord ? profileToView(profileRecord, actor.handle) : null; 21 } 22 23 export function profileToView( 24 + record: WithBffMeta<GrainProfile>, 25 handle: string, 26 ): Un$Typed<ProfileView> { 27 return { ··· 127 new Set(galleries.map((gallery) => gallery.did)), 128 ); 129 130 + const { items: profiles } = ctx.indexService.getRecords< 131 + WithBffMeta<GrainProfile> 132 + >( 133 "social.grain.actor.profile", 134 { 135 where: [{ field: "did", in: uniqueDids }], ··· 156 }) 157 .filter((g) => g !== null); 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 import { Record as BskyFollow } from "$lexicon/types/app/bsky/graph/follow.ts"; 2 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 3 4 - export function getFollow( 5 followeeDid: string, 6 followerDid: string, 7 ctx: BffContext, 8 - ) { 9 - const { 10 - items: [follow], 11 - } = ctx.indexService.getRecords<WithBffMeta<BskyFollow>>( 12 "app.bsky.graph.follow", 13 - { 14 where: [ 15 { 16 field: "did", ··· 21 equals: followeeDid, 22 }, 23 ], 24 - }, 25 - ); 26 - return follow; 27 }
··· 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"; 4 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 5 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( 14 followeeDid: string, 15 followerDid: string, 16 ctx: BffContext, 17 + ): FollowMap { 18 + const sources: FollowSource[] = [ 19 "app.bsky.graph.follow", 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, { 32 where: [ 33 { 34 field: "did", ··· 39 equals: followeeDid, 40 }, 41 ], 42 + }); 43 + if (follow && "uri" in follow) { 44 + result[source] = follow.uri; 45 + } 46 + } 47 + return result; 48 }
+29 -11
src/lib/timeline.ts
··· 1 import { Record as BskyFollow } from "$lexicon/types/app/bsky/graph/follow.ts"; 2 import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 3 import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 4 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 5 import { Un$Typed } from "$lexicon/util.ts"; 6 import { AtUri } from "@atproto/syntax"; 7 import { BffContext, QueryOptions, WithBffMeta } from "@bigmoves/bff"; 8 import { getActorProfile } from "./actor.ts"; 9 import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts"; 10 11 - type TimelineItemType = "gallery"; 12 13 export type TimelineItem = { 14 createdAt: string; ··· 33 ? [{ field: "did", equals: options.actorDid }] 34 : undefined; 35 36 - if (options?.followingDids && options.followingDids.size > 0) { 37 - whereClause = [ 38 - ...(whereClause ?? []), 39 - { field: "did", in: Array.from(options.followingDids) }, 40 - ]; 41 } 42 43 const { items: galleries } = ctx.indexService.getRecords< ··· 86 ); 87 } 88 89 - function getFollowingDids(ctx: BffContext): Set<string> { 90 if (!ctx.currentUser?.did) return new Set(); 91 const { items: follows } = ctx.indexService.getRecords< 92 - WithBffMeta<BskyFollow> 93 >( 94 - "app.bsky.graph.follow", 95 { where: [{ field: "did", equals: ctx.currentUser.did }] }, 96 ); 97 return new Set(follows.map((f) => f.subject).filter(Boolean)); ··· 99 100 export function getTimeline( 101 ctx: BffContext, 102 - type: "timeline" | "following" = "timeline", 103 ): TimelineItem[] { 104 let followingDids: Set<string> | undefined = undefined; 105 if (type === "following") { 106 - followingDids = getFollowingDids(ctx); 107 } 108 const galleryItems = processGalleries(ctx, { followingDids }); 109 return galleryItems.sort(
··· 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 { ProfileView } from "$lexicon/types/social/grain/actor/defs.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 GrainFollow } from "$lexicon/types/social/grain/graph/follow.ts"; 7 import { Un$Typed } from "$lexicon/util.ts"; 8 import { AtUri } from "@atproto/syntax"; 9 import { BffContext, QueryOptions, WithBffMeta } from "@bigmoves/bff"; 10 import { getActorProfile } from "./actor.ts"; 11 import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts"; 12 13 + export type TimelineItemType = "gallery"; 14 + 15 + export type SocialNetwork = "bluesky" | "grain" | "tangled"; 16 17 export type TimelineItem = { 18 createdAt: string; ··· 37 ? [{ field: "did", equals: options.actorDid }] 38 : undefined; 39 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 + } 49 } 50 51 const { items: galleries } = ctx.indexService.getRecords< ··· 94 ); 95 } 96 97 + function getFollowingDids(type: SocialNetwork, ctx: BffContext): Set<string> { 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 + } 108 const { items: follows } = ctx.indexService.getRecords< 109 + WithBffMeta<BskyFollow | GrainFollow | TangledFollow> 110 >( 111 + collection, 112 { where: [{ field: "did", equals: ctx.currentUser.did }] }, 113 ); 114 return new Set(follows.map((f) => f.subject).filter(Boolean)); ··· 116 117 export function getTimeline( 118 ctx: BffContext, 119 + type: "timeline" | "following", 120 + graph: SocialNetwork, 121 ): TimelineItem[] { 122 let followingDids: Set<string> | undefined = undefined; 123 if (type === "following") { 124 + followingDids = getFollowingDids(graph, ctx); 125 } 126 const galleryItems = processGalleries(ctx, { followingDids }); 127 return galleryItems.sort(
+5 -1
src/main.tsx
··· 29 "social.grain.photo", 30 "social.grain.favorite", 31 "social.grain.gallery.item", 32 ], 33 externalCollections: [ 34 "app.bsky.actor.profile", 35 "app.bsky.graph.follow", 36 ], 37 jetstreamUrl: JETSTREAM.WEST_1, 38 lexicons, ··· 75 "/dialogs/photo-select/:galleryRkey", 76 dialogHandlers.galleryPhotoSelect, 77 ), 78 route("/actions/update-seen", ["POST"], actionHandlers.updateSeen), 79 route("/actions/follow/:did", ["POST"], actionHandlers.follow), 80 route( 81 - "/actions/follow/:followeeDid/:rkey", 82 ["DELETE"], 83 actionHandlers.unfollow, 84 ),
··· 29 "social.grain.photo", 30 "social.grain.favorite", 31 "social.grain.gallery.item", 32 + "social.grain.graph.follow", 33 ], 34 externalCollections: [ 35 "app.bsky.actor.profile", 36 "app.bsky.graph.follow", 37 + "sh.tangled.actor.profile", 38 + "sh.tangled.graph.follow", 39 ], 40 jetstreamUrl: JETSTREAM.WEST_1, 41 lexicons, ··· 78 "/dialogs/photo-select/:galleryRkey", 79 dialogHandlers.galleryPhotoSelect, 80 ), 81 + route("/dialogs/follows/:followeeDid", dialogHandlers.follows), 82 route("/actions/update-seen", ["POST"], actionHandlers.updateSeen), 83 route("/actions/follow/:did", ["POST"], actionHandlers.follow), 84 route( 85 + "/actions/follow/:followeeDid", 86 ["DELETE"], 87 actionHandlers.unfollow, 88 ),
+39 -8
src/routes/actions.tsx
··· 12 import { PhotoButton } from "../components/PhotoButton.tsx"; 13 import { PhotoPreview } from "../components/PhotoPreview.tsx"; 14 import { PhotoSelectButton } from "../components/PhotoSelectButton.tsx"; 15 import { deleteGallery, getGallery, getGalleryFavs } from "../lib/gallery.ts"; 16 import { photoThumb, photoToView } from "../lib/photo.ts"; 17 import type { State } from "../state.ts"; ··· 28 }; 29 30 export const follow: RouteHandler = async ( 31 - _req, 32 params, 33 ctx: BffContext<State>, 34 ) => { 35 ctx.requireAuth(); 36 const did = params.did; 37 - if (!did) return ctx.next(); 38 const followUri = await ctx.createRecord<BskyFollow>( 39 - "app.bsky.graph.follow", 40 { 41 subject: did, 42 createdAt: new Date().toISOString(), 43 }, 44 ); 45 return ctx.html(<FollowButton followeeDid={did} followUri={followUri} />); 46 }; 47 48 export const unfollow: RouteHandler = async ( 49 - _req, 50 params, 51 ctx: BffContext<State>, 52 ) => { 53 - const { did } = ctx.requireAuth(); 54 const followeeDid = params.followeeDid; 55 - const rkey = params.rkey; 56 await ctx.deleteRecord( 57 - `at://${did}/app.bsky.graph.follow/${rkey}`, 58 ); 59 return ctx.html( 60 - <FollowButton followeeDid={followeeDid} followUri={undefined} />, 61 ); 62 }; 63
··· 12 import { PhotoButton } from "../components/PhotoButton.tsx"; 13 import { PhotoPreview } from "../components/PhotoPreview.tsx"; 14 import { PhotoSelectButton } from "../components/PhotoSelectButton.tsx"; 15 + import { BadRequestError } from "../lib/errors.ts"; 16 import { deleteGallery, getGallery, getGalleryFavs } from "../lib/gallery.ts"; 17 import { photoThumb, photoToView } from "../lib/photo.ts"; 18 import type { State } from "../state.ts"; ··· 29 }; 30 31 export const follow: RouteHandler = async ( 32 + req, 33 params, 34 ctx: BffContext<State>, 35 ) => { 36 ctx.requireAuth(); 37 const did = params.did; 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 + } 45 const followUri = await ctx.createRecord<BskyFollow>( 46 + collection, 47 { 48 subject: did, 49 createdAt: new Date().toISOString(), 50 }, 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 + } 64 return ctx.html(<FollowButton followeeDid={did} followUri={followUri} />); 65 }; 66 67 export const unfollow: RouteHandler = async ( 68 + req, 69 params, 70 ctx: BffContext<State>, 71 ) => { 72 + ctx.requireAuth(); 73 const followeeDid = params.followeeDid; 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 + } 80 await ctx.deleteRecord( 81 + uri, 82 ); 83 return ctx.html( 84 + <FollowButton 85 + {...!hideCollection && { 86 + class: "sm:w-full", 87 + collection: new AtUri(uri).collection, 88 + }} 89 + followeeDid={followeeDid} 90 + followUri={undefined} 91 + />, 92 ); 93 }; 94
+29 -1
src/routes/dialogs.tsx
··· 7 import { wrap } from "popmotion"; 8 import { AvatarDialog } from "../components/AvatarDialog.tsx"; 9 import { CreateAccountDialog } from "../components/CreateAccountDialog.tsx"; 10 import { GalleryCreateEditDialog } from "../components/GalleryCreateEditDialog.tsx"; 11 import { GallerySortDialog } from "../components/GallerySortDialog.tsx"; 12 import { PhotoAltDialog } from "../components/PhotoAltDialog.tsx"; 13 import { PhotoDialog } from "../components/PhotoDialog.tsx"; 14 import { PhotoSelectDialog } from "../components/PhotoSelectDialog.tsx"; 15 import { ProfileDialog } from "../components/ProfileDialog.tsx"; 16 - import { getActorPhotos, getActorProfile } from "../lib/actor.ts"; 17 import { getGallery, getGalleryItemsAndPhotos } from "../lib/gallery.ts"; 18 import { photoToView } from "../lib/photo.ts"; 19 import type { State } from "../state.ts"; ··· 162 ) => { 163 return ctx.html(<CreateAccountDialog />); 164 };
··· 7 import { wrap } from "popmotion"; 8 import { AvatarDialog } from "../components/AvatarDialog.tsx"; 9 import { CreateAccountDialog } from "../components/CreateAccountDialog.tsx"; 10 + import { FollowsDialog } from "../components/FollowsDialog.tsx"; 11 import { GalleryCreateEditDialog } from "../components/GalleryCreateEditDialog.tsx"; 12 import { GallerySortDialog } from "../components/GallerySortDialog.tsx"; 13 import { PhotoAltDialog } from "../components/PhotoAltDialog.tsx"; 14 import { PhotoDialog } from "../components/PhotoDialog.tsx"; 15 import { PhotoSelectDialog } from "../components/PhotoSelectDialog.tsx"; 16 import { ProfileDialog } from "../components/ProfileDialog.tsx"; 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"; 24 import { getGallery, getGalleryItemsAndPhotos } from "../lib/gallery.ts"; 25 import { photoToView } from "../lib/photo.ts"; 26 import type { State } from "../state.ts"; ··· 169 ) => { 170 return ctx.html(<CreateAccountDialog />); 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"; 3 import { ProfilePage, ProfileTabs } from "../components/ProfilePage.tsx"; 4 import { 5 getActorGalleries, 6 getActorGalleryFavs, 7 getActorProfile, 8 } from "../lib/actor.ts"; 9 - import { getFollow } from "../lib/follow.ts"; 10 - import { getActorTimeline } from "../lib/timeline.ts"; 11 import { getPageMeta } from "../meta.ts"; 12 import type { State } from "../state.ts"; 13 import { profileLink } from "../utils.ts"; ··· 31 32 if (!profile) return ctx.next(); 33 34 - let follow: WithBffMeta<BskyFollow> | undefined; 35 36 if (ctx.currentUser) { 37 - follow = getFollow(profile.did, ctx.currentUser.did, ctx); 38 } 39 40 ctx.state.meta = [ 41 { ··· 52 const galleryFavs = getActorGalleryFavs(handle, ctx); 53 return render( 54 <ProfilePage 55 - followUri={follow?.uri} 56 loggedInUserDid={ctx.currentUser?.did} 57 timelineItems={timelineItems} 58 profile={profile} ··· 66 const galleries = getActorGalleries(handle, ctx); 67 return render( 68 <ProfilePage 69 - followUri={follow?.uri} 70 loggedInUserDid={ctx.currentUser?.did} 71 timelineItems={timelineItems} 72 profile={profile} ··· 77 } 78 return ctx.render( 79 <ProfilePage 80 - followUri={follow?.uri} 81 loggedInUserDid={ctx.currentUser?.did} 82 timelineItems={timelineItems} 83 profile={profile}
··· 1 + import { BffContext, RouteHandler } from "@bigmoves/bff"; 2 import { ProfilePage, ProfileTabs } from "../components/ProfilePage.tsx"; 3 import { 4 getActorGalleries, 5 getActorGalleryFavs, 6 getActorProfile, 7 + getActorProfiles, 8 } from "../lib/actor.ts"; 9 + import { type FollowMap, getFollows } from "../lib/follow.ts"; 10 + import { getActorTimeline, type SocialNetwork } from "../lib/timeline.ts"; 11 import { getPageMeta } from "../meta.ts"; 12 import type { State } from "../state.ts"; 13 import { profileLink } from "../utils.ts"; ··· 31 32 if (!profile) return ctx.next(); 33 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[] = []; 41 42 if (ctx.currentUser) { 43 + followMap = getFollows(profile.did, ctx.currentUser.did, ctx); 44 + actorProfiles = getActorProfiles(ctx.currentUser.did, ctx); 45 } 46 + 47 + userProfiles = getActorProfiles(handle, ctx); 48 49 ctx.state.meta = [ 50 { ··· 61 const galleryFavs = getActorGalleryFavs(handle, ctx); 62 return render( 63 <ProfilePage 64 + userProfiles={userProfiles} 65 + actorProfiles={actorProfiles} 66 + followMap={followMap} 67 loggedInUserDid={ctx.currentUser?.did} 68 timelineItems={timelineItems} 69 profile={profile} ··· 77 const galleries = getActorGalleries(handle, ctx); 78 return render( 79 <ProfilePage 80 + userProfiles={userProfiles} 81 + actorProfiles={actorProfiles} 82 + followMap={followMap} 83 loggedInUserDid={ctx.currentUser?.did} 84 timelineItems={timelineItems} 85 profile={profile} ··· 90 } 91 return ctx.render( 92 <ProfilePage 93 + userProfiles={userProfiles} 94 + actorProfiles={actorProfiles} 95 + followMap={followMap} 96 loggedInUserDid={ctx.currentUser?.did} 97 timelineItems={timelineItems} 98 profile={profile}
+58 -25
src/routes/timeline.tsx
··· 1 import { BffContext, RouteHandler } from "@bigmoves/bff"; 2 import { getCookies, setCookie } from "@std/http"; 3 import { Timeline } from "../components/Timeline.tsx"; 4 - import { getTimeline } from "../lib/timeline.ts"; 5 import { getPageMeta } from "../meta.ts"; 6 import type { State } from "../state.ts"; 7 ··· 12 ) => { 13 const url = new URL(req.url); 14 const tabSearchParam = url.searchParams.get("tab") || ""; 15 - const cookieState = getCookieState(req.headers); 16 let tab; 17 let headers: Record<string, string> = {}; 18 19 if (!ctx.currentUser) { 20 tab = ""; 21 - } else if (!req.headers.get("hx-request")) { 22 tab = cookieState.lastSelectedHomeFeed || ""; 23 } else { 24 tab = tabSearchParam || ""; 25 headers = setCookieState(url.hostname, { 26 lastSelectedHomeFeed: tab, 27 }); 28 } 29 30 const items = getTimeline( 31 ctx, 32 tab === "following" ? "following" : "timeline", 33 ); 34 35 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( 48 <Timeline 49 isLoggedIn={!!ctx.currentUser} 50 selectedTab={tab} 51 items={items} 52 />, 53 headers, 54 ); ··· 56 57 ctx.state.meta = [{ title: "Timeline — Grain" }, ...getPageMeta("")]; 58 59 - return ctx.render( 60 - <Timeline isLoggedIn={!!ctx.currentUser} selectedTab={tab} items={items} />, 61 headers, 62 ); 63 }; 64 65 type GrainStorageState = { 66 lastSelectedHomeFeed?: string; 67 - }; 68 - 69 - const defaultGrainStorageState: GrainStorageState = { 70 - lastSelectedHomeFeed: undefined, 71 }; 72 73 function setCookieState( ··· 92 } 93 94 function getCookieState( 95 headers: Headers, 96 ): GrainStorageState { 97 const cookies = getCookies(headers); 98 if (!cookies.grain_storage) { 99 - return defaultGrainStorageState; 100 } 101 const grainStorage = atob(cookies.grain_storage); 102 if (grainStorage) { 103 try { 104 - return JSON.parse(grainStorage); 105 } catch { 106 - return defaultGrainStorageState; 107 } 108 } 109 - return defaultGrainStorageState; 110 }
··· 1 import { BffContext, RouteHandler } from "@bigmoves/bff"; 2 import { getCookies, setCookie } from "@std/http"; 3 import { Timeline } from "../components/Timeline.tsx"; 4 + import { getActorProfiles } from "../lib/actor.ts"; 5 + import { getTimeline, SocialNetwork } from "../lib/timeline.ts"; 6 import { getPageMeta } from "../meta.ts"; 7 import type { State } from "../state.ts"; 8 ··· 13 ) => { 14 const url = new URL(req.url); 15 const tabSearchParam = url.searchParams.get("tab") || ""; 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 + 22 let tab; 23 + let graph: SocialNetwork = "grain"; 24 let headers: Record<string, string> = {}; 25 26 + const actorProfiles = getActorProfiles(ctx?.currentUser?.did ?? "", ctx); 27 + 28 if (!ctx.currentUser) { 29 tab = ""; 30 + } else if (!isHxRequest) { 31 tab = cookieState.lastSelectedHomeFeed || ""; 32 + graph = cookieState.lastSelectedFollowGraph || "grain"; 33 } else { 34 tab = tabSearchParam || ""; 35 + graph = graphSearchParam; 36 headers = setCookieState(url.hostname, { 37 + did: ctx.currentUser.did, 38 lastSelectedHomeFeed: tab, 39 + lastSelectedFollowGraph: graph, 40 }); 41 } 42 43 + if (!graph && actorProfiles.length > 0) { 44 + graph = actorProfiles[0]; 45 + } 46 + 47 const items = getTimeline( 48 ctx, 49 tab === "following" ? "following" : "timeline", 50 + graph, 51 ); 52 53 if (tab === "following") { 54 + ctx.state.meta = [{ title: "Following — Grain" }, ...getPageMeta("")]; 55 + return render( 56 <Timeline 57 isLoggedIn={!!ctx.currentUser} 58 selectedTab={tab} 59 items={items} 60 + selectedGraph={graph} 61 + actorProfiles={actorProfiles} 62 />, 63 headers, 64 ); ··· 66 67 ctx.state.meta = [{ title: "Timeline — Grain" }, ...getPageMeta("")]; 68 69 + return render( 70 + <Timeline 71 + isLoggedIn={!!ctx.currentUser} 72 + selectedTab={tab} 73 + items={items} 74 + selectedGraph={graph} 75 + actorProfiles={actorProfiles} 76 + />, 77 headers, 78 ); 79 }; 80 81 type GrainStorageState = { 82 + did?: string; 83 lastSelectedHomeFeed?: string; 84 + lastSelectedFollowGraph?: SocialNetwork; 85 }; 86 87 function setCookieState( ··· 106 } 107 108 function getCookieState( 109 + did: string | undefined, 110 headers: Headers, 111 ): GrainStorageState { 112 const cookies = getCookies(headers); 113 if (!cookies.grain_storage) { 114 + return createDefaultCookieState(did); 115 } 116 const grainStorage = atob(cookies.grain_storage); 117 if (grainStorage) { 118 try { 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; 128 } catch { 129 + return createDefaultCookieState(did); 130 } 131 } 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 + }; 143 }
+27
static/styles.css
··· 36 --text-3xl--line-height: calc(2.25 / 1.875); 37 --text-4xl: 2.25rem; 38 --text-4xl--line-height: calc(2.5 / 2.25); 39 --font-weight-semibold: 600; 40 --font-weight-bold: 700; 41 --default-font-family: var(--font-sans); ··· 316 .mr-2 { 317 margin-right: calc(var(--spacing) * 2); 318 } 319 .mb-2 { 320 margin-bottom: calc(var(--spacing) * 2); 321 } ··· 564 } 565 .overflow-x-auto { 566 overflow-x: auto; 567 } 568 .rounded-full { 569 border-radius: calc(infinity * 1px); ··· 633 .p-4 { 634 padding: calc(var(--spacing) * 4); 635 } 636 .px-4 { 637 padding-inline: calc(var(--spacing) * 4); 638 } 639 .px-\[3px\] { 640 padding-inline: 3px; 641 } 642 .py-2 { 643 padding-block: calc(var(--spacing) * 2); ··· 710 --tw-font-weight: var(--font-weight-bold); 711 font-weight: var(--font-weight-bold); 712 } 713 .font-semibold { 714 --tw-font-weight: var(--font-weight-semibold); 715 font-weight: var(--font-weight-semibold); ··· 858 width: fit-content; 859 } 860 } 861 .sm\:max-w-\[400px\] { 862 @media (width >= 40rem) { 863 max-width: 400px; ··· 948 :where(& > :not(:last-child)) { 949 border-color: var(--color-zinc-800); 950 } 951 } 952 } 953 .dark\:border-zinc-800 {
··· 36 --text-3xl--line-height: calc(2.25 / 1.875); 37 --text-4xl: 2.25rem; 38 --text-4xl--line-height: calc(2.5 / 2.25); 39 + --font-weight-medium: 500; 40 --font-weight-semibold: 600; 41 --font-weight-bold: 700; 42 --default-font-family: var(--font-sans); ··· 317 .mr-2 { 318 margin-right: calc(var(--spacing) * 2); 319 } 320 + .mb-1 { 321 + margin-bottom: calc(var(--spacing) * 1); 322 + } 323 .mb-2 { 324 margin-bottom: calc(var(--spacing) * 2); 325 } ··· 568 } 569 .overflow-x-auto { 570 overflow-x: auto; 571 + } 572 + .rounded { 573 + border-radius: 0.25rem; 574 } 575 .rounded-full { 576 border-radius: calc(infinity * 1px); ··· 640 .p-4 { 641 padding: calc(var(--spacing) * 4); 642 } 643 + .px-2 { 644 + padding-inline: calc(var(--spacing) * 2); 645 + } 646 .px-4 { 647 padding-inline: calc(var(--spacing) * 4); 648 } 649 .px-\[3px\] { 650 padding-inline: 3px; 651 + } 652 + .py-1 { 653 + padding-block: calc(var(--spacing) * 1); 654 } 655 .py-2 { 656 padding-block: calc(var(--spacing) * 2); ··· 723 --tw-font-weight: var(--font-weight-bold); 724 font-weight: var(--font-weight-bold); 725 } 726 + .font-medium { 727 + --tw-font-weight: var(--font-weight-medium); 728 + font-weight: var(--font-weight-medium); 729 + } 730 .font-semibold { 731 --tw-font-weight: var(--font-weight-semibold); 732 font-weight: var(--font-weight-semibold); ··· 875 width: fit-content; 876 } 877 } 878 + .sm\:w-full { 879 + @media (width >= 40rem) { 880 + width: 100%; 881 + } 882 + } 883 .sm\:max-w-\[400px\] { 884 @media (width >= 40rem) { 885 max-width: 400px; ··· 970 :where(& > :not(:last-child)) { 971 border-color: var(--color-zinc-800); 972 } 973 + } 974 + } 975 + .dark\:border-zinc-700 { 976 + @media (prefers-color-scheme: dark) { 977 + border-color: var(--color-zinc-700); 978 } 979 } 980 .dark\:border-zinc-800 {