+50
__generated__/index.ts
+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
+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
+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
+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
+
}
+1
-1
deno.json
+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
+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
+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
+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
+
}
+47
-8
src/components/FollowButton.tsx
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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 {