+13
.zed/settings.json
+13
.zed/settings.json
+98
-55
deno.lock
+98
-55
deno.lock
···
1
1
{
2
2
"version": "5",
3
3
"specifiers": {
4
-
"npm:@atcute/atproto@^3.1.9": "3.1.9",
4
+
"npm:@atcute/atproto@^3.1.10": "3.1.10",
5
5
"npm:@atcute/bluesky-richtext-builder@^2.0.4": "2.0.4",
6
6
"npm:@atcute/bluesky-richtext-segmenter@^2.0.4": "2.0.4",
7
7
"npm:@atcute/bluesky@^3.2.14": "3.2.14",
8
-
"npm:@atcute/client@^4.1.1": "4.1.1",
8
+
"npm:@atcute/client@^4.2.0": "4.2.0",
9
9
"npm:@atcute/identity-resolver@^1.2.1": "1.2.1_@atcute+identity@1.1.3",
10
10
"npm:@atcute/identity@^1.1.3": "1.1.3",
11
11
"npm:@atcute/jetstream@^1.1.2": "1.1.2",
12
-
"npm:@atcute/lexicons@^1.2.5": "1.2.5",
12
+
"npm:@atcute/lexicons@^1.2.6": "1.2.6",
13
13
"npm:@atcute/oauth-browser-client@^2.0.3": "2.0.3_@atcute+identity@1.1.3",
14
-
"npm:@atcute/tid@^1.0.3": "1.0.3",
14
+
"npm:@atcute/tid@^1.1.1": "1.1.1",
15
15
"npm:@eslint/compat@2": "2.0.0_eslint@9.39.2",
16
16
"npm:@eslint/js@^9.39.2": "9.39.2",
17
17
"npm:@floating-ui/dom@^1.7.4": "1.7.4",
···
22
22
"npm:@sveltejs/vite-plugin-svelte@^6.2.1": "6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3",
23
23
"npm:@tailwindcss/forms@~0.5.11": "0.5.11_tailwindcss@4.1.18",
24
24
"npm:@tailwindcss/vite@^4.1.18": "4.1.18_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3",
25
-
"npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.17": "3.0.17_svelte@5.46.1__acorn@8.15.0",
25
+
"npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.18": "3.0.18_svelte@5.46.1__acorn@8.15.0",
26
26
"npm:@types/node@^25.0.3": "25.0.3",
27
27
"npm:@wora/cache-persist@^2.2.1": "2.2.1",
28
28
"npm:async-cache-dedupe@^3.4.0": "3.4.0",
29
29
"npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.39.2",
30
30
"npm:eslint-plugin-svelte@^3.13.1": "3.13.1_eslint@9.39.2_svelte@5.46.1__acorn@8.15.0_postcss@8.5.6",
31
31
"npm:eslint@^9.39.2": "9.39.2",
32
-
"npm:globals@^16.5.0": "16.5.0",
32
+
"npm:globals@17": "17.0.0",
33
33
"npm:hash-wasm@^4.12.0": "4.12.0",
34
34
"npm:lru-cache@^11.2.4": "11.2.4",
35
+
"npm:photoswipe@^5.4.4": "5.4.4",
35
36
"npm:prettier-plugin-svelte@^3.4.1": "3.4.1_prettier@3.7.4_svelte@5.46.1__acorn@8.15.0",
36
37
"npm:prettier-plugin-tailwindcss@~0.7.2": "0.7.2_prettier@3.7.4_prettier-plugin-svelte@3.4.1__prettier@3.7.4__svelte@5.46.1___acorn@8.15.0_svelte@5.46.1__acorn@8.15.0",
37
38
"npm:prettier@^3.7.4": "3.7.4",
···
42
43
"npm:svelte-portal@^2.2.1": "2.2.1",
43
44
"npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0",
44
45
"npm:tailwindcss@^4.1.18": "4.1.18",
45
-
"npm:typescript-eslint@^8.50.1": "8.50.1_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.50.1__eslint@9.39.2__typescript@5.9.3",
46
+
"npm:typescript-eslint@^8.51.0": "8.51.0_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.51.0__eslint@9.39.2__typescript@5.9.3",
46
47
"npm:typescript@^5.9.3": "5.9.3",
47
48
"npm:vite@^7.3.0": "7.3.0_@types+node@25.0.3_picomatch@4.0.3"
48
49
},
49
50
"npm": {
50
-
"@atcute/atproto@3.1.9": {
51
-
"integrity": "sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==",
51
+
"@atcute/atproto@3.1.10": {
52
+
"integrity": "sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==",
52
53
"dependencies": [
53
54
"@atcute/lexicons"
54
55
]
···
74
75
"@atcute/lexicons"
75
76
]
76
77
},
77
-
"@atcute/client@4.1.1": {
78
-
"integrity": "sha512-FROCbTTCeL5u4tO/n72jDEKyKqjdlXMB56Ehve3W/gnnLGCYWvN42sS7tvL1Mgu6sbO3yZwsXKDrmM2No4XpjA==",
78
+
"@atcute/client@4.2.0": {
79
+
"integrity": "sha512-vYixpXevM+dkzN4HGTfmxCJBOXNSRAMki1bfi1atFdmZGo9n1zStyClHqn0SRs8I5nOQoVG1XBkYAGSFJWqy2Q==",
79
80
"dependencies": [
80
81
"@atcute/identity",
81
82
"@atcute/lexicons"
···
109
110
"yocto-queue@1.2.2"
110
111
]
111
112
},
112
-
"@atcute/lexicons@1.2.5": {
113
-
"integrity": "sha512-9yO9WdgxW8jZ7SbzUycH710z+JmsQ9W9n5S6i6eghYju32kkluFmgBeS47r8e8p2+Dv4DemS7o/3SUGsX9FR5Q==",
113
+
"@atcute/lexicons@1.2.6": {
114
+
"integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==",
114
115
"dependencies": [
116
+
"@atcute/uint8array",
117
+
"@atcute/util-text",
115
118
"@standard-schema/spec",
116
119
"esm-env"
117
120
]
···
133
136
"nanoid@5.1.6"
134
137
]
135
138
},
136
-
"@atcute/tid@1.0.3": {
137
-
"integrity": "sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w=="
139
+
"@atcute/tid@1.1.1": {
140
+
"integrity": "sha512-djJ8UGhLkTU5V51yCnBEruMg35qETjWzWy5sJG/2gEOl2Gd7rQWHSaf+yrO6vMS5EFA38U2xOWE3EDUPzvc2ZQ==",
141
+
"dependencies": [
142
+
"@atcute/time-ms"
143
+
]
144
+
},
145
+
"@atcute/time-ms@1.0.0": {
146
+
"integrity": "sha512-iWEOlMBcO3ktB+zQPC2kXka9H/798we+IWq2sjhb+hQJNNfcJrwejzvNi/68Q3jKo/hdfwZjRU9iF8U6D32/2Q==",
147
+
"dependencies": [
148
+
"@types/node@22.19.3",
149
+
"node-gyp-build"
150
+
],
151
+
"scripts": true
138
152
},
139
153
"@atcute/uint8array@1.0.6": {
140
154
"integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A=="
···
143
157
"integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==",
144
158
"dependencies": [
145
159
"@badrap/valita"
160
+
]
161
+
},
162
+
"@atcute/util-text@0.0.1": {
163
+
"integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==",
164
+
"dependencies": [
165
+
"unicode-segmenter"
146
166
]
147
167
},
148
168
"@badrap/valita@0.4.6": {
···
278
298
"os": ["win32"],
279
299
"cpu": ["x64"]
280
300
},
281
-
"@eslint-community/eslint-utils@4.9.0_eslint@9.39.2": {
282
-
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
301
+
"@eslint-community/eslint-utils@4.9.1_eslint@9.39.2": {
302
+
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
283
303
"dependencies": [
284
304
"eslint",
285
305
"eslint-visitor-keys@3.4.3"
···
717
737
"vite"
718
738
]
719
739
},
720
-
"@tutorlatin/svelte-tiny-virtual-list@3.0.17_svelte@5.46.1__acorn@8.15.0": {
721
-
"integrity": "sha512-OvFRITfbWdsFk7VR2FKVJiBMPlgbyc81hqbFORXdEcBXcT91XRdLXfhSbS8o14ntUBMFPWVv19fti+Ez50q45g==",
740
+
"@tutorlatin/svelte-tiny-virtual-list@3.0.18_svelte@5.46.1__acorn@8.15.0": {
741
+
"integrity": "sha512-In7ASkVkLhg0ClWEVA50J/hWrGovwkw7dfHYlUyQJz4rPvh1TGpfo0lN1mXWERj2bkWY8AYITSbBZnVz34tcfA==",
722
742
"dependencies": [
723
743
"svelte"
724
744
]
···
732
752
"@types/json-schema@7.0.15": {
733
753
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
734
754
},
755
+
"@types/node@22.19.3": {
756
+
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
757
+
"dependencies": [
758
+
"undici-types@6.21.0"
759
+
]
760
+
},
735
761
"@types/node@25.0.3": {
736
762
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
737
763
"dependencies": [
738
-
"undici-types"
764
+
"undici-types@7.16.0"
739
765
]
740
766
},
741
-
"@typescript-eslint/eslint-plugin@8.50.1_@typescript-eslint+parser@8.50.1__eslint@9.39.2__typescript@5.9.3_eslint@9.39.2_typescript@5.9.3": {
742
-
"integrity": "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==",
767
+
"@typescript-eslint/eslint-plugin@8.51.0_@typescript-eslint+parser@8.51.0__eslint@9.39.2__typescript@5.9.3_eslint@9.39.2_typescript@5.9.3": {
768
+
"integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==",
743
769
"dependencies": [
744
770
"@eslint-community/regexpp",
745
771
"@typescript-eslint/parser",
···
754
780
"typescript"
755
781
]
756
782
},
757
-
"@typescript-eslint/parser@8.50.1_eslint@9.39.2_typescript@5.9.3": {
758
-
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
783
+
"@typescript-eslint/parser@8.51.0_eslint@9.39.2_typescript@5.9.3": {
784
+
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
759
785
"dependencies": [
760
786
"@typescript-eslint/scope-manager",
761
787
"@typescript-eslint/types",
···
766
792
"typescript"
767
793
]
768
794
},
769
-
"@typescript-eslint/project-service@8.50.1_typescript@5.9.3": {
770
-
"integrity": "sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==",
795
+
"@typescript-eslint/project-service@8.51.0_typescript@5.9.3": {
796
+
"integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==",
771
797
"dependencies": [
772
798
"@typescript-eslint/tsconfig-utils",
773
799
"@typescript-eslint/types",
···
775
801
"typescript"
776
802
]
777
803
},
778
-
"@typescript-eslint/scope-manager@8.50.1": {
779
-
"integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==",
804
+
"@typescript-eslint/scope-manager@8.51.0": {
805
+
"integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==",
780
806
"dependencies": [
781
807
"@typescript-eslint/types",
782
808
"@typescript-eslint/visitor-keys"
783
809
]
784
810
},
785
-
"@typescript-eslint/tsconfig-utils@8.50.1_typescript@5.9.3": {
786
-
"integrity": "sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==",
811
+
"@typescript-eslint/tsconfig-utils@8.51.0_typescript@5.9.3": {
812
+
"integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==",
787
813
"dependencies": [
788
814
"typescript"
789
815
]
790
816
},
791
-
"@typescript-eslint/type-utils@8.50.1_eslint@9.39.2_typescript@5.9.3": {
792
-
"integrity": "sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==",
817
+
"@typescript-eslint/type-utils@8.51.0_eslint@9.39.2_typescript@5.9.3": {
818
+
"integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==",
793
819
"dependencies": [
794
820
"@typescript-eslint/types",
795
821
"@typescript-eslint/typescript-estree",
···
800
826
"typescript"
801
827
]
802
828
},
803
-
"@typescript-eslint/types@8.50.1": {
804
-
"integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA=="
829
+
"@typescript-eslint/types@8.51.0": {
830
+
"integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="
805
831
},
806
-
"@typescript-eslint/typescript-estree@8.50.1_typescript@5.9.3": {
807
-
"integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==",
832
+
"@typescript-eslint/typescript-estree@8.51.0_typescript@5.9.3": {
833
+
"integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==",
808
834
"dependencies": [
809
835
"@typescript-eslint/project-service",
810
836
"@typescript-eslint/tsconfig-utils",
···
818
844
"typescript"
819
845
]
820
846
},
821
-
"@typescript-eslint/utils@8.50.1_eslint@9.39.2_typescript@5.9.3": {
822
-
"integrity": "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==",
847
+
"@typescript-eslint/utils@8.51.0_eslint@9.39.2_typescript@5.9.3": {
848
+
"integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==",
823
849
"dependencies": [
824
850
"@eslint-community/eslint-utils",
825
851
"@typescript-eslint/scope-manager",
···
829
855
"typescript"
830
856
]
831
857
},
832
-
"@typescript-eslint/visitor-keys@8.50.1": {
833
-
"integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==",
858
+
"@typescript-eslint/visitor-keys@8.51.0": {
859
+
"integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==",
834
860
"dependencies": [
835
861
"@typescript-eslint/types",
836
862
"eslint-visitor-keys@4.2.1"
···
1100
1126
"eslint-visitor-keys@4.2.1"
1101
1127
]
1102
1128
},
1103
-
"esquery@1.6.0": {
1104
-
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
1129
+
"esquery@1.7.0": {
1130
+
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
1105
1131
"dependencies": [
1106
1132
"estraverse"
1107
1133
]
···
1184
1210
},
1185
1211
"globals@16.5.0": {
1186
1212
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="
1213
+
},
1214
+
"globals@17.0.0": {
1215
+
"integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw=="
1187
1216
},
1188
1217
"graceful-fs@4.2.11": {
1189
1218
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
···
1410
1439
"natural-compare@1.4.0": {
1411
1440
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
1412
1441
},
1442
+
"node-gyp-build@4.8.4": {
1443
+
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
1444
+
"bin": true
1445
+
},
1413
1446
"obliterator@2.0.5": {
1414
1447
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="
1415
1448
},
···
1453
1486
},
1454
1487
"path-key@3.1.1": {
1455
1488
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
1489
+
},
1490
+
"photoswipe@5.4.4": {
1491
+
"integrity": "sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA=="
1456
1492
},
1457
1493
"picocolors@1.1.1": {
1458
1494
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
···
1701
1737
"totalist@3.0.1": {
1702
1738
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
1703
1739
},
1704
-
"ts-api-utils@2.1.0_typescript@5.9.3": {
1705
-
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
1740
+
"ts-api-utils@2.4.0_typescript@5.9.3": {
1741
+
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
1706
1742
"dependencies": [
1707
1743
"typescript"
1708
1744
]
···
1719
1755
"type-fest@4.41.0": {
1720
1756
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="
1721
1757
},
1722
-
"typescript-eslint@8.50.1_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.50.1__eslint@9.39.2__typescript@5.9.3": {
1723
-
"integrity": "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==",
1758
+
"typescript-eslint@8.51.0_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.51.0__eslint@9.39.2__typescript@5.9.3": {
1759
+
"integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==",
1724
1760
"dependencies": [
1725
1761
"@typescript-eslint/eslint-plugin",
1726
1762
"@typescript-eslint/parser",
···
1734
1770
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1735
1771
"bin": true
1736
1772
},
1773
+
"undici-types@6.21.0": {
1774
+
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
1775
+
},
1737
1776
"undici-types@7.16.0": {
1738
1777
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
1739
1778
},
1779
+
"unicode-segmenter@0.14.5": {
1780
+
"integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="
1781
+
},
1740
1782
"uri-js@4.4.1": {
1741
1783
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
1742
1784
"dependencies": [
···
1749
1791
"vite@7.3.0_@types+node@25.0.3_picomatch@4.0.3": {
1750
1792
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
1751
1793
"dependencies": [
1752
-
"@types/node",
1794
+
"@types/node@25.0.3",
1753
1795
"esbuild",
1754
1796
"fdir",
1755
1797
"picomatch",
···
1761
1803
"fsevents"
1762
1804
],
1763
1805
"optionalPeers": [
1764
-
"@types/node"
1806
+
"@types/node@25.0.3"
1765
1807
],
1766
1808
"bin": true
1767
1809
},
···
1800
1842
"workspace": {
1801
1843
"packageJson": {
1802
1844
"dependencies": [
1803
-
"npm:@atcute/atproto@^3.1.9",
1845
+
"npm:@atcute/atproto@^3.1.10",
1804
1846
"npm:@atcute/bluesky-richtext-builder@^2.0.4",
1805
1847
"npm:@atcute/bluesky-richtext-segmenter@^2.0.4",
1806
1848
"npm:@atcute/bluesky@^3.2.14",
1807
-
"npm:@atcute/client@^4.1.1",
1849
+
"npm:@atcute/client@^4.2.0",
1808
1850
"npm:@atcute/identity-resolver@^1.2.1",
1809
1851
"npm:@atcute/identity@^1.1.3",
1810
1852
"npm:@atcute/jetstream@^1.1.2",
1811
-
"npm:@atcute/lexicons@^1.2.5",
1853
+
"npm:@atcute/lexicons@^1.2.6",
1812
1854
"npm:@atcute/oauth-browser-client@^2.0.3",
1813
-
"npm:@atcute/tid@^1.0.3",
1855
+
"npm:@atcute/tid@^1.1.1",
1814
1856
"npm:@eslint/compat@2",
1815
1857
"npm:@eslint/js@^9.39.2",
1816
1858
"npm:@floating-ui/dom@^1.7.4",
···
1821
1863
"npm:@sveltejs/vite-plugin-svelte@^6.2.1",
1822
1864
"npm:@tailwindcss/forms@~0.5.11",
1823
1865
"npm:@tailwindcss/vite@^4.1.18",
1824
-
"npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.17",
1866
+
"npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.18",
1825
1867
"npm:@types/node@^25.0.3",
1826
1868
"npm:@wora/cache-persist@^2.2.1",
1827
1869
"npm:async-cache-dedupe@^3.4.0",
1828
1870
"npm:eslint-config-prettier@^10.1.8",
1829
1871
"npm:eslint-plugin-svelte@^3.13.1",
1830
1872
"npm:eslint@^9.39.2",
1831
-
"npm:globals@^16.5.0",
1873
+
"npm:globals@17",
1832
1874
"npm:hash-wasm@^4.12.0",
1833
1875
"npm:lru-cache@^11.2.4",
1876
+
"npm:photoswipe@^5.4.4",
1834
1877
"npm:prettier-plugin-svelte@^3.4.1",
1835
1878
"npm:prettier-plugin-tailwindcss@~0.7.2",
1836
1879
"npm:prettier@^3.7.4",
···
1841
1884
"npm:svelte-portal@^2.2.1",
1842
1885
"npm:svelte@^5.46.1",
1843
1886
"npm:tailwindcss@^4.1.18",
1844
-
"npm:typescript-eslint@^8.50.1",
1887
+
"npm:typescript-eslint@^8.51.0",
1845
1888
"npm:typescript@^5.9.3",
1846
1889
"npm:vite@^7.3.0"
1847
1890
]
+2
-2
nix/modules.nix
+2
-2
nix/modules.nix
···
14
14
];
15
15
};
16
16
17
-
outputHash = "sha256-oZKRCeIxjkv8Iujo82FaumsEnpt8pSqoYDbibmPgmZA=";
17
+
outputHash = "sha256-1AkU6eV0uIUZohotHhd8E5eAwc4E4wwg2SjHVUdX8LE=";
18
18
outputHashAlgo = "sha256";
19
19
outputHashMode = "recursive";
20
20
21
-
nativeBuildInputs = [deno];
21
+
nativeBuildInputs = [ deno ];
22
22
23
23
dontConfigure = true;
24
24
dontCheck = true;
+8
-7
package.json
+8
-7
package.json
···
14
14
"lint": "prettier --check . && eslint ."
15
15
},
16
16
"dependencies": {
17
-
"@atcute/atproto": "^3.1.9",
17
+
"@atcute/atproto": "^3.1.10",
18
18
"@atcute/bluesky": "^3.2.14",
19
19
"@atcute/bluesky-richtext-builder": "^2.0.4",
20
20
"@atcute/bluesky-richtext-segmenter": "^2.0.4",
21
-
"@atcute/client": "^4.1.1",
21
+
"@atcute/client": "^4.2.0",
22
22
"@atcute/identity": "^1.1.3",
23
23
"@atcute/identity-resolver": "^1.2.1",
24
24
"@atcute/jetstream": "^1.1.2",
25
-
"@atcute/lexicons": "^1.2.5",
25
+
"@atcute/lexicons": "^1.2.6",
26
26
"@atcute/oauth-browser-client": "^2.0.3",
27
-
"@atcute/tid": "^1.0.3",
27
+
"@atcute/tid": "^1.1.1",
28
28
"@floating-ui/dom": "^1.7.4",
29
29
"@soffinal/websocket": "^0.2.1",
30
-
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.17",
30
+
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.18",
31
31
"@wora/cache-persist": "^2.2.1",
32
32
"async-cache-dedupe": "^3.4.0",
33
33
"hash-wasm": "^4.12.0",
34
34
"lru-cache": "^11.2.4",
35
+
"photoswipe": "^5.4.4",
35
36
"svelte-device-info": "^1.0.6",
36
37
"svelte-infinite": "^0.5.1",
37
38
"svelte-portal": "^2.2.1"
···
49
50
"eslint": "^9.39.2",
50
51
"eslint-config-prettier": "^10.1.8",
51
52
"eslint-plugin-svelte": "^3.13.1",
52
-
"globals": "^16.5.0",
53
+
"globals": "^17.0.0",
53
54
"prettier": "^3.7.4",
54
55
"prettier-plugin-svelte": "^3.4.1",
55
56
"prettier-plugin-tailwindcss": "^0.7.2",
···
58
59
"svelte-check": "^4.3.5",
59
60
"tailwindcss": "^4.1.18",
60
61
"typescript": "^5.9.3",
61
-
"typescript-eslint": "^8.50.1",
62
+
"typescript-eslint": "^8.51.0",
62
63
"vite": "^7.3.0"
63
64
}
64
65
}
+4
src/app.css
+4
src/app.css
+1
-1
src/components/AccountSelector.svelte
+1
-1
src/components/AccountSelector.svelte
···
1
1
<script lang="ts">
2
2
import { generateColorForDid, loggingIn, type Account } from '$lib/accounts';
3
-
import { AtpClient, resolveHandle } from '$lib/at/client';
3
+
import { AtpClient, resolveHandle } from '$lib/at/client.svelte';
4
4
import type { Handle } from '@atcute/lexicons';
5
5
import ProfilePicture from './ProfilePicture.svelte';
6
6
import PfpPlaceholder from './PfpPlaceholder.svelte';
+36
src/components/BlockedUserIndicator.svelte
+36
src/components/BlockedUserIndicator.svelte
···
1
+
<script lang="ts">
2
+
import type { Did } from '@atcute/lexicons';
3
+
import ProfilePicture from './ProfilePicture.svelte';
4
+
import type { AtpClient } from '$lib/at/client.svelte';
5
+
import { generateColorForDid } from '$lib/accounts';
6
+
7
+
interface Props {
8
+
client: AtpClient;
9
+
did: Did;
10
+
reason: 'blocked' | 'blocks-you';
11
+
size?: 'small' | 'normal' | 'large';
12
+
}
13
+
14
+
let { client, did, reason, size = 'normal' }: Props = $props();
15
+
16
+
const color = $derived(generateColorForDid(did));
17
+
const text = $derived(reason === 'blocked' ? 'user blocked' : 'user blocks you');
18
+
const pfpSize = $derived(size === 'small' ? 8 : size === 'large' ? 16 : 10);
19
+
</script>
20
+
21
+
<div
22
+
class="flex items-center gap-2 rounded-sm border-2 p-2 {size === 'small' ? 'text-sm' : ''}"
23
+
style="background: {color}11; border-color: {color}44;"
24
+
>
25
+
<div class="blocked-pfp">
26
+
<ProfilePicture {client} {did} size={pfpSize} />
27
+
</div>
28
+
<span class="opacity-80">{text}</span>
29
+
</div>
30
+
31
+
<style>
32
+
.blocked-pfp {
33
+
filter: blur(8px) grayscale(100%);
34
+
opacity: 0.4;
35
+
}
36
+
</style>
+237
-375
src/components/BskyPost.svelte
+237
-375
src/components/BskyPost.svelte
···
1
1
<script lang="ts">
2
-
import { resolveDidDoc, type AtpClient } from '$lib/at/client';
3
-
import {
4
-
AppBskyActorProfile,
5
-
AppBskyEmbedExternal,
6
-
AppBskyEmbedImages,
7
-
AppBskyEmbedVideo,
8
-
AppBskyFeedPost
9
-
} from '@atcute/bluesky';
2
+
import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte';
3
+
import { AppBskyActorProfile, AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky';
10
4
import {
11
5
parseCanonicalResourceUri,
12
-
type ActorIdentifier,
13
-
type CanonicalResourceUri,
14
6
type Did,
15
-
type Nsid,
7
+
type Handle,
16
8
type RecordKey,
17
9
type ResourceUri
18
10
} from '@atcute/lexicons';
19
11
import { expect, ok } from '$lib/result';
20
12
import { accounts, generateColorForDid } from '$lib/accounts';
21
13
import ProfilePicture from './ProfilePicture.svelte';
22
-
import { isBlob } from '@atcute/lexicons/interfaces';
23
-
import { blob, img } from '$lib/cdn';
24
14
import BskyPost from './BskyPost.svelte';
25
15
import Icon from '@iconify/svelte';
26
-
import { type Backlink, type BacklinksSource } from '$lib/at/constellation';
27
16
import {
28
-
clients,
29
-
postActions,
30
-
posts,
17
+
allPosts,
31
18
pulsingPostId,
32
-
type PostActions,
33
-
currentTime
19
+
currentTime,
20
+
deletePostBacklink,
21
+
createPostBacklink,
22
+
router,
23
+
profiles,
24
+
handles,
25
+
hasBacklink,
26
+
getBlockRelationship,
27
+
clients
34
28
} from '$lib/state.svelte';
35
-
import * as TID from '@atcute/tid';
36
29
import type { PostWithUri } from '$lib/at/fetch';
37
-
import { onMount } from 'svelte';
38
-
import { type AtprotoDid } from '@atcute/lexicons/syntax';
30
+
import { onMount, type Snippet } from 'svelte';
39
31
import { derived } from 'svelte/store';
40
-
import Device from 'svelte-device-info';
41
32
import Dropdown from './Dropdown.svelte';
42
-
import { type AppBskyEmbeds } from '$lib/at/types';
43
33
import { settings } from '$lib/settings';
44
34
import RichText from './RichText.svelte';
45
35
import { getRelativeTime } from '$lib/date';
36
+
import { likeSource, repostSource, toCanonicalUri } from '$lib';
37
+
import ProfileInfo from './ProfileInfo.svelte';
38
+
import EmbedBadge from './EmbedBadge.svelte';
39
+
import EmbedMedia from './EmbedMedia.svelte';
46
40
47
41
interface Props {
48
42
client: AtpClient;
···
56
50
isOnPostComposer?: boolean;
57
51
onQuote?: (quote: PostWithUri) => void;
58
52
onReply?: (reply: PostWithUri) => void;
53
+
cornerFragment?: Snippet;
54
+
isBlocked?: boolean;
59
55
}
60
56
61
57
const {
···
67
63
mini,
68
64
onQuote,
69
65
onReply,
70
-
isOnPostComposer = false /* replyBacklinks */
66
+
isOnPostComposer = false /* replyBacklinks */,
67
+
cornerFragment,
68
+
isBlocked = false
71
69
}: Props = $props();
72
70
73
-
const selectedDid = $derived(client.user?.did ?? null);
74
-
const actionClient = $derived(clients.get(did as AtprotoDid));
71
+
const user = $derived(client.user);
72
+
const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did));
75
73
76
-
const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
77
-
const color = generateColorForDid(did);
74
+
const aturi = $derived(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey }));
75
+
const color = $derived(generateColorForDid(did));
78
76
79
-
let handle: ActorIdentifier = $state(did);
80
-
const didDoc = resolveDidDoc(did).then((res) => {
81
-
if (res.ok) handle = res.value.handle;
82
-
return res;
77
+
let expandBlocked = $state(false);
78
+
const blockRel = $derived(
79
+
user && !isOnPostComposer
80
+
? getBlockRelationship(user.did, did)
81
+
: { userBlocked: false, blockedByTarget: false }
82
+
);
83
+
const showAsBlocked = $derived(
84
+
(isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandBlocked
85
+
);
86
+
87
+
let handle: Handle = $state(handles.get(did) ?? 'handle.invalid');
88
+
onMount(() => {
89
+
resolveDidDoc(did).then((res) => {
90
+
if (res.ok) {
91
+
handle = res.value.handle;
92
+
handles.set(did, handle);
93
+
}
94
+
return res;
95
+
});
83
96
});
84
97
const post = data
85
98
? Promise.resolve(ok(data))
86
99
: client.getRecord(AppBskyFeedPost.mainSchema, did, rkey);
87
-
let profile: AppBskyActorProfile.Main | null = $state(null);
100
+
let profile: AppBskyActorProfile.Main | null = $state(profiles.get(did) ?? null);
88
101
onMount(async () => {
89
102
const p = await client.getProfile(did);
90
103
if (!p.ok) return;
91
104
profile = p.value;
92
-
// console.log(profile.description);
105
+
profiles.set(did, profile);
93
106
});
94
-
// const replies = replyBacklinks
95
-
// ? Promise.resolve(ok(replyBacklinks))
96
-
// : client.getBacklinks(
97
-
// identifier,
98
-
// 'app.bsky.feed.post',
99
-
// rkey,
100
-
// 'app.bsky.feed.post:reply.parent.uri'
101
-
// );
102
107
103
-
const postId = `timeline-post-${aturi}-${quoteDepth}`;
108
+
const postId = $derived(
109
+
`timeline-post-${did.replace(/[^a-zA-Z0-9]/g, '_')}-${rkey}-${quoteDepth}`
110
+
);
104
111
const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId);
105
112
106
113
const scrollToAndPulse = (targetUri: ResourceUri) => {
107
114
const targetId = `timeline-post-${targetUri}-0`;
108
-
// console.log(`Scrolling to ${targetId}`);
109
115
const element = document.getElementById(targetId);
110
116
if (!element) return;
111
117
···
117
123
generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo)
118
124
);
119
125
pulsingPostId.set(targetId);
120
-
// Clear pulse after animation
121
126
setTimeout(() => pulsingPostId.set(null), 1200);
122
127
}, 400);
123
128
};
124
129
125
-
const getEmbedText = (embedType: string) => {
126
-
switch (embedType) {
127
-
case 'app.bsky.embed.external':
128
-
return '๐ has external link';
129
-
case 'app.bsky.embed.record':
130
-
return '๐ฌ has quote';
131
-
case 'app.bsky.embed.images':
132
-
return '๐ผ๏ธ has images';
133
-
case 'app.bsky.embed.video':
134
-
return '๐ฅ has video';
135
-
case 'app.bsky.embed.recordWithMedia':
136
-
return '๐ has quote with media';
137
-
default:
138
-
return 'โ has unknown embed';
139
-
}
140
-
};
141
-
142
-
const findBacklink = $derived(async (toDid: AtprotoDid, source: BacklinksSource) => {
143
-
const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source);
144
-
if (!backlinks.ok) return null;
145
-
return backlinks.value.records.find((r) => r.did === toDid) ?? null;
146
-
});
147
-
148
-
let findAllBacklinks = async (did: AtprotoDid | null) => {
149
-
if (!did) return;
150
-
if (postActions.has(`${did}:${aturi}`)) return;
151
-
const backlinks = await Promise.all([
152
-
findBacklink(did, 'app.bsky.feed.like:subject.uri'),
153
-
findBacklink(did, 'app.bsky.feed.repost:subject.uri')
154
-
// findBacklink('app.bsky.feed.post:reply.parent.uri'),
155
-
// findBacklink('app.bsky.feed.post:embed.record.uri')
156
-
]);
157
-
const actions: PostActions = {
158
-
like: backlinks[0],
159
-
repost: backlinks[1]
160
-
// reply: backlinks[2],
161
-
// quote: backlinks[3]
162
-
};
163
-
// console.log('findAllBacklinks', did, aturi, actions);
164
-
postActions.set(`${did}:${aturi}`, actions);
165
-
};
166
-
onMount(() => {
167
-
// findAllBacklinks($selectedDid);
168
-
accounts.subscribe((accs) => {
169
-
accs.map((acc) => acc.did).forEach((did) => findAllBacklinks(did));
170
-
});
171
-
});
172
-
173
-
const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => {
174
-
// console.log('toggleLink', selectedDid, link, collection);
175
-
if (!selectedDid) return null;
176
-
const _post = await post;
177
-
if (!_post.ok) return null;
178
-
if (!link) {
179
-
if (_post.value.cid) {
180
-
const record = {
181
-
$type: collection,
182
-
subject: {
183
-
cid: _post.value.cid,
184
-
uri: aturi
185
-
},
186
-
createdAt: new Date().toISOString()
187
-
};
188
-
const rkey = TID.now();
189
-
// todo: handle errors
190
-
client.atcute?.post('com.atproto.repo.createRecord', {
191
-
input: {
192
-
repo: selectedDid,
193
-
collection,
194
-
record,
195
-
rkey
196
-
}
197
-
});
198
-
return {
199
-
collection,
200
-
did: selectedDid,
201
-
rkey
202
-
};
203
-
}
204
-
} else {
205
-
// todo: handle errors
206
-
client.atcute?.post('com.atproto.repo.deleteRecord', {
207
-
input: {
208
-
repo: link.did,
209
-
collection: link.collection,
210
-
rkey: link.rkey
211
-
}
212
-
});
213
-
return null;
214
-
}
215
-
return link;
216
-
};
217
-
218
130
let actionsOpen = $state(false);
219
131
let actionsPos = $state({ x: 0, y: 0 });
220
132
···
237
149
return;
238
150
}
239
151
240
-
actionClient?.atcute
241
-
?.post('com.atproto.repo.deleteRecord', {
152
+
clients
153
+
.get(did)
154
+
?.user?.atcute.post('com.atproto.repo.deleteRecord', {
242
155
input: {
243
156
collection: 'app.bsky.feed.post',
244
157
repo: did,
···
247
160
})
248
161
.then((result) => {
249
162
if (!result.ok) return;
250
-
posts.get(did)?.delete(aturi);
163
+
allPosts.get(did)?.delete(aturi);
251
164
deleteState = 'deleted';
252
165
});
253
166
actionsOpen = false;
254
167
};
255
168
256
169
let profileOpen = $state(false);
257
-
let profilePopoutShowDid = $state(false);
258
170
</script>
259
171
260
-
{#snippet embedBadge(embed: AppBskyEmbeds)}
261
-
<span
262
-
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
263
-
style="
264
-
background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent);
265
-
color: {mini ? 'var(--nucleus-fg)' : color};
266
-
"
267
-
>
268
-
{getEmbedText(embed.$type!)}
269
-
</span>
270
-
{/snippet}
271
-
272
172
{#snippet profileInline()}
273
173
<button
274
174
class="
275
-
flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-200' : ''}
175
+
flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-125' : ''}
276
176
rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10
277
177
"
278
178
style="color: {color};"
279
-
onclick={() => (profileOpen = !profileOpen)}
179
+
onclick={() => ((profileOpen = false), router.navigate(`/profile/${did}`))}
280
180
>
281
181
<ProfilePicture {client} {did} size={8} />
282
182
283
183
{#if profile}
284
184
<span class="w-min max-w-sm min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
285
-
>{profile.displayName}</span
185
+
>{profile.displayName?.length === 0 ? handle : profile.displayName}</span
286
186
><span class="shrink-0 text-sm text-nowrap opacity-70">(@{handle})</span>
287
187
{:else}
288
188
{handle}
···
290
190
</button>
291
191
{/snippet}
292
192
293
-
<!-- eslint-disable svelte/no-navigation-without-resolve -->
294
193
{#snippet profilePopout()}
295
-
{@const profileDesc = profile?.description?.trim() ?? ''}
296
194
<Dropdown
297
195
class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!"
298
196
style="background: {color}36; border-color: {color}99;"
299
197
bind:isOpen={profileOpen}
300
198
trigger={profileInline}
199
+
onMouseEnter={() => (profileOpen = true)}
200
+
onMouseLeave={() => (profileOpen = false)}
301
201
>
302
-
<div class="flex items-center gap-2">
303
-
<ProfilePicture {client} {did} size={20} />
304
-
305
-
<div class="flex flex-col items-start overflow-hidden overflow-ellipsis">
306
-
<span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis">
307
-
{profile?.displayName ?? handle}
308
-
{#if profile?.pronouns}
309
-
<span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span>
310
-
{/if}
311
-
</span>
312
-
<button
313
-
oncontextmenu={(e) => {
314
-
const node = e.target as Node;
315
-
const selection = window.getSelection() ?? new Selection();
316
-
const range = document.createRange();
317
-
range.selectNodeContents(node);
318
-
selection.removeAllRanges();
319
-
selection.addRange(range);
320
-
e.stopPropagation();
321
-
}}
322
-
onclick={() => (profilePopoutShowDid = !profilePopoutShowDid)}
323
-
class="mb-0.5 text-nowrap opacity-85 select-text hover:underline"
324
-
>
325
-
{profilePopoutShowDid ? did : `@${handle}`}
326
-
</button>
327
-
{#if profile?.website}
328
-
<a
329
-
target="_blank"
330
-
rel="noopener noreferrer"
331
-
href={profile.website}
332
-
class="text-sm text-nowrap opacity-60">{profile.website}</a
333
-
>
334
-
{/if}
335
-
</div>
336
-
</div>
337
-
338
-
{#if profileDesc.length > 0}
339
-
<p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word">
340
-
<RichText text={profileDesc} />
341
-
</p>
342
-
{/if}
202
+
<ProfileInfo {client} {did} {handle} {profile} />
343
203
</Dropdown>
344
204
{/snippet}
345
205
···
350
210
{:then post}
351
211
{#if post.ok}
352
212
{@const record = post.value.record}
353
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
354
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
355
-
<div
356
-
onclick={() => scrollToAndPulse(post.value.uri)}
357
-
class="select-none hover:cursor-pointer hover:underline"
358
-
>
359
-
<span style="color: {color};">@{handle}</span>:
360
-
{#if record.embed}
361
-
{@render embedBadge(record.embed)}
362
-
{/if}
363
-
<span title={record.text}>{record.text}</span>
364
-
</div>
213
+
{#if showAsBlocked}
214
+
<button
215
+
onclick={() => (expandBlocked = true)}
216
+
class="text-left hover:cursor-pointer hover:underline"
217
+
>
218
+
<span style="color: {color};">post from blocked user</span> (click to show)
219
+
</button>
220
+
{:else}
221
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
222
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
223
+
<div
224
+
onclick={() => scrollToAndPulse(post.value.uri)}
225
+
class="hover:cursor-pointer hover:underline"
226
+
>
227
+
<span style="color: {color};">@{handle}</span>:
228
+
{#if record.embed}
229
+
<EmbedBadge embed={record.embed} />
230
+
{/if}
231
+
<span title={record.text}>{record.text}</span>
232
+
</div>
233
+
{/if}
365
234
{:else}
366
235
{post.error}
367
236
{/if}
···
384
253
{:then post}
385
254
{#if post.ok}
386
255
{@const record = post.value.record}
387
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
388
-
<div
389
-
id="timeline-post-{post.value.uri}-{quoteDepth}"
390
-
oncontextmenu={handleRightClick}
391
-
class="
256
+
{#if showAsBlocked}
257
+
<button
258
+
onclick={() => (expandBlocked = true)}
259
+
class="
260
+
group w-full rounded-sm border-2 p-3 text-left shadow-lg
261
+
backdrop-blur-sm transition-all hover:border-(--nucleus-accent)
262
+
"
263
+
style="background: {color}18; border-color: {color}66;"
264
+
>
265
+
<div class="flex items-center gap-2">
266
+
<span class="opacity-80">post from blocked user</span>
267
+
<span class="text-sm opacity-60">(click to show)</span>
268
+
</div>
269
+
</button>
270
+
{:else}
271
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
272
+
<div
273
+
id="timeline-post-{post.value.uri}-{quoteDepth}"
274
+
oncontextmenu={handleRightClick}
275
+
class="
392
276
group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all
393
277
{$isPulsing ? 'animate-pulse-highlight' : ''}
394
278
{isOnPostComposer ? 'backdrop-brightness-20' : ''}
395
279
"
396
-
style="
280
+
style="
397
281
background: {color}{isOnPostComposer
398
-
? '36'
399
-
: Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)};
282
+
? '36'
283
+
: Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)};
400
284
border-color: {color}{isOnPostComposer ? '99' : '66'};
401
285
"
402
-
>
403
-
<div
404
-
class="
405
-
mb-3 flex w-fit max-w-full items-center gap-1 rounded-sm pr-1
406
-
"
407
-
style="background: {color}33;"
408
286
>
409
-
{@render profilePopout()}
410
-
<span>ยท</span>
411
-
<span
412
-
title={new Date(record.createdAt).toLocaleString()}
413
-
class="pl-0.5 text-nowrap text-(--nucleus-fg)/67"
414
-
>
415
-
{getRelativeTime(new Date(record.createdAt), currentTime)}
416
-
</span>
287
+
<div class="mb-3 flex max-w-full items-center justify-between">
288
+
<div class="flex items-center gap-1 rounded-sm pr-1" style="background: {color}33;">
289
+
{@render profilePopout()}
290
+
<span>ยท</span>
291
+
<span
292
+
title={new Date(record.createdAt).toLocaleString()}
293
+
class="pl-0.5 text-nowrap text-(--nucleus-fg)/67"
294
+
>
295
+
{getRelativeTime(new Date(record.createdAt), currentTime)}
296
+
</span>
297
+
</div>
298
+
{@render cornerFragment?.()}
299
+
</div>
300
+
301
+
<p class="leading-normal text-wrap wrap-break-word">
302
+
<RichText text={record.text} facets={record.facets ?? []} />
303
+
{#if isOnPostComposer && record.embed}
304
+
<EmbedBadge embed={record.embed} {color} />
305
+
{/if}
306
+
</p>
307
+
{#if !isOnPostComposer && record.embed}
308
+
{@const embed = record.embed}
309
+
<div class="mt-2">
310
+
{#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'}
311
+
<EmbedMedia {did} {embed} />
312
+
{:else if embed.$type === 'app.bsky.embed.record'}
313
+
{@render embedPost(embed.record.uri)}
314
+
{:else if embed.$type === 'app.bsky.embed.recordWithMedia'}
315
+
<div class="space-y-1.5">
316
+
<EmbedMedia {did} embed={embed.media} />
317
+
{@render embedPost(embed.record.record.uri)}
318
+
</div>
319
+
{/if}
320
+
</div>
321
+
{/if}
322
+
{#if !isOnPostComposer}
323
+
{@render postControls(post.value)}
324
+
{/if}
417
325
</div>
418
-
<p class="leading-normal text-wrap wrap-break-word">
419
-
<RichText text={record.text} facets={record.facets ?? []} />
420
-
{#if isOnPostComposer && record.embed}
421
-
{@render embedBadge(record.embed)}
422
-
{/if}
423
-
</p>
424
-
{#if !isOnPostComposer && record.embed}
425
-
{@const embed = record.embed}
426
-
<div class="mt-2">
427
-
{@render postEmbed(embed)}
428
-
</div>
429
-
{/if}
430
-
{#if !isOnPostComposer}
431
-
{@const backlinks = postActions.get(`${selectedDid!}:${post.value.uri}`)}
432
-
{@render postControls(post.value, backlinks)}
433
-
{/if}
434
-
</div>
326
+
{/if}
435
327
{:else}
436
328
<div class="error-disclaimer">
437
329
<p class="text-sm font-medium">error: {post.error}</p>
···
440
332
{/await}
441
333
{/if}
442
334
443
-
{#snippet postEmbed(embed: AppBskyEmbeds)}
444
-
{#snippet embedMedia(
445
-
embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main
446
-
)}
447
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
448
-
<div oncontextmenu={(e) => e.stopPropagation()}>
449
-
{#if embed.$type === 'app.bsky.embed.images'}
450
-
<!-- todo: improve how images are displayed, and pop out on click -->
451
-
{#each embed.images as image (image.image)}
452
-
{#if isBlob(image.image)}
453
-
<img
454
-
class="w-full rounded-sm"
455
-
src={img('feed_thumbnail', did, image.image.ref.$link)}
456
-
alt={image.alt}
457
-
/>
458
-
{/if}
459
-
{/each}
460
-
{:else if embed.$type === 'app.bsky.embed.video'}
461
-
{#if isBlob(embed.video)}
462
-
{#await didDoc then didDoc}
463
-
{#if didDoc.ok}
464
-
<!-- svelte-ignore a11y_media_has_caption -->
465
-
<video
466
-
class="rounded-sm"
467
-
src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
468
-
controls
469
-
></video>
470
-
{/if}
471
-
{/await}
472
-
{/if}
473
-
{/if}
474
-
</div>
475
-
{/snippet}
476
-
{#snippet embedPost(uri: ResourceUri)}
477
-
{#if quoteDepth < 2}
478
-
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
479
-
<!-- reject recursive quotes -->
480
-
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
335
+
{#snippet embedPost(uri: ResourceUri)}
336
+
{#if quoteDepth < 2}
337
+
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
338
+
{@const embedBlockRel =
339
+
user?.did && !isOnPostComposer
340
+
? getBlockRelationship(user.did, parsedUri.repo)
341
+
: { userBlocked: false, blockedByTarget: false }}
342
+
{@const embedIsBlocked = embedBlockRel.userBlocked || embedBlockRel.blockedByTarget}
343
+
344
+
<!-- reject recursive quotes -->
345
+
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
346
+
{#if embedIsBlocked}
347
+
<div
348
+
class="rounded-sm border-2 p-2 text-sm opacity-70"
349
+
style="background: {generateColorForDid(
350
+
parsedUri.repo
351
+
)}11; border-color: {generateColorForDid(parsedUri.repo)}44;"
352
+
>
353
+
quoted post from blocked user
354
+
</div>
355
+
{:else}
481
356
<BskyPost
482
357
{client}
483
358
quoteDepth={quoteDepth + 1}
···
487
362
{onQuote}
488
363
{onReply}
489
364
/>
490
-
{:else}
491
-
<span>you think you're funny with that recursive quote but i'm onto you</span>
492
365
{/if}
493
366
{:else}
494
-
{@render embedBadge(embed)}
367
+
<span>you think you're funny with that recursive quote but i'm onto you</span>
495
368
{/if}
496
-
{/snippet}
497
-
{#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'}
498
-
{@render embedMedia(embed)}
499
-
{:else if embed.$type === 'app.bsky.embed.record'}
500
-
{@render embedPost(embed.record.uri)}
501
-
{:else if embed.$type === 'app.bsky.embed.recordWithMedia'}
502
-
<div class="space-y-1.5">
503
-
{@render embedPost(embed.record.record.uri)}
504
-
{@render embedMedia(embed.media)}
505
-
</div>
369
+
{:else}
370
+
<EmbedBadge embed={{ $type: 'app.bsky.embed.record' } as AppBskyEmbedRecord.Main} />
506
371
{/if}
507
-
<!-- todo: implement external link embeds -->
508
372
{/snippet}
509
373
510
-
{#snippet postControls(post: PostWithUri, backlinks?: PostActions)}
511
-
{#snippet control(
512
-
name: string,
513
-
icon: string,
514
-
onClick: (e: MouseEvent) => void,
515
-
isFull?: boolean,
516
-
hasSolid?: boolean
517
-
)}
374
+
{#snippet postControls(post: PostWithUri)}
375
+
{@const myRepost = user ? hasBacklink(post.uri, repostSource, user.did) : false}
376
+
{@const myLike = user ? hasBacklink(post.uri, likeSource, user.did) : false}
377
+
{#snippet control({
378
+
name,
379
+
icon,
380
+
onClick,
381
+
isFull,
382
+
hasSolid,
383
+
canBeDisabled = true,
384
+
iconColor = color
385
+
}: {
386
+
name: string;
387
+
icon: string;
388
+
onClick: (e: MouseEvent) => void;
389
+
isFull?: boolean;
390
+
hasSolid?: boolean;
391
+
canBeDisabled?: boolean;
392
+
iconColor?: string;
393
+
})}
518
394
<button
519
395
class="
520
-
px-2 py-1.5 text-(--nucleus-fg)/90 transition-all
521
-
duration-100 hover:[backdrop-filter:brightness(120%)]
396
+
px-1.75 py-1.5 text-(--nucleus-fg)/90 transition-all
397
+
duration-100 not-disabled:hover:[backdrop-filter:brightness(120%)]
398
+
disabled:cursor-not-allowed!
522
399
"
523
400
onclick={(e) => onClick(e)}
524
-
style="color: {isFull ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}"
401
+
style="color: {isFull ? iconColor : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}"
525
402
title={name}
403
+
disabled={canBeDisabled ? user?.did === undefined : false}
526
404
>
527
405
<Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} />
528
406
</button>
529
407
{/snippet}
530
408
<div class="mt-3 flex w-full items-center justify-between">
531
409
<div class="flex w-fit items-center rounded-sm" style="background: {color}1f;">
532
-
{#snippet label(
533
-
name: string,
534
-
icon: string,
535
-
onClick: (link: Backlink | null | undefined) => void,
536
-
backlink?: Backlink | null,
537
-
hasSolid?: boolean
538
-
)}
539
-
{@render control(name, icon, () => onClick(backlink), backlink ? true : false, hasSolid)}
540
-
{/snippet}
541
-
{@render label('reply', 'heroicons:chat-bubble-left', () => {
542
-
onReply?.(post);
410
+
{@render control({
411
+
name: 'reply',
412
+
icon: 'heroicons:chat-bubble-left',
413
+
hasSolid: true,
414
+
onClick: () => onReply?.(post)
543
415
})}
544
-
{@render label(
545
-
'repost',
546
-
'heroicons:arrow-path-rounded-square-20-solid',
547
-
async (link) => {
548
-
if (link === undefined) return;
549
-
postActions.set(`${selectedDid!}:${aturi}`, {
550
-
...backlinks!,
551
-
repost: await toggleLink(link, 'app.bsky.feed.repost')
552
-
});
416
+
{@render control({
417
+
name: 'repost',
418
+
icon: 'heroicons:arrow-path-rounded-square-20-solid',
419
+
onClick: () => {
420
+
if (!user?.did) return;
421
+
if (myRepost) deletePostBacklink(client, post, repostSource);
422
+
else createPostBacklink(client, post, repostSource);
553
423
},
554
-
backlinks?.repost
555
-
)}
556
-
{@render label('quote', 'heroicons:paper-clip-20-solid', () => {
557
-
onQuote?.(post);
424
+
isFull: myRepost
558
425
})}
559
-
{@render label(
560
-
'like',
561
-
'heroicons:star',
562
-
async (link) => {
563
-
if (link === undefined) return;
564
-
postActions.set(`${selectedDid!}:${aturi}`, {
565
-
...backlinks!,
566
-
like: await toggleLink(link, 'app.bsky.feed.like')
567
-
});
426
+
{@render control({
427
+
name: 'quote',
428
+
icon: 'heroicons:paper-clip-20-solid',
429
+
onClick: () => onQuote?.(post)
430
+
})}
431
+
{@render control({
432
+
name: 'like',
433
+
icon: 'heroicons:star',
434
+
onClick: () => {
435
+
if (!user?.did) return;
436
+
if (myLike) deletePostBacklink(client, post, likeSource);
437
+
else createPostBacklink(client, post, likeSource);
568
438
},
569
-
backlinks?.like,
570
-
true
571
-
)}
439
+
isFull: myLike,
440
+
hasSolid: true
441
+
})}
572
442
</div>
573
443
<Dropdown
574
444
class="post-dropdown"
···
580
450
{@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () =>
581
451
navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`)
582
452
)}
583
-
{@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () =>
453
+
{@render dropdownItem(undefined, 'copy at uri', () =>
584
454
navigator.clipboard.writeText(post.uri)
585
455
)}
586
456
{@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () =>
587
457
navigator.clipboard.writeText(post.record.text)
588
458
)}
589
-
{#if actionClient}
459
+
{#if isLoggedInUser}
590
460
<div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div>
591
461
{@render dropdownItem(
592
462
deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid',
···
598
468
{/if}
599
469
600
470
{#snippet trigger()}
601
-
<div
602
-
class="
603
-
w-fit items-center rounded-sm transition-opacity
604
-
duration-100 ease-in-out group-hover:opacity-100
605
-
{!actionsOpen && !Device.isMobile ? 'opacity-0' : ''}
606
-
"
607
-
style="background: {color}1f;"
608
-
>
609
-
{@render control('actions', 'heroicons:ellipsis-horizontal-16-solid', (e) => {
471
+
{@render control({
472
+
name: 'actions',
473
+
icon: 'heroicons:ellipsis-horizontal-16-solid',
474
+
onClick: (e: MouseEvent) => {
610
475
e.stopPropagation();
611
476
actionsOpen = !actionsOpen;
612
477
actionsPos = { x: 0, y: 0 };
613
-
})}
614
-
</div>
478
+
},
479
+
canBeDisabled: false,
480
+
isFull: true,
481
+
iconColor: 'color-mix(in srgb, var(--nucleus-fg) 70%, transparent)'
482
+
})}
615
483
{/snippet}
616
484
</Dropdown>
617
485
</div>
618
486
{/snippet}
619
487
620
488
{#snippet dropdownItem(
621
-
icon: string,
489
+
icon: string | undefined,
622
490
label: string,
623
491
onClick: () => void,
624
492
autoClose: boolean = true,
···
635
503
if (autoClose) actionsOpen = false;
636
504
}}
637
505
>
638
-
<span class="font-bold">{label}</span>
639
-
<Icon class="h-6 w-6" {icon} />
506
+
<span class="font-semibold opacity-85">{label}</span>
507
+
{#if icon}
508
+
<Icon class="h-6 w-6" {icon} />
509
+
{/if}
640
510
</button>
641
511
{/snippet}
642
-
643
-
<style>
644
-
@reference "../app.css";
645
-
646
-
:global(.post-dropdown) {
647
-
@apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60;
648
-
}
649
-
</style>
+71
-5
src/components/Dropdown.svelte
+71
-5
src/components/Dropdown.svelte
···
19
19
children?: import('svelte').Snippet;
20
20
placement?: Placement;
21
21
offsetDistance?: number;
22
+
openDelay?: number;
22
23
position?: { x: number; y: number };
24
+
onMouseEnter?: () => void;
25
+
onMouseLeave?: () => void;
23
26
}
24
27
25
28
let {
···
28
31
children,
29
32
placement = 'bottom-start',
30
33
offsetDistance = 2,
34
+
openDelay = 400,
31
35
position = $bindable(),
36
+
onMouseEnter,
37
+
onMouseLeave,
32
38
...restProps
33
39
}: Props = $props();
34
40
···
36
42
let contentRef: HTMLElement | undefined = $state();
37
43
let cleanup: (() => void) | null = null;
38
44
45
+
let isTriggerHovered = false;
46
+
let isContentHovered = false;
47
+
let closeTimer: ReturnType<typeof setTimeout>;
48
+
let openTimer: ReturnType<typeof setTimeout>;
49
+
39
50
const updatePosition = async () => {
40
51
const { x, y } = await computePosition(triggerRef!, contentRef!, {
41
52
placement,
···
55
66
let rect = element.getBoundingClientRect();
56
67
let x = event.clientX;
57
68
let y = event.clientY;
69
+
58
70
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
59
71
};
60
72
···
70
82
71
83
const handleScroll = handleClose;
72
84
85
+
// The central check: "Should we close now?"
86
+
const scheduleCloseCheck = () => {
87
+
clearTimeout(closeTimer);
88
+
closeTimer = setTimeout(() => {
89
+
// Only close if we are NOT on the trigger AND NOT on the content
90
+
if (!isTriggerHovered && !isContentHovered) if (isOpen && onMouseLeave) onMouseLeave();
91
+
}, 30); // Small buffer to handle the physical gap between elements
92
+
};
93
+
94
+
const handleTriggerEnter = () => {
95
+
isTriggerHovered = true;
96
+
clearTimeout(closeTimer);
97
+
98
+
if (!isOpen) {
99
+
clearTimeout(openTimer);
100
+
openTimer = setTimeout(() => {
101
+
if (onMouseEnter) onMouseEnter();
102
+
}, openDelay);
103
+
}
104
+
};
105
+
106
+
const handleTriggerLeave = () => {
107
+
isTriggerHovered = false;
108
+
clearTimeout(openTimer);
109
+
scheduleCloseCheck(); // We left the trigger, check if we should close
110
+
};
111
+
112
+
const handleContentEnter = () => {
113
+
isContentHovered = true;
114
+
clearTimeout(closeTimer); // We made it to the content, cancel close
115
+
};
116
+
117
+
const handleContentLeave = () => {
118
+
isContentHovered = false;
119
+
scheduleCloseCheck(); // We left the content, check if we should close
120
+
};
121
+
122
+
// Reset state if the menu is closed externally
123
+
$effect(() => {
124
+
if (!isOpen) {
125
+
isContentHovered = false;
126
+
clearTimeout(closeTimer);
127
+
clearTimeout(openTimer); // Ensure open timer is cleared on external close
128
+
}
129
+
});
130
+
73
131
$effect(() => {
74
132
if (isOpen) {
75
133
cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition);
···
79
137
}
80
138
});
81
139
82
-
onMount(() => {
83
-
return () => {
84
-
if (cleanup) cleanup();
85
-
};
140
+
onMount(() => () => {
141
+
if (cleanup) cleanup();
142
+
clearTimeout(closeTimer);
143
+
clearTimeout(openTimer); // Cleanup open timer on unmount
86
144
});
87
145
</script>
88
146
89
147
<svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} />
90
148
91
-
<div role="button" tabindex="0" bind:this={triggerRef}>
149
+
<div
150
+
role="button"
151
+
tabindex="0"
152
+
bind:this={triggerRef}
153
+
onmouseenter={handleTriggerEnter}
154
+
onmouseleave={handleTriggerLeave}
155
+
>
92
156
{@render trigger?.()}
93
157
</div>
94
158
···
100
164
style={restProps.style}
101
165
role="menu"
102
166
tabindex="-1"
167
+
onmouseenter={handleContentEnter}
168
+
onmouseleave={handleContentLeave}
103
169
>
104
170
{@render children?.()}
105
171
</div>
+37
src/components/EmbedBadge.svelte
+37
src/components/EmbedBadge.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyEmbeds } from '$lib/at/types';
3
+
4
+
interface Props {
5
+
embed: AppBskyEmbeds;
6
+
color?: string;
7
+
}
8
+
9
+
let { embed, color = 'var(--nucleus-fg)' }: Props = $props();
10
+
11
+
const embedText = $derived.by(() => {
12
+
switch (embed.$type) {
13
+
case 'app.bsky.embed.external':
14
+
return '๐ has external link';
15
+
case 'app.bsky.embed.record':
16
+
return '๐ฌ has quote';
17
+
case 'app.bsky.embed.images':
18
+
return '๐ผ๏ธ has images';
19
+
case 'app.bsky.embed.video':
20
+
return '๐ฅ has video';
21
+
case 'app.bsky.embed.recordWithMedia':
22
+
return '๐ has quote with media';
23
+
default:
24
+
return 'โ has unknown embed';
25
+
}
26
+
});
27
+
</script>
28
+
29
+
<span
30
+
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
31
+
style="
32
+
background: color-mix(in srgb, {color} 10%, transparent);
33
+
color: {color};
34
+
"
35
+
>
36
+
{embedText}
37
+
</span>
+53
src/components/EmbedMedia.svelte
+53
src/components/EmbedMedia.svelte
···
1
+
<script lang="ts">
2
+
import { isBlob } from '@atcute/lexicons/interfaces';
3
+
import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte';
4
+
import { blob, img } from '$lib/cdn';
5
+
import { type Did } from '@atcute/lexicons';
6
+
import { resolveDidDoc } from '$lib/at/client.svelte';
7
+
import type { AppBskyEmbedMedia } from '$lib/at/types';
8
+
9
+
interface Props {
10
+
did: Did;
11
+
embed: AppBskyEmbedMedia;
12
+
}
13
+
14
+
let { did, embed }: Props = $props();
15
+
</script>
16
+
17
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
18
+
<div oncontextmenu={(e) => e.stopPropagation()}>
19
+
{#if embed.$type === 'app.bsky.embed.images'}
20
+
{@const _images = embed.images.flatMap((img) =>
21
+
isBlob(img.image) ? [{ ...img, image: img.image }] : []
22
+
)}
23
+
{@const images = _images.map((i): GalleryItem => {
24
+
const size = i.aspectRatio;
25
+
const cid = i.image.ref.$link;
26
+
return {
27
+
...size,
28
+
src: img('feed_fullsize', did, cid),
29
+
thumbnail: {
30
+
src: img('feed_thumbnail', did, cid),
31
+
...size
32
+
},
33
+
alt: i.alt
34
+
};
35
+
})}
36
+
{#if images.length > 0}
37
+
<PhotoSwipeGallery {images} />
38
+
{/if}
39
+
{:else if embed.$type === 'app.bsky.embed.video'}
40
+
{#if isBlob(embed.video)}
41
+
{#await resolveDidDoc(did) then didDoc}
42
+
{#if didDoc.ok}
43
+
<!-- svelte-ignore a11y_media_has_caption -->
44
+
<video
45
+
class="rounded-sm"
46
+
src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
47
+
controls
48
+
></video>
49
+
{/if}
50
+
{/await}
51
+
{/if}
52
+
{/if}
53
+
</div>
+123
src/components/FollowingItem.svelte
+123
src/components/FollowingItem.svelte
···
1
+
<script lang="ts">
2
+
import ProfilePicture from './ProfilePicture.svelte';
3
+
import BlockedUserIndicator from './BlockedUserIndicator.svelte';
4
+
import { getRelativeTime } from '$lib/date';
5
+
import { generateColorForDid } from '$lib/accounts';
6
+
import type { Did } from '@atcute/lexicons';
7
+
import type { calculateFollowedUserStats, Sort } from '$lib/following';
8
+
import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte';
9
+
import { router, getBlockRelationship, profiles, handles } from '$lib/state.svelte';
10
+
import { map } from '$lib/result';
11
+
12
+
interface Props {
13
+
style: string;
14
+
did: Did;
15
+
stats: NonNullable<ReturnType<typeof calculateFollowedUserStats>>;
16
+
client: AtpClient;
17
+
sort: Sort;
18
+
currentTime: Date;
19
+
}
20
+
21
+
let { style, did, stats, client, sort, currentTime }: Props = $props();
22
+
23
+
const userDid = $derived(client.user?.did);
24
+
const blockRel = $derived(
25
+
userDid ? getBlockRelationship(userDid, did) : { userBlocked: false, blockedByTarget: false }
26
+
);
27
+
const isBlocked = $derived(blockRel.userBlocked || blockRel.blockedByTarget);
28
+
29
+
const displayName = $derived(profiles.get(did)?.displayName);
30
+
const handle = $derived(handles.get(did) ?? 'loading...');
31
+
32
+
let error = $state('');
33
+
34
+
const loadProfile = async (targetDid: Did) => {
35
+
if (profiles.has(targetDid) && handles.has(targetDid)) return;
36
+
37
+
try {
38
+
const [profileRes, handleRes] = await Promise.all([
39
+
client.getProfile(targetDid),
40
+
resolveDidDoc(targetDid).then((r) => map(r, (doc) => doc.handle))
41
+
]);
42
+
if (did !== targetDid) return;
43
+
44
+
if (profileRes.ok) profiles.set(targetDid, profileRes.value);
45
+
if (handleRes.ok) handles.set(targetDid, handleRes.value);
46
+
else handles.set(targetDid, 'handle.invalid');
47
+
} catch (e) {
48
+
if (did !== targetDid) return;
49
+
console.error(`failed to load profile for ${targetDid}`, e);
50
+
error = String(e);
51
+
}
52
+
};
53
+
54
+
$effect(() => {
55
+
loadProfile(did);
56
+
});
57
+
58
+
const lastPostAt = $derived(stats?.lastPostAt ?? new Date(0));
59
+
const relTime = $derived(getRelativeTime(lastPostAt, currentTime));
60
+
const color = $derived(generateColorForDid(did));
61
+
62
+
const goToProfile = () => router.navigate(`/profile/${did}`);
63
+
</script>
64
+
65
+
<div {style} class="box-border w-full pb-2">
66
+
{#if isBlocked}
67
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
68
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
69
+
<div onclick={goToProfile} class="cursor-pointer">
70
+
<BlockedUserIndicator
71
+
{client}
72
+
{did}
73
+
reason={blockRel.userBlocked ? 'blocked' : 'blocks-you'}
74
+
size="small"
75
+
/>
76
+
</div>
77
+
{:else}
78
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
79
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
80
+
<div
81
+
onclick={goToProfile}
82
+
class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20"
83
+
style={`--post-color: ${color};`}
84
+
>
85
+
<ProfilePicture {client} {did} size={10} />
86
+
<div class="min-w-0 flex-1 space-y-1">
87
+
{#if error.length === 0}
88
+
<div
89
+
class="flex items-baseline gap-2 truncate font-bold transition-colors group-hover:text-(--post-color)"
90
+
style={`--post-color: ${color};`}
91
+
>
92
+
<span class="truncate">{displayName || handle}</span>
93
+
<span class="truncate text-sm opacity-60">@{handle}</span>
94
+
</div>
95
+
{:else}
96
+
<div class="flex items-baseline truncate text-sm text-red-500">
97
+
error: {error}
98
+
</div>
99
+
{/if}
100
+
<div class="flex gap-2 text-xs opacity-70">
101
+
<span
102
+
class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2
103
+
? 'text-(--nucleus-accent)'
104
+
: ''}
105
+
>
106
+
posted {relTime}
107
+
{relTime !== 'now' ? 'ago' : ''}
108
+
</span>
109
+
{#if stats?.recentPostCount && stats.recentPostCount > 0}
110
+
<span class="text-(--nucleus-accent2)">
111
+
{stats.recentPostCount} posts / 6h
112
+
</span>
113
+
{/if}
114
+
{#if sort === 'conversational' && stats?.conversationalScore && stats.conversationalScore > 0}
115
+
<span class="ml-auto font-bold text-(--nucleus-accent)">
116
+
โ
{stats.conversationalScore.toFixed(1)}
117
+
</span>
118
+
{/if}
119
+
</div>
120
+
</div>
121
+
</div>
122
+
{/if}
123
+
</div>
+105
-217
src/components/FollowingView.svelte
+105
-217
src/components/FollowingView.svelte
···
1
1
<script lang="ts">
2
-
import { follows, getClient, posts, postActions, currentTime } from '$lib/state.svelte';
3
-
import type { ActorIdentifier, Did, ResourceUri } from '@atcute/lexicons';
4
-
import ProfilePicture from './ProfilePicture.svelte';
5
-
import { type AtpClient, resolveDidDoc } from '$lib/at/client';
6
-
import { getRelativeTime } from '$lib/date';
7
-
import { generateColorForDid } from '$lib/accounts';
8
-
import { type AtprotoDid } from '@atcute/lexicons/syntax';
2
+
import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte';
3
+
import type { Did } from '@atcute/lexicons';
4
+
import { type AtpClient } from '$lib/at/client.svelte';
9
5
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list';
6
+
import {
7
+
calculateFollowedUserStats,
8
+
calculateInteractionScores,
9
+
sortFollowedUser,
10
+
type Sort
11
+
} from '$lib/following';
12
+
import FollowingItem from './FollowingItem.svelte';
13
+
import NotLoggedIn from './NotLoggedIn.svelte';
10
14
11
15
interface Props {
12
-
selectedDid: Did;
13
-
selectedClient: AtpClient;
16
+
client: AtpClient | undefined;
17
+
followingSort: Sort;
14
18
}
15
19
16
-
const { selectedDid, selectedClient }: Props = $props();
20
+
let { client, followingSort = $bindable('active') }: Props = $props();
17
21
18
-
type Sort = 'recent' | 'active' | 'conversational';
19
-
let followingSort: Sort = $state('active' as Sort);
22
+
const selectedDid = $derived(client?.user?.did);
23
+
const followsMap = $derived(selectedDid ? follows.get(selectedDid) : undefined);
20
24
21
-
const interactionScores = $derived.by(() => {
22
-
if (followingSort !== 'conversational') return null;
25
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+
let sortedFollowing = $state<{ did: Did; data: any }[]>([]);
23
27
24
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
25
-
const scores = new Map<ActorIdentifier, number>();
26
-
const now = currentTime.getTime();
28
+
let isLongCalculation = $state(false);
29
+
let calculationTimer: ReturnType<typeof setTimeout> | undefined;
27
30
28
-
// Interactions are full weight for the first 3 days, then start decreasing linearly
29
-
// until 2 weeks, after which they decrease exponentially.
30
-
// Keep the same overall exponential timescale as before (half-life ~30 days).
31
-
const oneDay = 24 * 60 * 60 * 1000;
32
-
const halfLifeMs = 30 * oneDay;
33
-
const decayLambda = 0.693 / halfLifeMs;
34
-
const threeDays = 3 * oneDay;
35
-
const twoWeeks = 14 * oneDay;
31
+
// we could update the "now" every second but its pretty unnecessary
32
+
// so we only do it when we receive new data or sort mode changes
33
+
let staticNow = $state(Date.now());
36
34
37
-
const decay = (time: number) => {
38
-
const age = Math.max(0, now - time);
39
-
40
-
// Full weight for recent interactions within 3 days
41
-
if (age <= threeDays) return 1;
42
-
43
-
// Between 3 days and 2 weeks, linearly interpolate down to the value
44
-
// that the exponential would have at 2 weeks to keep continuity.
45
-
if (age <= twoWeeks) {
46
-
const expAtTwoWeeks = Math.exp(-decayLambda * twoWeeks);
47
-
const t = (age - threeDays) / (twoWeeks - threeDays); // 0..1
48
-
// linear ramp from 1 -> expAtTwoWeeks
49
-
return 1 - t * (1 - expAtTwoWeeks);
50
-
}
51
-
52
-
// After 2 weeks, exponential decay based on the chosen lambda
53
-
return Math.exp(-decayLambda * age);
54
-
};
55
-
56
-
const replyWeight = 4;
57
-
const repostWeight = 2;
58
-
const likeWeight = 1;
35
+
const updateList = async () => {
36
+
// Reset timer and loading state at start
37
+
if (calculationTimer) clearTimeout(calculationTimer);
38
+
isLongCalculation = false;
59
39
60
-
const myPosts = posts.get(selectedDid);
61
-
if (myPosts) {
62
-
for (const post of myPosts.values()) {
63
-
if (post.record.reply) {
64
-
const parentUri = post.record.reply.parent.uri;
65
-
// only try to extract the DID
66
-
const match = parentUri.match(/^at:\/\/([^/]+)/);
67
-
if (match) {
68
-
const targetDid = match[1] as Did;
69
-
if (targetDid === selectedDid) continue;
70
-
const s = scores.get(targetDid) || 0;
71
-
scores.set(
72
-
targetDid,
73
-
s + replyWeight * decay(new Date(post.record.createdAt).getTime())
74
-
);
75
-
}
76
-
}
77
-
}
40
+
if (!followsMap || !selectedDid) {
41
+
sortedFollowing = [];
42
+
return;
78
43
}
79
44
80
-
// interactions with others
81
-
for (const [key, actions] of postActions) {
82
-
const sepIndex = key.indexOf(':');
83
-
if (sepIndex === -1) continue;
84
-
const did = key.slice(0, sepIndex) as Did;
85
-
const uri = key.slice(sepIndex + 1) as ResourceUri;
86
-
87
-
// only try to extract the DID
88
-
const match = uri.match(/^at:\/\/([^/]+)/);
89
-
if (!match) continue;
90
-
const targetDid = match[1] as Did;
91
-
92
-
if (did === targetDid) continue;
93
-
94
-
let add = 0;
95
-
if (actions.like) add += likeWeight;
96
-
if (actions.repost) add += repostWeight;
97
-
98
-
if (add > 0) {
99
-
const targetPosts = posts.get(targetDid);
100
-
const post = targetPosts?.get(uri);
101
-
if (post) {
102
-
const time = new Date(post.record.createdAt).getTime();
103
-
add *= decay(time);
104
-
}
105
-
scores.set(targetDid, (scores.get(targetDid) || 0) + add);
106
-
}
107
-
}
108
-
109
-
return scores;
110
-
});
111
-
112
-
class FollowedUserStats {
113
-
did: Did;
114
-
profile: Promise<string | null | undefined>;
115
-
handle: Promise<string>;
45
+
// schedule spinner to appear only if calculation takes > 200ms
46
+
calculationTimer = setTimeout(() => (isLongCalculation = true), 200);
47
+
// yield to main thread to allow UI to show spinner/update
48
+
await new Promise((resolve) => setTimeout(resolve, 0));
116
49
117
-
constructor(did: Did) {
118
-
this.did = did;
119
-
this.profile = getClient(did as AtprotoDid)
120
-
.then((client) => client.getProfile())
121
-
.then((profile) => {
122
-
if (profile.ok) return profile.value.displayName;
123
-
return null;
124
-
});
125
-
this.handle = resolveDidDoc(did).then((doc) => {
126
-
if (doc.ok) return doc.value.handle;
127
-
return 'handle.invalid';
128
-
});
129
-
}
50
+
const interactionScores =
51
+
followingSort === 'conversational'
52
+
? calculateInteractionScores(
53
+
selectedDid,
54
+
followsMap,
55
+
allPosts,
56
+
allBacklinks,
57
+
replyIndex,
58
+
staticNow
59
+
)
60
+
: null;
130
61
131
-
data = $derived.by(() => {
132
-
const postsMap = posts.get(this.did);
133
-
if (!postsMap || postsMap.size === 0) return null;
62
+
const userStatsList = followsMap.values().map((f) => ({
63
+
did: f.subject,
64
+
data: calculateFollowedUserStats(
65
+
followingSort,
66
+
f.subject,
67
+
allPosts,
68
+
interactionScores,
69
+
staticNow
70
+
)
71
+
}));
134
72
135
-
let lastPostAtTime = 0;
136
-
let activeScore = 0;
137
-
let recentPostCount = 0;
138
-
const now = currentTime.getTime();
139
-
const quarterPosts = 6 * 60 * 60 * 1000;
140
-
const gravity = 2.0;
73
+
const following = userStatsList.filter((u) => u.data !== null);
74
+
const sorted = [...following].sort((a, b) => sortFollowedUser(followingSort, a.data!, b.data!));
141
75
142
-
for (const post of postsMap.values()) {
143
-
const t = new Date(post.record.createdAt).getTime();
144
-
if (t > lastPostAtTime) lastPostAtTime = t;
145
-
const ageMs = Math.max(0, now - t);
146
-
if (ageMs < quarterPosts) recentPostCount++;
147
-
if (followingSort === 'active') {
148
-
const ageHours = ageMs / (1000 * 60 * 60);
149
-
// score = 1 / t^G
150
-
activeScore += 1 / Math.pow(ageHours + 1, gravity);
151
-
}
152
-
}
76
+
sortedFollowing = sorted;
153
77
154
-
let conversationalScore = 0;
155
-
if (followingSort === 'conversational' && interactionScores)
156
-
conversationalScore = interactionScores.get(this.did) || 0;
78
+
// Clear timer and remove loading state immediately after done
79
+
if (calculationTimer) clearTimeout(calculationTimer);
80
+
isLongCalculation = false;
81
+
};
157
82
158
-
return {
159
-
did: this.did,
160
-
lastPostAt: new Date(lastPostAtTime),
161
-
activeScore,
162
-
conversationalScore,
163
-
recentPostCount
164
-
};
165
-
});
166
-
}
83
+
// todo: there is a bug where the view doesn't update and just gets stuck being loaded
84
+
$effect(() => {
85
+
// Dependencies that trigger a re-sort
86
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
87
+
const _s = followingSort;
88
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
89
+
const _f = followsMap?.size;
90
+
// Update time when sort changes
91
+
staticNow = Date.now();
167
92
168
-
const followsMap = $derived(follows.get(selectedDid));
93
+
updateList();
94
+
});
169
95
170
-
const userStatsList = $derived(
171
-
followsMap ? Array.from(followsMap.values()).map((f) => new FollowedUserStats(f.subject)) : []
172
-
);
96
+
let listHeight = $state(0);
97
+
let listContainer: HTMLDivElement | undefined = $state();
173
98
174
-
const following = $derived(userStatsList.filter((u) => u.data !== null));
99
+
const calcHeight = () => {
100
+
if (!listContainer) return;
101
+
const footer = document.getElementById('app-footer');
102
+
const footerHeight = footer?.getBoundingClientRect().height || 0;
103
+
const top = listContainer.getBoundingClientRect().top;
104
+
// 24px is our bottom padding
105
+
listHeight = Math.max(0, window.innerHeight - top - footerHeight - 24);
106
+
};
175
107
176
-
const sortedFollowing = $derived(
177
-
[...following].sort((a, b) => {
178
-
const statsA = a.data!;
179
-
const statsB = b.data!;
180
-
if (followingSort === 'conversational') {
181
-
if (Math.abs(statsB.conversationalScore - statsA.conversationalScore) > 0.1)
182
-
// sort based on conversational score
183
-
return statsB.conversationalScore - statsA.conversationalScore;
184
-
} else {
185
-
if (followingSort === 'active')
186
-
if (Math.abs(statsB.activeScore - statsA.activeScore) > 0.0001)
187
-
// sort based on activity
188
-
return statsB.activeScore - statsA.activeScore;
189
-
}
190
-
// use recent if scores are similar / we are using recent mode
191
-
return statsB.lastPostAt.getTime() - statsA.lastPostAt.getTime();
192
-
})
193
-
);
108
+
$effect(() => {
109
+
if (listContainer) {
110
+
calcHeight();
111
+
const observer = new ResizeObserver(calcHeight);
112
+
observer.observe(document.body);
113
+
return () => observer.disconnect();
114
+
}
115
+
});
194
116
</script>
195
117
196
118
<div class="flex h-full flex-col p-2">
···
216
138
</div>
217
139
</div>
218
140
219
-
<div class="min-h-0 flex-1">
220
-
{#if sortedFollowing.length === 0}
141
+
<div class="min-h-0 flex-1" bind:this={listContainer}>
142
+
{#if !client || !client.user}
143
+
<NotLoggedIn />
144
+
{:else if sortedFollowing.length === 0 || isLongCalculation}
221
145
<div class="flex justify-center py-8">
222
146
<div
223
147
class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
224
148
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
225
149
></div>
226
150
</div>
227
-
{:else}
228
-
<VirtualList height="70vh" itemCount={sortedFollowing.length} itemSize={76}>
151
+
{:else if listHeight > 0}
152
+
<VirtualList height={listHeight} itemCount={sortedFollowing.length} itemSize={76}>
229
153
{#snippet item({ index, style }: { index: number; style: string })}
230
154
{@const user = sortedFollowing[index]}
231
-
{@const stats = user.data!}
232
-
{@const lastPostAt = stats.lastPostAt}
233
-
{@const relTime = getRelativeTime(lastPostAt, currentTime)}
234
-
{@const color = generateColorForDid(user.did)}
235
-
<!-- box-border and pb-2 (0.5rem) simulates the gap-2 -->
236
-
<div {style} class="box-border w-full pb-2">
237
-
<div
238
-
class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20"
239
-
style={`--post-color: ${color};`}
240
-
>
241
-
<ProfilePicture client={selectedClient} did={user.did} size={10} />
242
-
<div class="min-w-0 flex-1 space-y-1">
243
-
<div
244
-
class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)"
245
-
style={`--post-color: ${color};`}
246
-
>
247
-
{#await Promise.all([user.profile, user.handle]) then [displayName, handle]}
248
-
<span class="truncate">{displayName || handle}</span>
249
-
<span class="truncate text-sm opacity-60">@{handle}</span>
250
-
{/await}
251
-
</div>
252
-
<div class="flex gap-2 text-xs opacity-70">
253
-
<span
254
-
class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2
255
-
? 'text-(--nucleus-accent)'
256
-
: ''}
257
-
>
258
-
posted {relTime}
259
-
{relTime !== 'now' ? 'ago' : ''}
260
-
</span>
261
-
{#if stats.recentPostCount > 0}
262
-
<span class="text-(--nucleus-accent2)">
263
-
{stats.recentPostCount} posts / 6h
264
-
</span>
265
-
{/if}
266
-
{#if followingSort === 'conversational' && stats.conversationalScore > 0}
267
-
<span class="ml-auto font-bold text-(--nucleus-accent)">
268
-
โ
{stats.conversationalScore.toFixed(1)}
269
-
</span>
270
-
{/if}
271
-
</div>
272
-
</div>
273
-
</div>
274
-
</div>
155
+
<FollowingItem
156
+
{style}
157
+
did={user.did}
158
+
stats={user.data!}
159
+
{client}
160
+
sort={followingSort}
161
+
{currentTime}
162
+
/>
275
163
{/snippet}
276
164
</VirtualList>
277
165
{/if}
+5
src/components/NotLoggedIn.svelte
+5
src/components/NotLoggedIn.svelte
+199
src/components/PhotoSwipeGallery.svelte
+199
src/components/PhotoSwipeGallery.svelte
···
1
+
<script context="module" lang="ts">
2
+
export interface GalleryItem {
3
+
src: string;
4
+
thumbnail?: {
5
+
src: string;
6
+
width?: number;
7
+
height?: number;
8
+
};
9
+
width?: number;
10
+
height?: number;
11
+
alt?: string;
12
+
}
13
+
export type GalleryData = Array<GalleryItem>;
14
+
</script>
15
+
16
+
<script lang="ts">
17
+
import 'photoswipe/photoswipe.css';
18
+
import PhotoSwipeLightbox from 'photoswipe/lightbox';
19
+
import PhotoSwipe, { type ElementProvider, type PreparedPhotoSwipeOptions } from 'photoswipe';
20
+
import { onMount } from 'svelte';
21
+
import { writable } from 'svelte/store';
22
+
23
+
export let images: GalleryData;
24
+
let element: HTMLDivElement;
25
+
let imageElements: { [key: number]: HTMLImageElement } = {};
26
+
27
+
const options = writable<Partial<PreparedPhotoSwipeOptions> | undefined>(undefined);
28
+
$: {
29
+
if (!element) break $;
30
+
const opts: Partial<PreparedPhotoSwipeOptions> = {
31
+
pswpModule: PhotoSwipe,
32
+
children: element.childNodes as ElementProvider,
33
+
gallery: element,
34
+
hideAnimationDuration: 0,
35
+
showAnimationDuration: 0,
36
+
zoomAnimationDuration: 200,
37
+
zoomSVG:
38
+
'<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" d="M6.25 8.75v-1h-1a.75.75 0 0 1 0-1.5h1v-1a.75.75 0 0 1 1.5 0v1h1a.75.75 0 0 1 0 1.5h-1v1a.75.75 0 0 1-1.5 0"/><path fill="currentColor" fill-rule="evenodd" d="M7 12c1.11 0 2.136-.362 2.965-.974l2.755 2.754a.75.75 0 1 0 1.06-1.06l-2.754-2.755A5 5 0 1 0 7 12m0-1.5a3.5 3.5 0 1 0 0-7a3.5 3.5 0 0 0 0 7" clip-rule="evenodd"/></svg>',
39
+
closeSVG:
40
+
'<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94z"/></svg>',
41
+
arrowPrevSVG:
42
+
'<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M9.78 4.22a.75.75 0 0 1 0 1.06L7.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L5.47 8.53a.75.75 0 0 1 0-1.06l3.25-3.25a.75.75 0 0 1 1.06 0" clip-rule="evenodd"/></svg>',
43
+
arrowNextSVG:
44
+
'<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8L6.22 5.28a.75.75 0 0 1 0-1.06" clip-rule="evenodd"/></svg>'
45
+
};
46
+
$options = opts;
47
+
}
48
+
49
+
onMount(() => {
50
+
let lightbox: PhotoSwipeLightbox | undefined;
51
+
const unsub = options.subscribe((opts) => {
52
+
lightbox?.destroy?.();
53
+
if (opts === undefined) return;
54
+
lightbox = new PhotoSwipeLightbox(opts);
55
+
lightbox.init();
56
+
});
57
+
return () => {
58
+
unsub();
59
+
lightbox?.destroy?.();
60
+
};
61
+
});
62
+
</script>
63
+
64
+
<div class="gallery styling-twitter" data-total={images.length} bind:this={element}>
65
+
{#each images as img, i (img.src)}
66
+
{@const thumb = img.thumbnail ?? img}
67
+
{@const isHidden = i > 3}
68
+
{@const isOverlay = i === 3 && images.length > 4}
69
+
70
+
<!-- eslint-disable svelte/no-navigation-without-resolve -->
71
+
<a
72
+
href={img.src}
73
+
data-pswp-width={img.width ?? imageElements[i]?.width}
74
+
data-pswp-height={img.height ?? imageElements[i]?.height}
75
+
target="_blank"
76
+
class:hidden-in-grid={isHidden}
77
+
class:overlay-container={isOverlay}
78
+
>
79
+
<img bind:this={imageElements[i]} src={thumb.src} title={img.alt ?? ''} alt={img.alt ?? ''} />
80
+
81
+
{#if isOverlay}
82
+
<div class="more-overlay">
83
+
+{images.length - 4}
84
+
</div>
85
+
{/if}
86
+
</a>
87
+
{/each}
88
+
</div>
89
+
90
+
<style>
91
+
:global(.gallery--icon) {
92
+
--drop-color: color-mix(in srgb, var(--color-gray-900) 70%, transparent);
93
+
color: var(--nucleus-fg);
94
+
filter: drop-shadow(2px 2px 1px var(--drop-color)) drop-shadow(-2px -2px 1px var(--drop-color))
95
+
drop-shadow(-2px 2px 1px var(--drop-color)) drop-shadow(2px -2px 1px var(--drop-color));
96
+
}
97
+
98
+
/* --- Default Grid (for 2+ images) --- */
99
+
.gallery.styling-twitter {
100
+
display: grid;
101
+
gap: 2px;
102
+
border-radius: 4px;
103
+
overflow: hidden;
104
+
}
105
+
106
+
.gallery.styling-twitter > a {
107
+
width: 100%;
108
+
height: 100%;
109
+
display: block;
110
+
position: relative;
111
+
overflow: hidden;
112
+
}
113
+
114
+
.gallery.styling-twitter > a > img {
115
+
@apply transition-opacity duration-200 hover:opacity-80;
116
+
width: 100%;
117
+
height: 100%;
118
+
object-fit: cover; /* Standard tile crop */
119
+
}
120
+
121
+
/* --- SINGLE IMAGE OVERRIDES --- */
122
+
/* This configuration allows the image to determine the width/height
123
+
naturally based on aspect ratio, up to a max-height limit.
124
+
*/
125
+
.gallery.styling-twitter[data-total='1'] {
126
+
display: block; /* Remove grid constraints */
127
+
height: auto;
128
+
aspect-ratio: auto; /* Remove 16:9 ratio */
129
+
border-radius: 0;
130
+
}
131
+
132
+
.gallery.styling-twitter[data-total='1'] > a {
133
+
/* fit-content is key: the container shrinks to fit the image width */
134
+
width: fit-content;
135
+
height: auto;
136
+
display: block;
137
+
border-radius: 4px;
138
+
overflow: hidden;
139
+
max-width: 100%; /* Prevent overflowing the parent */
140
+
}
141
+
142
+
.gallery.styling-twitter[data-total='1'] > a > img {
143
+
/* Let dimensions flow naturally */
144
+
width: auto;
145
+
height: auto;
146
+
147
+
/* Constraints: */
148
+
max-width: 100%; /* Never wider than container */
149
+
max-height: 60vh; /* Never taller than 60% of viewport (adjust if needed) */
150
+
151
+
object-fit: contain; /* Never crop the single image */
152
+
}
153
+
154
+
/* --- Grid Layouts (2+ Images) --- */
155
+
/* These retain the standard grid look */
156
+
157
+
/* 2 Images: Split vertically */
158
+
.gallery.styling-twitter[data-total='2'] {
159
+
grid-template-columns: 1fr 1fr;
160
+
grid-template-rows: 1fr;
161
+
aspect-ratio: 16/9;
162
+
}
163
+
164
+
/* 3 Images: 1 Big (left), 2 Small (stacked right) */
165
+
.gallery.styling-twitter[data-total='3'] {
166
+
grid-template-columns: 1fr 1fr;
167
+
grid-template-rows: 1fr 1fr;
168
+
aspect-ratio: 16/9;
169
+
}
170
+
.gallery.styling-twitter[data-total='3'] > a:first-child {
171
+
grid-row: span 2;
172
+
}
173
+
174
+
/* 4+ Images: 2x2 Grid */
175
+
.gallery.styling-twitter[data-total='4'],
176
+
.gallery.styling-twitter[data-total^='5'],
177
+
.gallery.styling-twitter:not([data-total='1']):not([data-total='2']):not([data-total='3']) {
178
+
grid-template-columns: 1fr 1fr;
179
+
grid-template-rows: 1fr 1fr;
180
+
aspect-ratio: 16/9;
181
+
}
182
+
183
+
.gallery.styling-twitter .hidden-in-grid {
184
+
display: none;
185
+
}
186
+
187
+
.more-overlay {
188
+
position: absolute;
189
+
inset: 0;
190
+
background-color: rgba(0, 0, 0, 0.5);
191
+
color: white;
192
+
display: flex;
193
+
align-items: center;
194
+
justify-content: center;
195
+
font-size: 2rem;
196
+
font-weight: bold;
197
+
pointer-events: none;
198
+
}
199
+
</style>
+562
-79
src/components/PostComposer.svelte
+562
-79
src/components/PostComposer.svelte
···
1
1
<script lang="ts">
2
-
import type { AtpClient } from '$lib/at/client';
2
+
import type { AtpClient } from '$lib/at/client.svelte';
3
3
import { ok, err, type Result, expect } from '$lib/result';
4
-
import type { AppBskyFeedPost } from '@atcute/bluesky';
4
+
import type { AppBskyEmbedRecordWithMedia, AppBskyFeedPost } from '@atcute/bluesky';
5
5
import { generateColorForDid } from '$lib/accounts';
6
6
import type { PostWithUri } from '$lib/at/fetch';
7
7
import BskyPost from './BskyPost.svelte';
8
-
import { parseCanonicalResourceUri } from '@atcute/lexicons';
8
+
import { parseCanonicalResourceUri, type Blob as AtpBlob } from '@atcute/lexicons';
9
9
import type { ComAtprotoRepoStrongRef } from '@atcute/atproto';
10
10
import { parseToRichText } from '$lib/richtext';
11
11
import { tokenize } from '$lib/richtext/parser';
12
+
import Icon from '@iconify/svelte';
13
+
import ProfilePicture from './ProfilePicture.svelte';
14
+
import type { AppBskyEmbedMedia } from '$lib/at/types';
15
+
import { SvelteMap } from 'svelte/reactivity';
16
+
import { handles } from '$lib/state.svelte';
12
17
13
-
export type State =
14
-
| { type: 'null' }
15
-
| { type: 'focused'; quoting?: PostWithUri; replying?: PostWithUri };
18
+
type UploadState =
19
+
| { state: 'uploading'; progress: number }
20
+
| { state: 'uploaded'; blob: AtpBlob<string> }
21
+
| { state: 'error'; message: string };
22
+
export type FocusState = 'null' | 'focused';
23
+
export type State = {
24
+
focus: FocusState;
25
+
text: string;
26
+
quoting?: PostWithUri;
27
+
replying?: PostWithUri;
28
+
attachedMedia?: AppBskyEmbedMedia;
29
+
blobsState: SvelteMap<string, UploadState>;
30
+
};
16
31
17
32
interface Props {
18
33
client: AtpClient;
···
20
35
_state: State;
21
36
}
22
37
23
-
let { client, onPostSent, _state = $bindable({ type: 'null' }) }: Props = $props();
38
+
let { client, onPostSent, _state = $bindable() }: Props = $props();
24
39
25
-
const isFocused = $derived(_state.type === 'focused');
40
+
const isFocused = $derived(_state.focus === 'focused');
26
41
27
42
const color = $derived(
28
43
client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)'
29
44
);
30
45
46
+
const getVideoDimensions = (
47
+
blobUrl: string
48
+
): Promise<Result<{ width: number; height: number }, string>> =>
49
+
new Promise((resolve) => {
50
+
const video = document.createElement('video');
51
+
video.onloadedmetadata = () => {
52
+
resolve(ok({ width: video.videoWidth, height: video.videoHeight }));
53
+
};
54
+
video.onerror = (e) => resolve(err(String(e)));
55
+
video.src = blobUrl;
56
+
});
57
+
58
+
const uploadVideo = async (blobUrl: string, mimeType: string) => {
59
+
const file = await (await fetch(blobUrl)).blob();
60
+
return await client.uploadVideo(file, mimeType, (status) => {
61
+
if (status.stage === 'uploading' && status.progress !== undefined) {
62
+
_state.blobsState.set(blobUrl, { state: 'uploading', progress: status.progress * 0.5 });
63
+
} else if (status.stage === 'processing' && status.progress !== undefined) {
64
+
_state.blobsState.set(blobUrl, {
65
+
state: 'uploading',
66
+
progress: 0.5 + status.progress * 0.5
67
+
});
68
+
}
69
+
});
70
+
};
71
+
72
+
const getImageDimensions = (
73
+
blobUrl: string
74
+
): Promise<Result<{ width: number; height: number }, string>> =>
75
+
new Promise((resolve) => {
76
+
const img = new Image();
77
+
img.onload = () => resolve(ok({ width: img.width, height: img.height }));
78
+
img.onerror = (e) => resolve(err(String(e)));
79
+
img.src = blobUrl;
80
+
});
81
+
82
+
const uploadImage = async (blobUrl: string) => {
83
+
const file = await (await fetch(blobUrl)).blob();
84
+
return await client.uploadBlob(file, (progress) => {
85
+
_state.blobsState.set(blobUrl, { state: 'uploading', progress });
86
+
});
87
+
};
88
+
31
89
const post = async (text: string): Promise<Result<PostWithUri, string>> => {
32
90
const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({
33
91
$type: 'com.atproto.repo.strongRef',
···
35
93
uri: p.uri
36
94
});
37
95
38
-
// Parse rich text (mentions, links, tags)
39
96
const rt = await parseToRichText(text);
40
97
98
+
let media: AppBskyEmbedMedia | undefined = _state.attachedMedia;
99
+
if (_state.attachedMedia?.$type === 'app.bsky.embed.images') {
100
+
const images = _state.attachedMedia.images;
101
+
let uploadedImages: typeof images = [];
102
+
for (const image of images) {
103
+
const blobUrl = (image.image as AtpBlob<string>).ref.$link;
104
+
const upload = _state.blobsState.get(blobUrl);
105
+
if (!upload || upload.state !== 'uploaded') continue;
106
+
const size = await getImageDimensions(blobUrl);
107
+
if (size.ok) image.aspectRatio = size.value;
108
+
uploadedImages.push({
109
+
...image,
110
+
image: upload.blob
111
+
});
112
+
}
113
+
if (uploadedImages.length > 0)
114
+
media = {
115
+
..._state.attachedMedia,
116
+
$type: 'app.bsky.embed.images',
117
+
images: uploadedImages
118
+
};
119
+
} else if (_state.attachedMedia?.$type === 'app.bsky.embed.video') {
120
+
const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link;
121
+
const upload = _state.blobsState.get(blobUrl);
122
+
if (upload && upload.state === 'uploaded') {
123
+
const size = await getVideoDimensions(blobUrl);
124
+
if (size.ok) _state.attachedMedia.aspectRatio = size.value;
125
+
media = {
126
+
..._state.attachedMedia,
127
+
$type: 'app.bsky.embed.video',
128
+
video: upload.blob
129
+
};
130
+
}
131
+
}
132
+
// console.log('media', media);
133
+
41
134
const record: AppBskyFeedPost.Main = {
42
135
$type: 'app.bsky.feed.post',
43
136
text: rt.text,
44
137
facets: rt.facets,
45
138
reply:
46
-
_state.type === 'focused' && _state.replying
139
+
_state.focus === 'focused' && _state.replying
47
140
? {
48
141
root: _state.replying.record.reply?.root ?? strongRef(_state.replying),
49
142
parent: strongRef(_state.replying)
50
143
}
51
144
: undefined,
52
145
embed:
53
-
_state.type === 'focused' && _state.quoting
54
-
? {
55
-
$type: 'app.bsky.embed.record',
56
-
record: strongRef(_state.quoting)
57
-
}
58
-
: undefined,
146
+
_state.focus === 'focused' && _state.quoting
147
+
? media
148
+
? {
149
+
$type: 'app.bsky.embed.recordWithMedia',
150
+
record: { record: strongRef(_state.quoting) },
151
+
media: media as AppBskyEmbedRecordWithMedia.Main['media']
152
+
}
153
+
: {
154
+
$type: 'app.bsky.embed.record',
155
+
record: strongRef(_state.quoting)
156
+
}
157
+
: (media as AppBskyFeedPost.Main['embed']),
59
158
createdAt: new Date().toISOString()
60
159
};
61
160
62
-
const res = await client.atcute?.post('com.atproto.repo.createRecord', {
161
+
const res = await client.user?.atcute.post('com.atproto.repo.createRecord', {
63
162
input: {
64
163
collection: 'app.bsky.feed.post',
65
164
repo: client.user!.did,
···
67
166
}
68
167
});
69
168
70
-
if (!res) {
71
-
return err('failed to post: not logged in');
72
-
}
169
+
if (!res) return err('failed to post: not logged in');
73
170
74
-
if (!res.ok) {
171
+
if (!res.ok)
75
172
return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`);
76
-
}
77
173
78
174
return ok({
79
175
uri: res.data.uri,
···
82
178
});
83
179
};
84
180
85
-
let postText = $state('');
86
-
let info = $state('');
181
+
let posting = $state(false);
182
+
let postError = $state('');
87
183
let textareaEl: HTMLTextAreaElement | undefined = $state();
184
+
let fileInputEl: HTMLInputElement | undefined = $state();
185
+
let selectingFile = $state(false);
88
186
89
-
const unfocus = () => {
90
-
_state.type = 'null';
187
+
const canUpload = $derived(
188
+
!(
189
+
_state.attachedMedia?.$type === 'app.bsky.embed.video' ||
190
+
(_state.attachedMedia?.$type === 'app.bsky.embed.images' &&
191
+
_state.attachedMedia.images.length >= 4)
192
+
)
193
+
);
194
+
195
+
const unfocus = () => (_state.focus = 'null');
196
+
197
+
const handleFiles = (files: File[]) => {
198
+
if (!canUpload || !files || files.length === 0) return;
199
+
200
+
const existingImages =
201
+
_state.attachedMedia?.$type === 'app.bsky.embed.images' ? _state.attachedMedia.images : [];
202
+
203
+
let newImages = [...existingImages];
204
+
let hasVideo = false;
205
+
206
+
for (let i = 0; i < files.length; i++) {
207
+
const file = files[i];
208
+
const isVideo = file.type.startsWith('video/');
209
+
const isImage = file.type.startsWith('image/');
210
+
211
+
if (!isVideo && !isImage) {
212
+
postError = 'unsupported file type';
213
+
continue;
214
+
}
215
+
216
+
if (isVideo) {
217
+
if (existingImages.length > 0 || newImages.length > 0) {
218
+
postError = 'cannot mix images and video';
219
+
continue;
220
+
}
221
+
const blobUrl = URL.createObjectURL(file);
222
+
_state.attachedMedia = {
223
+
$type: 'app.bsky.embed.video',
224
+
video: {
225
+
$type: 'blob',
226
+
ref: { $link: blobUrl },
227
+
mimeType: file.type,
228
+
size: file.size
229
+
}
230
+
};
231
+
hasVideo = true;
232
+
break;
233
+
} else if (isImage) {
234
+
if (newImages.length >= 4) {
235
+
postError = 'max 4 images allowed';
236
+
break;
237
+
}
238
+
const blobUrl = URL.createObjectURL(file);
239
+
newImages.push({
240
+
image: {
241
+
$type: 'blob',
242
+
ref: { $link: blobUrl },
243
+
mimeType: file.type,
244
+
size: file.size
245
+
},
246
+
alt: '',
247
+
aspectRatio: undefined
248
+
});
249
+
}
250
+
}
251
+
252
+
if (!hasVideo && newImages.length > 0) {
253
+
_state.attachedMedia = {
254
+
$type: 'app.bsky.embed.images',
255
+
images: newImages
256
+
};
257
+
}
258
+
259
+
const handleUpload = (blobUrl: string, res: Result<AtpBlob<string>, string>) => {
260
+
if (res.ok) _state.blobsState.set(blobUrl, { state: 'uploaded', blob: res.value });
261
+
else _state.blobsState.set(blobUrl, { state: 'error', message: res.error });
262
+
};
263
+
264
+
const media = _state.attachedMedia;
265
+
if (media?.$type == 'app.bsky.embed.images') {
266
+
for (const image of media.images) {
267
+
const blobUrl = (image.image as AtpBlob<string>).ref.$link;
268
+
uploadImage(blobUrl).then((r) => handleUpload(blobUrl, r));
269
+
}
270
+
} else if (media?.$type === 'app.bsky.embed.video') {
271
+
const blobUrl = (media.video as AtpBlob<string>).ref.$link;
272
+
uploadVideo(blobUrl, media.video.mimeType).then((r) => handleUpload(blobUrl, r));
273
+
}
274
+
};
275
+
276
+
const handlePaste = (e: ClipboardEvent) => {
277
+
const files = Array.from(e.clipboardData?.items ?? [])
278
+
.filter((item) => item.kind === 'file')
279
+
.map((item) => item.getAsFile())
280
+
.filter((file): file is File => file !== null);
281
+
282
+
if (files.length > 0) {
283
+
e.preventDefault();
284
+
handleFiles(files);
285
+
}
286
+
};
287
+
288
+
const handleDrop = (e: DragEvent) => {
289
+
e.preventDefault();
290
+
const files = Array.from(e.dataTransfer?.files ?? []);
291
+
if (files.length > 0) handleFiles(files);
292
+
};
293
+
294
+
const handleFileSelect = (e: Event) => {
295
+
e.preventDefault();
296
+
selectingFile = false;
297
+
298
+
const input = e.target as HTMLInputElement;
299
+
if (input.files) handleFiles(Array.from(input.files));
300
+
301
+
input.value = '';
302
+
};
303
+
304
+
const removeMedia = () => {
305
+
if (_state.attachedMedia?.$type === 'app.bsky.embed.video') {
306
+
const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link;
307
+
_state.blobsState.delete(blobUrl);
308
+
queueMicrotask(() => URL.revokeObjectURL(blobUrl));
309
+
}
310
+
_state.attachedMedia = undefined;
311
+
};
312
+
313
+
const removeMediaAtIndex = (index: number) => {
314
+
if (_state.attachedMedia?.$type !== 'app.bsky.embed.images') return;
315
+
const imageToRemove = _state.attachedMedia.images[index];
316
+
const blobUrl = (imageToRemove.image as AtpBlob<string>).ref.$link;
317
+
_state.blobsState.delete(blobUrl);
318
+
queueMicrotask(() => URL.revokeObjectURL(blobUrl));
319
+
320
+
const images = _state.attachedMedia.images.filter((_, i) => i !== index);
321
+
_state.attachedMedia = images.length > 0 ? { ..._state.attachedMedia, images } : undefined;
91
322
};
92
323
93
324
const doPost = () => {
94
-
if (postText.length === 0 || postText.length > 300) return;
325
+
if (_state.text.length === 0 || _state.text.length > 300) return;
95
326
96
-
post(postText).then((res) => {
97
-
if (res.ok) {
98
-
onPostSent(res.value);
99
-
postText = '';
100
-
info = 'posted!';
101
-
unfocus();
102
-
setTimeout(() => (info = ''), 1000 * 0.8);
103
-
} else {
104
-
// todo: add a way to clear error
105
-
info = res.error;
106
-
}
107
-
});
327
+
postError = '';
328
+
posting = true;
329
+
post(_state.text)
330
+
.then((res) => {
331
+
if (res.ok) {
332
+
onPostSent(res.value);
333
+
_state.text = '';
334
+
_state.quoting = undefined;
335
+
_state.replying = undefined;
336
+
if (_state.attachedMedia?.$type === 'app.bsky.embed.video')
337
+
URL.revokeObjectURL((_state.attachedMedia.video as AtpBlob<string>).ref.$link);
338
+
else if (_state.attachedMedia?.$type === 'app.bsky.embed.images')
339
+
_state.attachedMedia.images.forEach((image) =>
340
+
URL.revokeObjectURL((image.image as AtpBlob<string>).ref.$link)
341
+
);
342
+
_state.attachedMedia = undefined;
343
+
_state.blobsState.clear();
344
+
unfocus();
345
+
} else {
346
+
postError = res.error;
347
+
}
348
+
})
349
+
.finally(() => {
350
+
posting = false;
351
+
});
108
352
};
109
353
110
354
$effect(() => {
···
113
357
});
114
358
</script>
115
359
116
-
{#snippet renderPost(post: PostWithUri)}
360
+
{#snippet attachedPost(post: PostWithUri, type: 'quoting' | 'replying')}
117
361
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
118
-
<BskyPost
119
-
{client}
120
-
did={parsedUri.repo}
121
-
rkey={parsedUri.rkey}
122
-
data={post}
123
-
isOnPostComposer={true}
124
-
/>
362
+
<BskyPost {client} did={parsedUri.repo} rkey={parsedUri.rkey} data={post} isOnPostComposer={true}>
363
+
{#snippet cornerFragment()}
364
+
<button
365
+
class="transition-transform hover:scale-150"
366
+
onclick={() => {
367
+
_state[type] = undefined;
368
+
}}><Icon width={24} icon="heroicons:x-mark-16-solid" /></button
369
+
>
370
+
{/snippet}
371
+
</BskyPost>
372
+
{/snippet}
373
+
374
+
{#snippet attachmentIndicator(post: PostWithUri, type: 'quoting' | 'replying')}
375
+
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
376
+
{@const color = generateColorForDid(parsedUri.repo)}
377
+
{@const id = handles.get(parsedUri.repo) ?? parsedUri.repo}
378
+
<div
379
+
class="flex shrink-0 items-center gap-1.5 rounded-sm border py-0.5 pr-0.5 pl-1 text-xs font-bold transition-all"
380
+
style="
381
+
background: color-mix(in srgb, {color} 10%, transparent);
382
+
border-color: {color};
383
+
color: {color};
384
+
"
385
+
title={type === 'replying' ? `replying to ${id}` : `quoting ${id}`}
386
+
>
387
+
<span class="truncate text-sm font-normal opacity-90">
388
+
{type === 'replying' ? 'replying to' : 'quoting'}
389
+
</span>
390
+
<div class="shrink-0">
391
+
<ProfilePicture {client} did={parsedUri.repo} size={5} />
392
+
</div>
393
+
</div>
125
394
{/snippet}
126
395
127
396
{#snippet highlighter(text: string)}
···
138
407
{/if}
139
408
{/snippet}
140
409
410
+
{#snippet uploadControls(blobUrl: string, remove: () => void)}
411
+
{@const upload = _state.blobsState.get(blobUrl)}
412
+
{#if upload !== undefined && upload.state === 'uploading'}
413
+
<div
414
+
class="absolute top-2 right-2 z-10 flex items-center gap-2 rounded-sm bg-black/70 p-1.5 text-sm backdrop-blur-sm"
415
+
>
416
+
<div class="flex justify-center">
417
+
<div
418
+
class="h-5 w-5 animate-spin rounded-full border-4 border-t-transparent"
419
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
420
+
></div>
421
+
</div>
422
+
<span class="font-medium">{Math.round(upload.progress * 100)}%</span>
423
+
</div>
424
+
{:else}
425
+
<div class="absolute top-2 right-2 z-10 flex items-center gap-1">
426
+
{#if upload !== undefined && upload.state === 'error'}
427
+
<span
428
+
class="rounded-sm bg-black/70 p-1.5 px-1 text-sm font-bold text-red-500 backdrop-blur-sm"
429
+
>{upload.message}</span
430
+
>
431
+
{/if}
432
+
<button
433
+
onclick={(e) => {
434
+
e.preventDefault();
435
+
e.stopPropagation();
436
+
remove();
437
+
}}
438
+
onmousedown={(e) => e.preventDefault()}
439
+
class="rounded-sm bg-black/70 p-1.5 backdrop-blur-sm {upload?.state !== 'error'
440
+
? 'opacity-0 transition-opacity group-hover:opacity-100'
441
+
: ''}"
442
+
>
443
+
{#if upload?.state === 'error'}
444
+
<Icon
445
+
class="text-red-500 group-hover:hidden"
446
+
icon="heroicons:exclamation-circle-16-solid"
447
+
width={20}
448
+
/>
449
+
{/if}
450
+
<Icon
451
+
class={upload?.state === 'error' ? 'hidden group-hover:block' : ''}
452
+
icon="heroicons:x-mark-16-solid"
453
+
width={20}
454
+
/>
455
+
</button>
456
+
</div>
457
+
{/if}
458
+
{/snippet}
459
+
460
+
{#snippet mediaPreview(embed: AppBskyEmbedMedia)}
461
+
{#if embed.$type === 'app.bsky.embed.images'}
462
+
<div class="image-preview-grid" data-total={embed.images.length}>
463
+
{#each embed.images as image, idx (idx)}
464
+
{@const blobUrl = (image.image as AtpBlob<string>).ref.$link}
465
+
<div class="image-preview-item group">
466
+
<img src={blobUrl} alt="" />
467
+
{@render uploadControls(blobUrl, () => removeMediaAtIndex(idx))}
468
+
</div>
469
+
{/each}
470
+
</div>
471
+
{:else if embed.$type === 'app.bsky.embed.video'}
472
+
{@const blobUrl = (embed.video as AtpBlob<string>).ref.$link}
473
+
<div
474
+
class="group relative max-h-[30vh] overflow-hidden rounded-sm"
475
+
style="aspect-ratio: 16/10;"
476
+
>
477
+
<!-- svelte-ignore a11y_media_has_caption -->
478
+
<video src={blobUrl} controls class="h-full w-full"></video>
479
+
{@render uploadControls(blobUrl, removeMedia)}
480
+
</div>
481
+
{/if}
482
+
{/snippet}
483
+
141
484
{#snippet composer(replying?: PostWithUri, quoting?: PostWithUri)}
485
+
{@const hasIncompleteUpload = _state.blobsState
486
+
.values()
487
+
.some((s) => s.state === 'uploading' || s.state === 'error')}
142
488
<div class="flex items-center gap-2">
489
+
<input
490
+
bind:this={fileInputEl}
491
+
type="file"
492
+
accept="image/*,video/*"
493
+
multiple
494
+
onchange={handleFileSelect}
495
+
oncancel={() => (selectingFile = false)}
496
+
class="hidden"
497
+
/>
498
+
<button
499
+
onclick={(e) => {
500
+
e.preventDefault();
501
+
e.stopPropagation();
502
+
selectingFile = true;
503
+
fileInputEl?.click();
504
+
}}
505
+
onmousedown={(e) => e.preventDefault()}
506
+
disabled={!canUpload}
507
+
class="rounded-sm p-1.5 transition-all duration-150 enabled:hover:scale-110 disabled:cursor-not-allowed disabled:opacity-50"
508
+
style="background: color-mix(in srgb, {color} 15%, transparent); color: {color};"
509
+
title="attach media"
510
+
>
511
+
<Icon icon="heroicons:photo-16-solid" width={20} />
512
+
</button>
513
+
{#if postError.length > 0}
514
+
<div class="group flex items-center gap-2 truncate rounded-sm bg-red-500 p-1.5">
515
+
<button onclick={() => (postError = '')}>
516
+
<Icon
517
+
class="group-hover:hidden"
518
+
icon="heroicons:exclamation-circle-16-solid"
519
+
width={20}
520
+
/>
521
+
<Icon class="hidden group-hover:block" icon="heroicons:x-mark-16-solid" width={20} />
522
+
</button>
523
+
<span title={postError} class="truncate text-sm font-bold">{postError}</span>
524
+
</div>
525
+
{/if}
143
526
<div class="grow"></div>
527
+
{#if posting}
528
+
<div
529
+
class="h-6 w-6 animate-spin rounded-full border-4 border-t-transparent"
530
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
531
+
></div>
532
+
{/if}
144
533
<span
145
-
class="text-sm font-medium"
146
-
style="color: color-mix(in srgb, {postText.length > 300
534
+
class="text-sm font-medium text-nowrap"
535
+
style="color: color-mix(in srgb, {_state.text.length > 300
147
536
? '#ef4444'
148
537
: 'var(--nucleus-fg)'} 53%, transparent);"
149
538
>
150
-
{postText.length} / 300
539
+
{_state.text.length} / 300
151
540
</span>
152
541
<button
153
-
onmousedown={(e) => {
154
-
e.preventDefault();
155
-
doPost();
156
-
}}
157
-
disabled={postText.length === 0 || postText.length > 300}
158
-
class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
542
+
onmousedown={(e) => e.preventDefault()}
543
+
onclick={doPost}
544
+
disabled={(!_state.attachedMedia && _state.text.length === 0) ||
545
+
_state.text.length > 300 ||
546
+
hasIncompleteUpload}
547
+
class="action-button border-none px-4 py-1.5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed! disabled:opacity-50 disabled:hover:scale-100"
159
548
style="background: color-mix(in srgb, {color} 87%, transparent);"
160
549
>
161
550
post
162
551
</button>
163
552
</div>
164
553
{#if replying}
165
-
{@render renderPost(replying)}
554
+
{@render attachedPost(replying, 'replying')}
166
555
{/if}
167
-
<div class="composer space-y-2">
556
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
557
+
<div
558
+
class="composer space-y-2"
559
+
onpaste={handlePaste}
560
+
ondrop={handleDrop}
561
+
ondragover={(e) => e.preventDefault()}
562
+
>
168
563
<div class="relative grid">
169
564
<!-- todo: replace this with a proper rich text editor -->
170
565
<div
171
566
class="pointer-events-none col-start-1 row-start-1 min-h-[5lh] w-full bg-transparent text-wrap break-all whitespace-pre-wrap text-(--nucleus-fg)"
172
567
aria-hidden="true"
173
568
>
174
-
{@render highlighter(postText)}
569
+
{@render highlighter(_state.text)}
175
570
</div>
176
571
177
572
<textarea
178
573
bind:this={textareaEl}
179
-
bind:value={postText}
180
-
onfocus={() => (_state.type = 'focused')}
181
-
onblur={unfocus}
574
+
bind:value={_state.text}
575
+
onfocus={() => (_state.focus = 'focused')}
576
+
onblur={() => (!selectingFile ? unfocus() : null)}
182
577
onkeydown={(event) => {
183
578
if (event.key === 'Escape') unfocus();
184
579
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
···
188
583
class="col-start-1 row-start-1 field-sizing-content min-h-[5lh] w-full resize-none overflow-hidden bg-transparent text-wrap break-all whitespace-pre-wrap text-transparent caret-(--nucleus-fg) placeholder:text-(--nucleus-fg)/45"
189
584
></textarea>
190
585
</div>
191
-
586
+
{#if _state.attachedMedia}
587
+
{@render mediaPreview(_state.attachedMedia)}
588
+
{/if}
192
589
{#if quoting}
193
-
{@render renderPost(quoting)}
590
+
{@render attachedPost(quoting, 'quoting')}
194
591
{/if}
195
592
</div>
196
593
{/snippet}
···
214
611
: `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`};
215
612
border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);"
216
613
>
217
-
<div class="w-full p-1.5 px-2">
218
-
{#if info.length > 0}
614
+
<div class="w-full p-1">
615
+
{#if !client.user}
219
616
<div
220
617
class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
221
618
style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};"
222
619
>
223
-
{info}
620
+
not logged in
224
621
</div>
225
622
{:else}
226
-
<div class="flex flex-col gap-2">
227
-
{#if _state.type === 'focused'}
623
+
<div class="flex flex-col gap-1">
624
+
{#if _state.focus === 'focused'}
228
625
{@render composer(_state.replying, _state.quoting)}
229
626
{:else}
230
-
<input
231
-
bind:value={postText}
232
-
onfocus={() => (_state = { type: 'focused' })}
233
-
type="text"
234
-
placeholder="what's on your mind?"
235
-
class="flex-1"
236
-
/>
627
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
628
+
<div
629
+
class="composer relative flex cursor-text items-center gap-0 py-0! transition-all hover:brightness-110"
630
+
onmousedown={(e) => {
631
+
if (e.defaultPrevented) return;
632
+
_state.focus = 'focused';
633
+
}}
634
+
>
635
+
{#if _state.replying}
636
+
{@render attachmentIndicator(_state.replying, 'replying')}
637
+
{/if}
638
+
<input
639
+
bind:value={_state.text}
640
+
onfocus={() => (_state.focus = 'focused')}
641
+
type="text"
642
+
placeholder="what's on your mind?"
643
+
class="min-w-0 flex-1 border-none bg-transparent outline-none placeholder:text-(--nucleus-fg)/45 focus:ring-0"
644
+
/>
645
+
{#if _state.quoting}
646
+
{@render attachmentIndicator(_state.quoting, 'quoting')}
647
+
{/if}
648
+
</div>
237
649
{/if}
238
650
</div>
239
651
{/if}
···
246
658
247
659
input,
248
660
.composer {
249
-
@apply single-line-input bg-(--nucleus-bg)/35;
661
+
@apply single-line-input rounded-xs bg-(--nucleus-bg)/35;
250
662
border-color: color-mix(in srgb, var(--acc-color) 30%, transparent);
251
663
}
252
664
253
665
.composer {
254
-
@apply p-2;
666
+
@apply p-1;
255
667
}
256
668
257
669
textarea {
···
259
671
}
260
672
261
673
input {
262
-
@apply p-1 px-2;
674
+
@apply p-1.5;
263
675
}
264
676
265
677
.composer {
···
272
684
273
685
textarea:focus {
274
686
@apply border-none! [box-shadow:none]! outline-none!;
687
+
}
688
+
689
+
/* Image preview grid - based on PhotoSwipeGallery */
690
+
.image-preview-grid {
691
+
display: grid;
692
+
gap: 2px;
693
+
border-radius: 4px;
694
+
overflow: hidden;
695
+
width: 100%;
696
+
max-height: 30vh;
697
+
}
698
+
699
+
.image-preview-item {
700
+
width: 100%;
701
+
height: 100%;
702
+
display: block;
703
+
position: relative;
704
+
overflow: hidden;
705
+
border-radius: 4px;
706
+
}
707
+
708
+
.image-preview-item > img {
709
+
width: 100%;
710
+
height: 100%;
711
+
object-fit: cover;
712
+
}
713
+
714
+
/* Single image: natural aspect ratio */
715
+
.image-preview-grid[data-total='1'] {
716
+
display: block;
717
+
height: auto;
718
+
width: 100%;
719
+
border-radius: 0;
720
+
}
721
+
722
+
.image-preview-grid[data-total='1'] .image-preview-item {
723
+
width: 100%;
724
+
height: auto;
725
+
display: block;
726
+
border-radius: 4px;
727
+
}
728
+
729
+
.image-preview-grid[data-total='1'] .image-preview-item > img {
730
+
width: 100%;
731
+
height: auto;
732
+
max-height: 60vh;
733
+
object-fit: contain;
734
+
}
735
+
736
+
/* 2 Images: Split vertically */
737
+
.image-preview-grid[data-total='2'] {
738
+
grid-template-columns: 1fr 1fr;
739
+
grid-template-rows: 1fr;
740
+
aspect-ratio: 16/9;
741
+
}
742
+
743
+
/* 3 Images: 1 Big (left), 2 Small (stacked right) */
744
+
.image-preview-grid[data-total='3'] {
745
+
grid-template-columns: 1fr 1fr;
746
+
grid-template-rows: 1fr 1fr;
747
+
aspect-ratio: 16/9;
748
+
}
749
+
.image-preview-grid[data-total='3'] .image-preview-item:first-child {
750
+
grid-row: span 2;
751
+
}
752
+
753
+
/* 4 Images: 2x2 Grid */
754
+
.image-preview-grid[data-total='4'] {
755
+
grid-template-columns: 1fr 1fr;
756
+
grid-template-rows: 1fr 1fr;
757
+
aspect-ratio: 16/9;
275
758
}
276
759
</style>
+145
src/components/ProfileActions.svelte
+145
src/components/ProfileActions.svelte
···
1
+
<script lang="ts">
2
+
import type { AtpClient } from '$lib/at/client.svelte';
3
+
import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons';
4
+
import Dropdown from './Dropdown.svelte';
5
+
import Icon from '@iconify/svelte';
6
+
import { createBlock, deleteBlock, follows } from '$lib/state.svelte';
7
+
import { generateColorForDid } from '$lib/accounts';
8
+
import { now as tidNow } from '@atcute/tid';
9
+
import type { AppBskyGraphFollow } from '@atcute/bluesky';
10
+
import { toCanonicalUri } from '$lib';
11
+
import { SvelteMap } from 'svelte/reactivity';
12
+
13
+
interface Props {
14
+
client: AtpClient;
15
+
targetDid: Did;
16
+
userBlocked: boolean;
17
+
blockedByTarget: boolean;
18
+
}
19
+
20
+
let { client, targetDid, userBlocked = $bindable(), blockedByTarget }: Props = $props();
21
+
22
+
const userDid = $derived(client.user?.did);
23
+
const color = $derived(generateColorForDid(targetDid));
24
+
25
+
let actionsOpen = $state(false);
26
+
let actionsPos = $state({ x: 0, y: 0 });
27
+
28
+
const followsMap = $derived(userDid ? follows.get(userDid) : undefined);
29
+
const follow = $derived(
30
+
followsMap
31
+
? Array.from(followsMap.entries()).find(([, follow]) => follow.subject === targetDid)
32
+
: undefined
33
+
);
34
+
35
+
const handleFollow = async () => {
36
+
if (!userDid || !client.user) return;
37
+
38
+
if (follow) {
39
+
const [uri] = follow;
40
+
followsMap?.delete(uri);
41
+
42
+
// extract rkey from uri
43
+
const parsedUri = parseCanonicalResourceUri(uri);
44
+
if (!parsedUri.ok) return;
45
+
const rkey = parsedUri.value.rkey;
46
+
47
+
await client.user.atcute.post('com.atproto.repo.deleteRecord', {
48
+
input: {
49
+
repo: userDid,
50
+
collection: 'app.bsky.graph.follow',
51
+
rkey
52
+
}
53
+
});
54
+
} else {
55
+
// follow
56
+
const rkey = tidNow();
57
+
const record: AppBskyGraphFollow.Main = {
58
+
$type: 'app.bsky.graph.follow',
59
+
subject: targetDid,
60
+
createdAt: new Date().toISOString()
61
+
};
62
+
63
+
const uri = toCanonicalUri({
64
+
did: userDid,
65
+
collection: 'app.bsky.graph.follow',
66
+
rkey
67
+
});
68
+
69
+
if (!followsMap) follows.set(userDid, new SvelteMap([[uri, record]]));
70
+
else followsMap.set(uri, record);
71
+
72
+
await client.user.atcute.post('com.atproto.repo.createRecord', {
73
+
input: {
74
+
repo: userDid,
75
+
collection: 'app.bsky.graph.follow',
76
+
rkey,
77
+
record
78
+
}
79
+
});
80
+
}
81
+
82
+
actionsOpen = false;
83
+
};
84
+
85
+
const handleBlock = async () => {
86
+
if (!userDid) return;
87
+
88
+
if (userBlocked) {
89
+
await deleteBlock(client, targetDid);
90
+
userBlocked = false;
91
+
} else {
92
+
await createBlock(client, targetDid);
93
+
userBlocked = true;
94
+
}
95
+
96
+
actionsOpen = false;
97
+
};
98
+
</script>
99
+
100
+
{#snippet dropdownItem(icon: string, label: string, onClick: () => void, disabled: boolean = false)}
101
+
<button
102
+
class="flex items-center justify-between rounded-sm px-2 py-1.5 transition-all duration-100
103
+
{disabled ? 'cursor-not-allowed opacity-50' : 'hover:[backdrop-filter:brightness(120%)]'}"
104
+
onclick={onClick}
105
+
{disabled}
106
+
>
107
+
<span class="font-semibold opacity-85">{label}</span>
108
+
<Icon class="h-6 w-6" {icon} />
109
+
</button>
110
+
{/snippet}
111
+
112
+
<Dropdown
113
+
class="post-dropdown"
114
+
style="background: {color}36; border-color: {color}99;"
115
+
bind:isOpen={actionsOpen}
116
+
bind:position={actionsPos}
117
+
placement="bottom-end"
118
+
>
119
+
{#if !blockedByTarget}
120
+
{@render dropdownItem(
121
+
follow ? 'heroicons:user-minus-20-solid' : 'heroicons:user-plus-20-solid',
122
+
follow ? 'unfollow' : 'follow',
123
+
handleFollow
124
+
)}
125
+
{/if}
126
+
{@render dropdownItem(
127
+
userBlocked ? 'heroicons:eye-20-solid' : 'heroicons:eye-slash-20-solid',
128
+
userBlocked ? 'unblock' : 'block',
129
+
handleBlock
130
+
)}
131
+
132
+
{#snippet trigger()}
133
+
<button
134
+
class="rounded-sm p-1.5 transition-all hover:bg-white/10"
135
+
onclick={(e: MouseEvent) => {
136
+
e.stopPropagation();
137
+
actionsOpen = !actionsOpen;
138
+
actionsPos = { x: 0, y: 0 };
139
+
}}
140
+
title="profile actions"
141
+
>
142
+
<Icon icon="heroicons:ellipsis-horizontal-16-solid" width={24} />
143
+
</button>
144
+
{/snippet}
145
+
</Dropdown>
+116
src/components/ProfileInfo.svelte
+116
src/components/ProfileInfo.svelte
···
1
+
<script lang="ts">
2
+
import { AtpClient, resolveDidDoc } from '$lib/at/client.svelte';
3
+
import type { Did, Handle } from '@atcute/lexicons/syntax';
4
+
import type { AppBskyActorProfile } from '@atcute/bluesky';
5
+
import ProfilePicture from './ProfilePicture.svelte';
6
+
import RichText from './RichText.svelte';
7
+
import { onMount } from 'svelte';
8
+
import { getBlockRelationship, handles, profiles } from '$lib/state.svelte';
9
+
import BlockedUserIndicator from './BlockedUserIndicator.svelte';
10
+
11
+
interface Props {
12
+
client: AtpClient;
13
+
did: Did;
14
+
handle?: Handle;
15
+
profile?: AppBskyActorProfile.Main | null;
16
+
}
17
+
18
+
let {
19
+
client,
20
+
did,
21
+
handle = handles.get(did),
22
+
profile = $bindable(profiles.get(did) ?? null)
23
+
}: Props = $props();
24
+
25
+
const userDid = $derived(client.user?.did);
26
+
const blockRel = $derived(
27
+
userDid ? getBlockRelationship(userDid, did) : { userBlocked: false, blockedByTarget: false }
28
+
);
29
+
const isBlocked = $derived(blockRel.userBlocked || blockRel.blockedByTarget);
30
+
31
+
onMount(async () => {
32
+
// don't load profile info if blocked
33
+
if (isBlocked) return;
34
+
35
+
await Promise.all([
36
+
(async () => {
37
+
if (profile) return;
38
+
const res = await client.getProfile(did);
39
+
if (!res.ok) return;
40
+
profile = res.value;
41
+
profiles.set(did, res.value);
42
+
})(),
43
+
(async () => {
44
+
if (handle) return;
45
+
const res = await resolveDidDoc(did);
46
+
if (!res.ok) return;
47
+
handle = res.value.handle;
48
+
handles.set(did, res.value.handle);
49
+
})()
50
+
]);
51
+
});
52
+
53
+
let displayHandle = $derived(handle ?? 'handle.invalid');
54
+
let profileDesc = $derived(profile?.description?.trim() ?? '');
55
+
let profileDisplayName = $derived(profile?.displayName ?? '');
56
+
let showDid = $state(false);
57
+
</script>
58
+
59
+
{#if isBlocked}
60
+
<BlockedUserIndicator
61
+
{client}
62
+
{did}
63
+
reason={blockRel.userBlocked ? 'blocked' : 'blocks-you'}
64
+
size="normal"
65
+
/>
66
+
{:else}
67
+
<div class="flex flex-col gap-2">
68
+
<div class="flex items-center gap-2">
69
+
<ProfilePicture {client} {did} size={20} />
70
+
71
+
<div class="flex min-w-0 flex-col items-start overflow-hidden overflow-ellipsis">
72
+
<span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis">
73
+
{profileDisplayName.length > 0 ? profileDisplayName : displayHandle}
74
+
{#if profile?.pronouns}
75
+
<span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span>
76
+
{/if}
77
+
</span>
78
+
<button
79
+
oncontextmenu={(e) => {
80
+
e.stopPropagation();
81
+
const node = e.target as Node;
82
+
const selection = window.getSelection() ?? new Selection();
83
+
const range = document.createRange();
84
+
range.selectNodeContents(node);
85
+
selection.removeAllRanges();
86
+
selection.addRange(range);
87
+
}}
88
+
onmousedown={(e) => {
89
+
// disable double clicks to disable "double click to select text"
90
+
// since it doesnt work with us toggling did vs handle
91
+
if (e.detail > 1) e.preventDefault();
92
+
}}
93
+
onclick={() => (showDid = !showDid)}
94
+
class="mb-0.5 text-nowrap opacity-85 select-text hover:underline"
95
+
>
96
+
{showDid ? did : `@${displayHandle}`}
97
+
</button>
98
+
{#if profile?.website}
99
+
<!-- eslint-disable svelte/no-navigation-without-resolve -->
100
+
<a
101
+
target="_blank"
102
+
rel="noopener noreferrer"
103
+
href={profile.website}
104
+
class="text-sm text-nowrap opacity-60 hover:underline">{profile.website}</a
105
+
>
106
+
{/if}
107
+
</div>
108
+
</div>
109
+
110
+
{#if profileDesc.length > 0}
111
+
<div class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word">
112
+
<RichText text={profileDesc} />
113
+
</div>
114
+
{/if}
115
+
</div>
116
+
{/if}
+41
-24
src/components/ProfilePicture.svelte
+41
-24
src/components/ProfilePicture.svelte
···
1
1
<script lang="ts">
2
2
import { generateColorForDid } from '$lib/accounts';
3
-
import type { AtpClient } from '$lib/at/client';
3
+
import type { AtpClient } from '$lib/at/client.svelte';
4
4
import { isBlob } from '@atcute/lexicons/interfaces';
5
5
import PfpPlaceholder from './PfpPlaceholder.svelte';
6
6
import { img } from '$lib/cdn';
7
7
import type { Did } from '@atcute/lexicons';
8
+
import { profiles } from '$lib/state.svelte';
8
9
9
10
interface Props {
10
11
client: AtpClient;
···
14
15
15
16
let { client, did, size }: Props = $props();
16
17
18
+
// svelte-ignore state_referenced_locally
19
+
let avatarBlob = $state(profiles.get(did)?.avatar);
20
+
const avatarUrl: string | null = $derived(
21
+
isBlob(avatarBlob) ? img('avatar_thumbnail', did, avatarBlob.ref.$link) : null
22
+
);
23
+
24
+
const loadProfile = async (targetDid: Did) => {
25
+
const cachedBlob = profiles.get(did)?.avatar;
26
+
if (cachedBlob) {
27
+
avatarBlob = cachedBlob;
28
+
return;
29
+
}
30
+
31
+
try {
32
+
const profile = await client.getProfile(targetDid);
33
+
if (profile.ok) {
34
+
avatarBlob = profile.value.avatar;
35
+
profiles.set(did, profile.value);
36
+
} else avatarBlob = undefined;
37
+
} catch (e) {
38
+
console.error(`${targetDid}: failed to load pfp`, e);
39
+
avatarBlob = undefined;
40
+
}
41
+
};
42
+
43
+
$effect(() => {
44
+
loadProfile(did);
45
+
});
46
+
17
47
let color = $derived(generateColorForDid(did));
18
48
</script>
19
49
20
-
{#snippet missingPfp()}
50
+
{#if avatarUrl}
51
+
<img
52
+
class="rounded-sm bg-(--nucleus-accent)/10"
53
+
loading="lazy"
54
+
style="width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});"
55
+
alt="avatar for {did}"
56
+
src={avatarUrl}
57
+
/>
58
+
{:else}
21
59
<PfpPlaceholder {color} {size} />
22
-
{/snippet}
23
-
24
-
{#await client.getProfile(did)}
25
-
{@render missingPfp()}
26
-
{:then profile}
27
-
{#if profile.ok}
28
-
{@const record = profile.value}
29
-
{#if isBlob(record.avatar)}
30
-
<img
31
-
class="rounded-sm"
32
-
loading="lazy"
33
-
style="width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});"
34
-
alt="avatar for {did}"
35
-
src={img('avatar_thumbnail', did, record.avatar.ref.$link)}
36
-
/>
37
-
{:else}
38
-
{@render missingPfp()}
39
-
{/if}
40
-
{:else}
41
-
{@render missingPfp()}
42
-
{/if}
43
-
{/await}
60
+
{/if}
+159
src/components/ProfileView.svelte
+159
src/components/ProfileView.svelte
···
1
+
<script lang="ts">
2
+
import { AtpClient, resolveDidDoc } from '$lib/at/client.svelte';
3
+
import { isDid, isHandle, type ActorIdentifier, type Did } from '@atcute/lexicons/syntax';
4
+
import TimelineView from './TimelineView.svelte';
5
+
import ProfileInfo from './ProfileInfo.svelte';
6
+
import type { State as PostComposerState } from './PostComposer.svelte';
7
+
import Icon from '@iconify/svelte';
8
+
import { accounts, generateColorForDid } from '$lib/accounts';
9
+
import { img } from '$lib/cdn';
10
+
import { isBlob } from '@atcute/lexicons/interfaces';
11
+
import {
12
+
handles,
13
+
profiles,
14
+
getBlockRelationship,
15
+
fetchBlocked,
16
+
blockFlags
17
+
} from '$lib/state.svelte';
18
+
import BlockedUserIndicator from './BlockedUserIndicator.svelte';
19
+
import ProfileActions from './ProfileActions.svelte';
20
+
21
+
interface Props {
22
+
client: AtpClient;
23
+
actor: string;
24
+
onBack: () => void;
25
+
postComposerState: PostComposerState;
26
+
}
27
+
28
+
let { client, actor, onBack, postComposerState = $bindable() }: Props = $props();
29
+
30
+
const profile = $derived(profiles.get(actor as Did));
31
+
const displayName = $derived(profile?.displayName ?? '');
32
+
const handle = $derived(isHandle(actor) ? actor : handles.get(actor as Did));
33
+
let loading = $state(true);
34
+
let error = $state<string | null>(null);
35
+
let did = $state(isDid(actor) ? actor : null);
36
+
37
+
let userBlocked = $state(false);
38
+
let blockedByTarget = $state(false);
39
+
40
+
const loadProfile = async (identifier: ActorIdentifier) => {
41
+
loading = true;
42
+
error = null;
43
+
44
+
const docRes = await resolveDidDoc(identifier);
45
+
if (docRes.ok) {
46
+
did = docRes.value.did;
47
+
handles.set(did, docRes.value.handle);
48
+
} else {
49
+
error = docRes.error;
50
+
return;
51
+
}
52
+
53
+
// check block relationship
54
+
if (client.user?.did) {
55
+
let blockRel = getBlockRelationship(client.user.did, did);
56
+
blockRel = blockFlags.get(client.user.did)?.has(did)
57
+
? blockRel
58
+
: await (async () => {
59
+
const [userBlocked, blockedByTarget] = await Promise.all([
60
+
await fetchBlocked(client, did, client.user!.did),
61
+
await fetchBlocked(client, client.user!.did, did)
62
+
]);
63
+
return { userBlocked, blockedByTarget };
64
+
})();
65
+
userBlocked = blockRel.userBlocked;
66
+
blockedByTarget = blockRel.blockedByTarget;
67
+
}
68
+
69
+
// don't load profile if blocked
70
+
if (userBlocked || blockedByTarget) {
71
+
loading = false;
72
+
return;
73
+
}
74
+
75
+
const res = await client.getProfile(did, true);
76
+
if (res.ok) profiles.set(did, res.value);
77
+
else error = res.error;
78
+
79
+
loading = false;
80
+
};
81
+
82
+
$effect(() => {
83
+
// if we have accounts, wait until we are logged in to load the profile
84
+
if (!($accounts.length > 0 && !client.user?.did)) loadProfile(actor as ActorIdentifier);
85
+
});
86
+
87
+
const color = $derived(did ? generateColorForDid(did) : 'var(--nucleus-accent)');
88
+
const bannerUrl = $derived(
89
+
did && profile && isBlob(profile.banner)
90
+
? img('feed_fullsize', did, profile.banner.ref.$link)
91
+
: null
92
+
);
93
+
</script>
94
+
95
+
<div class="flex min-h-dvh flex-col">
96
+
<!-- header -->
97
+
<div
98
+
class="sticky top-0 z-20 flex items-center gap-4 border-b-2 bg-(--nucleus-bg)/80 p-2 backdrop-blur-md"
99
+
style="border-color: {color};"
100
+
>
101
+
<button
102
+
onclick={onBack}
103
+
class="rounded-sm p-1 text-(--nucleus-fg) transition-all hover:bg-(--nucleus-fg)/10"
104
+
>
105
+
<Icon icon="heroicons:arrow-left-20-solid" width={24} />
106
+
</button>
107
+
<h2 class="text-xl font-bold">
108
+
{displayName.length > 0 ? displayName : loading ? 'loading...' : (handle ?? 'handle.invalid')}
109
+
</h2>
110
+
<div class="grow"></div>
111
+
{#if did && client.user && client.user.did !== did}
112
+
<ProfileActions {client} targetDid={did} bind:userBlocked {blockedByTarget} />
113
+
{/if}
114
+
</div>
115
+
116
+
{#if !loading}
117
+
{#if error}
118
+
<div class="p-8 text-center text-red-500">
119
+
<p>failed to load profile: {error}</p>
120
+
</div>
121
+
{:else if userBlocked || blockedByTarget}
122
+
<div class="p-8">
123
+
<BlockedUserIndicator
124
+
{client}
125
+
did={did!}
126
+
reason={userBlocked ? 'blocked' : 'blocks-you'}
127
+
size="large"
128
+
/>
129
+
</div>
130
+
{:else}
131
+
<!-- banner -->
132
+
{#if bannerUrl}
133
+
<div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48">
134
+
<img src={bannerUrl} alt="banner" class="h-full w-full object-cover" />
135
+
<div
136
+
class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)"
137
+
style="opacity: 0.8;"
138
+
></div>
139
+
</div>
140
+
{/if}
141
+
142
+
{#if did}
143
+
<div class="px-4 pb-4">
144
+
<div class="relative z-10 {bannerUrl ? '-mt-12' : 'mt-4'} mb-4">
145
+
<ProfileInfo {client} {did} {profile} />
146
+
</div>
147
+
148
+
<TimelineView
149
+
showReplies={false}
150
+
{client}
151
+
targetDid={did}
152
+
bind:postComposerState
153
+
class="min-h-[50vh]"
154
+
/>
155
+
</div>
156
+
{/if}
157
+
{/if}
158
+
{/if}
159
+
</div>
+14
-5
src/components/RichText.svelte
+14
-5
src/components/RichText.svelte
···
1
1
<script lang="ts">
2
2
import { parseToRichText } from '$lib/richtext';
3
3
import { settings } from '$lib/settings';
4
+
import { router } from '$lib/state.svelte';
4
5
import type { BakedRichtext } from '@atcute/bluesky-richtext-builder';
5
6
import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter';
6
7
···
14
15
const richtext: Promise<BakedRichtext> = $derived(
15
16
facets ? Promise.resolve({ text, facets }) : parseToRichText(text)
16
17
);
18
+
19
+
const handleProfileClick = (e: MouseEvent, did: string) => {
20
+
e.preventDefault();
21
+
router.navigate(`/profile/${did}`);
22
+
};
17
23
</script>
18
24
19
25
{#snippet plainText(text: string)}
···
27
33
{/snippet}
28
34
29
35
{#snippet segments(segments: RichtextSegment[])}
30
-
{#each segments as segment, idx ([segment, idx])}
36
+
{#each segments as segment, idx (idx)}
31
37
{@const { text, features: _features } = segment}
32
38
{@const features = _features ?? []}
33
39
{#if features.length > 0}
34
-
{#each features as feature, idx ([feature, idx])}
40
+
<!-- eslint-disable svelte/no-navigation-without-resolve -->
41
+
{#each features as feature, idx (idx)}
35
42
{#if feature.$type === 'app.bsky.richtext.facet#mention'}
36
43
<a
37
-
class="text-(--nucleus-accent2)"
38
-
href={`${$settings.socialAppUrl}/profile/${feature.did}`}>{@render plainText(text)}</a
44
+
class="text-(--nucleus-accent2) hover:cursor-pointer hover:underline"
45
+
href={`/profile/${feature.did}`}
46
+
onclick={(e) => handleProfileClick(e, feature.did)}>{@render plainText(text)}</a
39
47
>
40
48
{:else if feature.$type === 'app.bsky.richtext.facet#link'}
41
49
{@const uri = new URL(feature.uri)}
···
51
59
<a
52
60
class="text-(--nucleus-accent2)"
53
61
href={`${$settings.socialAppUrl}/search?q=${encodeURIComponent('#' + feature.tag)}`}
54
-
>{@render plainText(text)}</a
62
+
target="_blank"
63
+
rel="noopener noreferrer">{@render plainText(text)}</a
55
64
>
56
65
{:else}
57
66
<span>{@render plainText(text)}</span>
+12
-10
src/components/SettingsView.svelte
+12
-10
src/components/SettingsView.svelte
···
5
5
import Tabs from './Tabs.svelte';
6
6
import { portal } from 'svelte-portal';
7
7
import { cache } from '$lib/cache';
8
+
import { router } from '$lib/state.svelte';
8
9
9
-
type Tab = 'style' | 'moderation' | 'advanced';
10
-
let activeTab = $state<Tab>('advanced');
10
+
interface Props {
11
+
tab: string;
12
+
}
13
+
14
+
let { tab }: Props = $props();
11
15
12
16
let localSettings = $state(get(settings));
13
17
let hasReloadChanges = $derived(needsReload($settings, localSettings));
···
32
36
cache.clear();
33
37
alert('cache cleared!');
34
38
};
39
+
40
+
const onTabChange = (tab: string) => router.replace(`/settings/${tab}`);
35
41
</script>
36
42
37
43
{#snippet advancedTab()}
···
138
144
</div>
139
145
140
146
<div class="flex-1">
141
-
{#if activeTab === 'advanced'}
147
+
{#if tab === 'advanced'}
142
148
{@render advancedTab()}
143
-
{:else if activeTab === 'moderation'}
149
+
{:else if tab === 'moderation'}
144
150
<div class="p-4">
145
151
<div class="flex h-64 items-center justify-center">
146
152
<div class="text-center">
···
149
155
</div>
150
156
</div>
151
157
</div>
152
-
{:else if activeTab === 'style'}
158
+
{:else if tab === 'style'}
153
159
{@render styleTab()}
154
160
{/if}
155
161
</div>
···
160
166
z-20 w-full max-w-2xl bg-(--nucleus-bg) p-4 pt-2 pb-1 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)]
161
167
"
162
168
>
163
-
<Tabs
164
-
tabs={['style', 'moderation', 'advanced']}
165
-
bind:activeTab
166
-
onTabChange={(tab) => (activeTab = tab)}
167
-
/>
169
+
<Tabs tabs={['style', 'moderation', 'advanced']} activeTab={tab} {onTabChange} />
168
170
</div>
169
171
</div>
170
172
+90
-30
src/components/TimelineView.svelte
+90
-30
src/components/TimelineView.svelte
···
1
1
<script lang="ts">
2
2
import BskyPost from './BskyPost.svelte';
3
3
import { type State as PostComposerState } from './PostComposer.svelte';
4
-
import { AtpClient } from '$lib/at/client';
5
-
import { accounts, type Account } from '$lib/accounts';
4
+
import { AtpClient } from '$lib/at/client.svelte';
5
+
import { accounts } from '$lib/accounts';
6
6
import { type ResourceUri } from '@atcute/lexicons';
7
7
import { SvelteSet } from 'svelte/reactivity';
8
8
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
9
-
import { cursors, fetchTimeline, posts, viewClient } from '$lib/state.svelte';
9
+
import {
10
+
postCursors,
11
+
fetchTimeline,
12
+
allPosts,
13
+
timelines,
14
+
fetchInteractionsToTimelineEnd
15
+
} from '$lib/state.svelte';
10
16
import Icon from '@iconify/svelte';
11
17
import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread';
18
+
import type { Did } from '@atcute/lexicons/syntax';
19
+
import NotLoggedIn from './NotLoggedIn.svelte';
12
20
13
21
interface Props {
14
22
client?: AtpClient | null;
23
+
targetDid?: Did;
15
24
postComposerState: PostComposerState;
16
25
class?: string;
26
+
// whether to show replies that are not the user's own posts
27
+
showReplies?: boolean;
17
28
}
18
29
19
-
let { client = null, postComposerState = $bindable(), class: className = '' }: Props = $props();
20
-
21
-
const effectiveClient = $derived(client ?? viewClient);
30
+
let {
31
+
client = null,
32
+
targetDid = undefined,
33
+
showReplies = true,
34
+
postComposerState = $bindable(),
35
+
class: className = ''
36
+
}: Props = $props();
22
37
23
38
let reverseChronological = $state(true);
24
39
let viewOwnPosts = $state(true);
25
40
const expandedThreads = new SvelteSet<ResourceUri>();
41
+
42
+
const userDid = $derived(client?.user?.did);
43
+
const did = $derived(targetDid ?? userDid);
26
44
27
45
const threads = $derived(
46
+
// todo: apply showReplies here
28
47
filterThreads(
29
-
buildThreads(
30
-
$accounts.map((account) => account.did),
31
-
posts
32
-
),
48
+
did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [],
33
49
$accounts,
34
50
{ viewOwnPosts }
35
51
)
···
40
56
let loading = $state(false);
41
57
let loadError = $state('');
42
58
43
-
const fetchTimelines = (newAccounts: Account[]) =>
44
-
Promise.all(newAccounts.map((acc) => fetchTimeline(acc.did)));
45
-
46
59
const loadMore = async () => {
47
-
if (loading || $accounts.length === 0) return;
60
+
if (loading || !client || !did) return;
48
61
49
62
loading = true;
50
63
loaderState.status = 'LOADING';
51
64
52
65
try {
53
-
await fetchTimelines($accounts);
66
+
await fetchTimeline(client, did, 7, showReplies, {
67
+
downwards: userDid === did ? 'sameAuthor' : 'none'
68
+
});
69
+
// only fetch interactions if logged in (because if not who is the interactor)
70
+
if (client.user && userDid) {
71
+
if (!fetchingInteractions) {
72
+
scheduledFetchInteractions = false;
73
+
fetchingInteractions = true;
74
+
await fetchInteractionsToTimelineEnd(client, userDid, did);
75
+
fetchingInteractions = false;
76
+
} else {
77
+
scheduledFetchInteractions = true;
78
+
}
79
+
}
54
80
loaderState.loaded();
55
81
} catch (error) {
56
82
loadError = `${error}`;
···
60
86
}
61
87
62
88
loading = false;
63
-
if (cursors.values().every((cursor) => cursor.end)) loaderState.complete();
89
+
const cursor = postCursors.get(did);
90
+
if (cursor && cursor.end) loaderState.complete();
64
91
};
92
+
93
+
$effect(() => {
94
+
if (threads.length === 0 && !loading && userDid && did) {
95
+
// if we saw all posts dont try to load more.
96
+
// this only really happens if the user has no posts at all
97
+
// but we do have to handle it to not cause an infinite loop
98
+
const cursor = did ? postCursors.get(did) : undefined;
99
+
if (!cursor?.end) loadMore();
100
+
}
101
+
});
102
+
103
+
let fetchingInteractions = $state(false);
104
+
let scheduledFetchInteractions = $state(false);
105
+
// we want to load interactions when changing logged in user
106
+
// only on timelines that arent logged in users, because those are already
107
+
// loaded by loadMore
108
+
$effect(() => {
109
+
if (client && scheduledFetchInteractions && userDid && did && did !== userDid) {
110
+
if (!fetchingInteractions) {
111
+
scheduledFetchInteractions = false;
112
+
fetchingInteractions = true;
113
+
fetchInteractionsToTimelineEnd(client, userDid, did).finally(
114
+
() => (fetchingInteractions = false)
115
+
);
116
+
} else {
117
+
scheduledFetchInteractions = true;
118
+
}
119
+
}
120
+
});
65
121
</script>
66
122
67
123
{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)}
···
69
125
class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis"
70
126
>
71
127
<span class="text-sm text-nowrap opacity-60">{reverse ? 'โฑ' : 'โณ'}</span>
72
-
<BskyPost mini client={effectiveClient} {...post} />
128
+
<BskyPost mini client={client!} {...post} />
73
129
</span>
74
130
{/snippet}
75
131
76
132
{#snippet threadsView()}
77
-
{#each threads as thread (thread.rootUri)}
133
+
{#each threads as thread, i (thread.rootUri)}
78
134
<div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}">
79
135
{#if thread.branchParentPost}
80
136
{@render replyPost(thread.branchParentPost)}
···
88
144
{#if !mini}
89
145
<div class="mb-1.5">
90
146
<BskyPost
91
-
client={effectiveClient}
92
-
onQuote={(post) => (postComposerState = { type: 'focused', quoting: post })}
93
-
onReply={(post) => (postComposerState = { type: 'focused', replying: post })}
147
+
client={client!}
148
+
onQuote={(post) => {
149
+
postComposerState.focus = 'focused';
150
+
postComposerState.quoting = post;
151
+
}}
152
+
onReply={(post) => {
153
+
postComposerState.focus = 'focused';
154
+
postComposerState.replying = post;
155
+
}}
94
156
{...post}
95
157
/>
96
158
</div>
···
117
179
{/if}
118
180
{/each}
119
181
</div>
120
-
<div
121
-
class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
122
-
></div>
182
+
{#if i < threads.length - 1}
183
+
<div
184
+
class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
185
+
></div>
186
+
{/if}
123
187
{/each}
124
188
{/snippet}
125
189
···
127
191
class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}"
128
192
bind:this={scrollContainer}
129
193
>
130
-
{#if $accounts.length > 0}
194
+
{#if targetDid || $accounts.length > 0}
131
195
<InfiniteLoader
132
196
{loaderState}
133
197
triggerLoad={loadMore}
···
165
229
{/snippet}
166
230
</InfiniteLoader>
167
231
{:else}
168
-
<div class="flex justify-center py-4">
169
-
<p class="text-xl opacity-80">
170
-
<span class="text-4xl">x_x</span> <br /> no accounts are logged in!
171
-
</p>
172
-
</div>
232
+
<NotLoggedIn />
173
233
{/if}
174
234
</div>
+506
src/lib/at/client.svelte.ts
+506
src/lib/at/client.svelte.ts
···
1
+
/* eslint-disable svelte/prefer-svelte-reactivity */
2
+
import { err, expect, map, ok, type OkType, type Result } from '$lib/result';
3
+
import {
4
+
ComAtprotoIdentityResolveHandle,
5
+
ComAtprotoRepoGetRecord,
6
+
ComAtprotoRepoListRecords
7
+
} from '@atcute/atproto';
8
+
import { Client as AtcuteClient, simpleFetchHandler } from '@atcute/client';
9
+
import { safeParse, type Blob as AtpBlob, type Handle, type InferOutput } from '@atcute/lexicons';
10
+
import {
11
+
isDid,
12
+
parseResourceUri,
13
+
type ActorIdentifier,
14
+
type AtprotoDid,
15
+
type Cid,
16
+
type Did,
17
+
type Nsid,
18
+
type RecordKey,
19
+
type ResourceUri
20
+
} from '@atcute/lexicons/syntax';
21
+
import type {
22
+
InferInput,
23
+
InferXRPCBodyOutput,
24
+
ObjectSchema,
25
+
RecordKeySchema,
26
+
RecordSchema,
27
+
XRPCQueryMetadata
28
+
} from '@atcute/lexicons/validations';
29
+
import * as v from '@atcute/lexicons/validations';
30
+
import { MiniDocQuery, type MiniDoc } from './slingshot';
31
+
import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';
32
+
import type { Records, XRPCProcedures } from '@atcute/lexicons/ambient';
33
+
import { cache as rawCache, ttl } from '$lib/cache';
34
+
import { AppBskyActorProfile } from '@atcute/bluesky';
35
+
import { WebSocket } from '@soffinal/websocket';
36
+
import type { Notification } from './stardust';
37
+
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
38
+
import { timestampFromCursor, toCanonicalUri, toResourceUri } from '$lib';
39
+
import { constellationUrl, httpToDidWeb, slingshotUrl, spacedustUrl } from '.';
40
+
41
+
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
42
+
43
+
const cacheWithHandles = rawCache.define(
44
+
'resolveHandle',
45
+
async (handle: Handle): Promise<AtprotoDid> => {
46
+
const res = await fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, {
47
+
handle
48
+
});
49
+
if (!res.ok) throw new Error(res.error);
50
+
return res.value.did as AtprotoDid;
51
+
}
52
+
);
53
+
54
+
const cacheWithDidDocs = cacheWithHandles.define(
55
+
'resolveDidDoc',
56
+
async (identifier: ActorIdentifier): Promise<MiniDoc> => {
57
+
const res = await fetchMicrocosm(slingshotUrl, MiniDocQuery, {
58
+
identifier
59
+
});
60
+
if (!res.ok) throw new Error(res.error);
61
+
return res.value;
62
+
}
63
+
);
64
+
65
+
const cacheWithRecords = cacheWithDidDocs.define('fetchRecord', async (uri: ResourceUri) => {
66
+
const parsedUri = parseResourceUri(uri);
67
+
if (!parsedUri.ok) throw new Error(`can't parse resource uri: ${parsedUri.error}`);
68
+
const { repo, collection, rkey } = parsedUri.value;
69
+
const res = await fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, {
70
+
repo,
71
+
collection: collection!,
72
+
rkey: rkey!
73
+
});
74
+
if (!res.ok) throw new Error(res.error);
75
+
return res.value;
76
+
});
77
+
78
+
const cache = cacheWithRecords;
79
+
80
+
export const invalidateRecordCache = async (uri: ResourceUri) => {
81
+
console.log(`invalidating cached for ${uri}`);
82
+
await cache.invalidate('fetchRecord', `fetchRecord~${uri}`);
83
+
};
84
+
export const setRecordCache = (uri: ResourceUri, record: unknown) =>
85
+
cache.set('fetchRecord', `fetchRecord~${uri}`, record, ttl);
86
+
87
+
export const xhrPost = (
88
+
url: string,
89
+
body: Blob | File,
90
+
headers: Record<string, string> = {},
91
+
onProgress?: (uploaded: number, total: number) => void
92
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
93
+
): Promise<Result<any, { error: string; message: string }>> => {
94
+
return new Promise((resolve) => {
95
+
const xhr = new XMLHttpRequest();
96
+
xhr.open('POST', url);
97
+
98
+
if (onProgress && xhr.upload)
99
+
xhr.upload.onprogress = (event: ProgressEvent) => {
100
+
if (event.lengthComputable) onProgress(event.loaded, event.total);
101
+
};
102
+
103
+
Object.keys(headers).forEach((key) => xhr.setRequestHeader(key, headers[key]));
104
+
105
+
xhr.onload = () => {
106
+
if (xhr.status >= 200 && xhr.status < 300) resolve(ok(JSON.parse(xhr.responseText)));
107
+
else resolve(err(JSON.parse(xhr.responseText)));
108
+
};
109
+
110
+
xhr.onerror = () => resolve(err({ error: 'xhr_error', message: 'network error' }));
111
+
xhr.onabort = () => resolve(err({ error: 'xhr_error', message: 'upload aborted' }));
112
+
xhr.send(body);
113
+
});
114
+
};
115
+
116
+
export type UploadStatus =
117
+
| { stage: 'auth' }
118
+
| { stage: 'uploading'; progress?: number }
119
+
| { stage: 'processing'; progress?: number }
120
+
| { stage: 'complete' };
121
+
122
+
export type Auth = {
123
+
atcute: AtcuteClient;
124
+
} & MiniDoc;
125
+
126
+
export class AtpClient {
127
+
public user: Auth | null = $state(null);
128
+
129
+
async login(agent: OAuthUserAgent): Promise<Result<null, string>> {
130
+
try {
131
+
const rpc = new AtcuteClient({ handler: agent });
132
+
const res = await rpc.get('com.atproto.server.getSession');
133
+
if (!res.ok) throw res.data.error;
134
+
this.user = {
135
+
atcute: rpc,
136
+
did: res.data.did,
137
+
handle: res.data.handle,
138
+
pds: agent.session.info.aud as `${string}:${string}`,
139
+
signing_key: ''
140
+
};
141
+
} catch (error) {
142
+
return err(`failed to login: ${error}`);
143
+
}
144
+
145
+
return ok(null);
146
+
}
147
+
148
+
async getRecordUri<
149
+
Collection extends Nsid,
150
+
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
151
+
TKey extends RecordKeySchema,
152
+
Schema extends RecordSchema<TObject, TKey>,
153
+
Output extends InferInput<Schema>
154
+
>(
155
+
schema: Schema,
156
+
uri: ResourceUri,
157
+
noCache?: boolean
158
+
): Promise<Result<RecordOutput<Output>, string>> {
159
+
const parsedUri = expect(parseResourceUri(uri));
160
+
if (parsedUri.collection !== schema.object.shape.$type.expected)
161
+
return err(
162
+
`collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}`
163
+
);
164
+
return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!, noCache);
165
+
}
166
+
167
+
async getRecord<
168
+
Collection extends Nsid,
169
+
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
170
+
TKey extends RecordKeySchema,
171
+
Schema extends RecordSchema<TObject, TKey>,
172
+
Output extends InferInput<Schema>
173
+
>(
174
+
schema: Schema,
175
+
repo: ActorIdentifier,
176
+
rkey: RecordKey,
177
+
noCache?: boolean
178
+
): Promise<Result<RecordOutput<Output>, string>> {
179
+
const collection = schema.object.shape.$type.expected;
180
+
181
+
try {
182
+
const uri = toResourceUri({ repo, collection, rkey, fragment: undefined });
183
+
if (noCache) await invalidateRecordCache(uri);
184
+
const rawValue = await cache.fetchRecord(uri);
185
+
186
+
const parsed = safeParse(schema, rawValue.value);
187
+
if (!parsed.ok) return err(parsed.message);
188
+
189
+
return ok({
190
+
uri: rawValue.uri,
191
+
cid: rawValue.cid,
192
+
record: parsed.value as Output
193
+
});
194
+
} catch (e) {
195
+
return err(String(e));
196
+
}
197
+
}
198
+
199
+
async getProfile(
200
+
repo?: ActorIdentifier,
201
+
noCache?: boolean
202
+
): Promise<Result<AppBskyActorProfile.Main, string>> {
203
+
repo = repo ?? this.user?.did;
204
+
if (!repo) return err('not authenticated');
205
+
return map(
206
+
await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self', noCache),
207
+
(d) => d.record
208
+
);
209
+
}
210
+
211
+
async listRecords<Collection extends keyof Records>(
212
+
ident: ActorIdentifier,
213
+
collection: Collection,
214
+
cursor?: string,
215
+
limit: number = 100
216
+
): Promise<
217
+
Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string>
218
+
> {
219
+
const auth = this.user;
220
+
if (!auth) return err('not authenticated');
221
+
const docRes = await resolveDidDoc(ident);
222
+
if (!docRes.ok) return docRes;
223
+
const atp =
224
+
auth.did === docRes.value.did
225
+
? auth.atcute
226
+
: new AtcuteClient({ handler: simpleFetchHandler({ service: docRes.value.pds }) });
227
+
const res = await atp.get('com.atproto.repo.listRecords', {
228
+
params: {
229
+
repo: docRes.value.did,
230
+
collection,
231
+
cursor,
232
+
limit,
233
+
reverse: false
234
+
}
235
+
});
236
+
if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`);
237
+
238
+
for (const record of res.data.records) setRecordCache(record.uri, record);
239
+
240
+
return ok(res.data);
241
+
}
242
+
243
+
async listRecordsUntil<Collection extends keyof Records>(
244
+
ident: ActorIdentifier,
245
+
collection: Collection,
246
+
cursor?: string,
247
+
timestamp: number = -1
248
+
): Promise<ReturnType<typeof this.listRecords>> {
249
+
const data: OkType<Awaited<ReturnType<typeof this.listRecords>>> = {
250
+
records: [],
251
+
cursor
252
+
};
253
+
254
+
let end = false;
255
+
while (!end) {
256
+
const res = await this.listRecords(ident, collection, data.cursor);
257
+
if (!res.ok) return res;
258
+
data.cursor = res.value.cursor;
259
+
data.records.push(...res.value.records);
260
+
end = data.records.length === 0 || !data.cursor;
261
+
if (!end && timestamp > 0) {
262
+
const cursorTimestamp = timestampFromCursor(data.cursor);
263
+
if (cursorTimestamp === undefined) {
264
+
console.warn(
265
+
'could not parse timestamp from cursor, stopping fetch to prevent infinite loop:',
266
+
data.cursor
267
+
);
268
+
end = true;
269
+
} else if (cursorTimestamp <= timestamp) {
270
+
end = true;
271
+
} else {
272
+
console.info(
273
+
`${ident}: continuing to fetch ${collection}, on ${cursorTimestamp} until ${timestamp}`
274
+
);
275
+
}
276
+
}
277
+
}
278
+
279
+
return ok(data);
280
+
}
281
+
282
+
async getBacklinks(
283
+
subject: ResourceUri,
284
+
source: BacklinksSource,
285
+
filterBy?: Did[],
286
+
limit?: number
287
+
): Promise<Result<Backlinks, string>> {
288
+
const { repo, collection, rkey } = expect(parseResourceUri(subject));
289
+
const did = await resolveHandle(repo);
290
+
if (!did.ok) return err(`cant resolve handle: ${did.error}`);
291
+
292
+
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000));
293
+
const query = fetchMicrocosm(constellationUrl, BacklinksQuery, {
294
+
subject: collection ? toCanonicalUri({ did: did.value, collection, rkey: rkey! }) : did.value,
295
+
source,
296
+
limit: limit || 100,
297
+
did: filterBy
298
+
});
299
+
300
+
const results = await Promise.race([query, timeout]);
301
+
if (!results) return err('cant fetch backlinks: timeout');
302
+
303
+
return results;
304
+
}
305
+
306
+
async getServiceAuth(lxm: keyof XRPCProcedures, exp: number): Promise<Result<string, string>> {
307
+
const auth = this.user;
308
+
if (!auth) return err('not authenticated');
309
+
const serviceAuthUrl = new URL(`${auth.pds}xrpc/com.atproto.server.getServiceAuth`);
310
+
serviceAuthUrl.searchParams.append('aud', httpToDidWeb(auth.pds));
311
+
serviceAuthUrl.searchParams.append('lxm', 'com.atproto.repo.uploadBlob');
312
+
serviceAuthUrl.searchParams.append('exp', exp.toString()); // 30 minutes
313
+
314
+
const serviceAuthResponse = await auth.atcute.handler(
315
+
`${serviceAuthUrl.pathname}${serviceAuthUrl.search}`,
316
+
{
317
+
method: 'GET'
318
+
}
319
+
);
320
+
if (!serviceAuthResponse.ok) {
321
+
const error = await serviceAuthResponse.text();
322
+
return err(`failed to get service auth: ${error}`);
323
+
}
324
+
const serviceAuth = await serviceAuthResponse.json();
325
+
return ok(serviceAuth.token);
326
+
}
327
+
328
+
async uploadBlob(
329
+
blob: Blob,
330
+
onProgress?: (progress: number) => void
331
+
): Promise<Result<AtpBlob<string>, string>> {
332
+
const auth = this.user;
333
+
if (!auth) return err('not authenticated');
334
+
const tokenResult = await this.getServiceAuth(
335
+
'com.atproto.repo.uploadBlob',
336
+
Math.floor(Date.now() / 1000) + 60
337
+
);
338
+
if (!tokenResult.ok) return tokenResult;
339
+
const result = await xhrPost(
340
+
`${auth.pds}xrpc/com.atproto.repo.uploadBlob`,
341
+
blob,
342
+
{ authorization: `Bearer ${tokenResult.value}` },
343
+
(uploaded, total) => onProgress?.(uploaded / total)
344
+
);
345
+
if (!result.ok) return err(`upload failed: ${result.error.message}`);
346
+
return ok(result.value.blob);
347
+
}
348
+
349
+
async uploadVideo(
350
+
blob: Blob,
351
+
mimeType: string,
352
+
onStatus?: (status: UploadStatus) => void
353
+
): Promise<Result<AtpBlob<string>, string>> {
354
+
const auth = this.user;
355
+
if (!auth) return err('not authenticated');
356
+
357
+
onStatus?.({ stage: 'auth' });
358
+
const tokenResult = await this.getServiceAuth(
359
+
'com.atproto.repo.uploadBlob',
360
+
Math.floor(Date.now() / 1000) + 60 * 30
361
+
);
362
+
if (!tokenResult.ok) return tokenResult;
363
+
364
+
onStatus?.({ stage: 'uploading' });
365
+
const uploadUrl = new URL('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo');
366
+
uploadUrl.searchParams.append('did', auth.did);
367
+
uploadUrl.searchParams.append('name', 'video');
368
+
369
+
const uploadResult = await xhrPost(
370
+
uploadUrl.toString(),
371
+
blob,
372
+
{
373
+
Authorization: `Bearer ${tokenResult.value}`,
374
+
'Content-Type': mimeType
375
+
},
376
+
(uploaded, total) => onStatus?.({ stage: 'uploading', progress: uploaded / total })
377
+
);
378
+
if (!uploadResult.ok) return err(`failed to upload video: ${uploadResult.error}`);
379
+
const jobStatus = uploadResult.value;
380
+
let videoBlobRef: AtpBlob<string> = jobStatus.blob;
381
+
382
+
onStatus?.({ stage: 'processing' });
383
+
while (!videoBlobRef) {
384
+
await new Promise((resolve) => setTimeout(resolve, 1000));
385
+
386
+
const statusResponse = await fetch(
387
+
`https://video.bsky.app/xrpc/app.bsky.video.getJobStatus?jobId=${jobStatus.jobId}`
388
+
);
389
+
390
+
if (!statusResponse.ok) {
391
+
const error = await statusResponse.json();
392
+
// reuse blob
393
+
if (error.error === 'already_exists' && error.blob) {
394
+
videoBlobRef = error.blob;
395
+
break;
396
+
}
397
+
return err(`failed to get job status: ${error.message || error.error}`);
398
+
}
399
+
400
+
const status = await statusResponse.json();
401
+
if (status.jobStatus.blob) {
402
+
videoBlobRef = status.jobStatus.blob;
403
+
} else if (status.jobStatus.state === 'JOB_STATE_FAILED') {
404
+
return err(`video processing failed: ${status.jobStatus.error || 'unknown error'}`);
405
+
} else if (status.jobStatus.progress !== undefined) {
406
+
onStatus?.({
407
+
stage: 'processing',
408
+
progress: status.jobStatus.progress / 100
409
+
});
410
+
}
411
+
}
412
+
413
+
onStatus?.({ stage: 'complete' });
414
+
return ok(videoBlobRef);
415
+
}
416
+
}
417
+
418
+
// export const newPublicClient = async (ident: ActorIdentifier) => {
419
+
// const atp = new AtpClient();
420
+
// const didDoc = await resolveDidDoc(ident);
421
+
// if (!didDoc.ok) {
422
+
// console.error('failed to resolve did doc', didDoc.error);
423
+
// return atp;
424
+
// }
425
+
// atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) });
426
+
// atp.user = didDoc.value;
427
+
// return atp;
428
+
// };
429
+
430
+
export const resolveHandle = (identifier: ActorIdentifier) => {
431
+
if (isDid(identifier)) return Promise.resolve(ok(identifier as AtprotoDid));
432
+
433
+
return cache
434
+
.resolveHandle(identifier)
435
+
.then((did) => ok(did))
436
+
.catch((e) => err(String(e)));
437
+
};
438
+
439
+
export const resolveDidDoc = (ident: ActorIdentifier) =>
440
+
cache
441
+
.resolveDidDoc(ident)
442
+
.then((doc) => ok(doc))
443
+
.catch((e) => err(String(e)));
444
+
445
+
type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>;
446
+
export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
447
+
export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>;
448
+
449
+
export const streamNotifications = (
450
+
subjects: Did[],
451
+
...sources: BacklinksSource[]
452
+
): NotificationsStream => {
453
+
const url = new URL(spacedustUrl);
454
+
url.protocol = 'wss:';
455
+
url.pathname = '/subscribe';
456
+
const searchParams = [];
457
+
sources.every((source) => searchParams.push(['wantedSources', source]));
458
+
subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject]));
459
+
subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`]));
460
+
searchParams.push(['instant', 'true']);
461
+
url.search = `?${new URLSearchParams(searchParams)}`;
462
+
// console.log(`streaming notifications: ${url}`);
463
+
const encoder = WebSocket.getDefaultEncoder<undefined, Notification>();
464
+
const ws = new WebSocket<typeof encoder>(url.toString(), {
465
+
encoder
466
+
});
467
+
return ws;
468
+
};
469
+
470
+
const fetchMicrocosm = async <
471
+
Schema extends XRPCQueryMetadata,
472
+
Input extends Schema['params'] extends ObjectSchema ? InferOutput<Schema['params']> : undefined,
473
+
Output extends InferXRPCBodyOutput<Schema['output']>
474
+
>(
475
+
api: URL,
476
+
schema: Schema,
477
+
params: Input,
478
+
init?: RequestInit
479
+
): Promise<Result<Output, string>> => {
480
+
if (!schema.output || schema.output.type === 'blob') return err('schema must be blob');
481
+
api.pathname = `/xrpc/${schema.nsid}`;
482
+
api.search = params
483
+
? `?${new URLSearchParams(Object.entries(params).flatMap(([k, v]) => (v === undefined ? [] : [[k, String(v)]])))}`
484
+
: '';
485
+
try {
486
+
const body = await fetchJson(api, init);
487
+
if (!body.ok) return err(body.error);
488
+
const parsed = safeParse(schema.output.schema, body.value);
489
+
if (!parsed.ok) return err(parsed.message);
490
+
return ok(parsed.value as Output);
491
+
} catch (error) {
492
+
return err(`FetchError: ${error}`);
493
+
}
494
+
};
495
+
496
+
const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => {
497
+
try {
498
+
const response = await fetch(url, init);
499
+
const body = await response.json();
500
+
if (response.status === 400) return err(`${body.error}: ${body.message}`);
501
+
if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`);
502
+
return ok(body);
503
+
} catch (error) {
504
+
return err(`FetchError: ${error}`);
505
+
}
506
+
};
-331
src/lib/at/client.ts
-331
src/lib/at/client.ts
···
1
-
import { err, expect, map, ok, type Result } from '$lib/result';
2
-
import {
3
-
ComAtprotoIdentityResolveHandle,
4
-
ComAtprotoRepoGetRecord,
5
-
ComAtprotoRepoListRecords
6
-
} from '@atcute/atproto';
7
-
import { Client as AtcuteClient, simpleFetchHandler } from '@atcute/client';
8
-
import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons';
9
-
import {
10
-
isDid,
11
-
parseCanonicalResourceUri,
12
-
parseResourceUri,
13
-
type ActorIdentifier,
14
-
type AtprotoDid,
15
-
type Cid,
16
-
type Did,
17
-
type Nsid,
18
-
type RecordKey,
19
-
type ResourceUri
20
-
} from '@atcute/lexicons/syntax';
21
-
import type {
22
-
InferInput,
23
-
InferXRPCBodyOutput,
24
-
ObjectSchema,
25
-
RecordKeySchema,
26
-
RecordSchema,
27
-
XRPCQueryMetadata
28
-
} from '@atcute/lexicons/validations';
29
-
import * as v from '@atcute/lexicons/validations';
30
-
import { MiniDocQuery, type MiniDoc } from './slingshot';
31
-
import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';
32
-
import type { Records } from '@atcute/lexicons/ambient';
33
-
import { cache as rawCache } from '$lib/cache';
34
-
import { AppBskyActorProfile } from '@atcute/bluesky';
35
-
import { WebSocket } from '@soffinal/websocket';
36
-
import type { Notification } from './stardust';
37
-
import { get } from 'svelte/store';
38
-
import { settings } from '$lib/settings';
39
-
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
40
-
41
-
export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot);
42
-
export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust);
43
-
export const constellationUrl: URL = new URL(get(settings).endpoints.constellation);
44
-
45
-
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
46
-
47
-
const cacheWithHandles = rawCache.define(
48
-
'resolveHandle',
49
-
async (handle: Handle): Promise<AtprotoDid> => {
50
-
const res = await fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, {
51
-
handle
52
-
});
53
-
if (!res.ok) throw new Error(res.error);
54
-
return res.value.did as AtprotoDid;
55
-
}
56
-
);
57
-
58
-
const cacheWithDidDocs = cacheWithHandles.define(
59
-
'resolveDidDoc',
60
-
async (identifier: ActorIdentifier): Promise<MiniDoc> => {
61
-
const res = await fetchMicrocosm(slingshotUrl, MiniDocQuery, {
62
-
identifier
63
-
});
64
-
if (!res.ok) throw new Error(res.error);
65
-
return res.value;
66
-
}
67
-
);
68
-
69
-
const cacheWithRecords = cacheWithDidDocs.define('fetchRecord', async (uri: ResourceUri) => {
70
-
const parsedUri = parseResourceUri(uri);
71
-
if (!parsedUri.ok) throw new Error(`can't parse resource uri: ${parsedUri.error}`);
72
-
const { repo, collection, rkey } = parsedUri.value;
73
-
const res = await fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, {
74
-
repo,
75
-
collection: collection!,
76
-
rkey: rkey!
77
-
});
78
-
if (!res.ok) throw new Error(res.error);
79
-
return res.value;
80
-
});
81
-
82
-
const cache = cacheWithRecords;
83
-
84
-
export class AtpClient {
85
-
public atcute: AtcuteClient | null = null;
86
-
public user: { did: Did; handle: Handle } | null = null;
87
-
88
-
async login(agent: OAuthUserAgent): Promise<Result<null, string>> {
89
-
try {
90
-
const rpc = new AtcuteClient({ handler: agent });
91
-
const res = await rpc.get('com.atproto.server.getSession');
92
-
if (!res.ok) throw res.data.error;
93
-
this.user = {
94
-
did: res.data.did,
95
-
handle: res.data.handle
96
-
};
97
-
this.atcute = rpc;
98
-
} catch (error) {
99
-
return err(`failed to login: ${error}`);
100
-
}
101
-
102
-
return ok(null);
103
-
}
104
-
105
-
async getRecordUri<
106
-
Collection extends Nsid,
107
-
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
108
-
TKey extends RecordKeySchema,
109
-
Schema extends RecordSchema<TObject, TKey>,
110
-
Output extends InferInput<Schema>
111
-
>(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> {
112
-
const parsedUri = expect(parseResourceUri(uri));
113
-
if (parsedUri.collection !== schema.object.shape.$type.expected)
114
-
return err(
115
-
`collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}`
116
-
);
117
-
return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!);
118
-
}
119
-
120
-
async getRecord<
121
-
Collection extends Nsid,
122
-
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
123
-
TKey extends RecordKeySchema,
124
-
Schema extends RecordSchema<TObject, TKey>,
125
-
Output extends InferInput<Schema>
126
-
>(
127
-
schema: Schema,
128
-
repo: ActorIdentifier,
129
-
rkey: RecordKey
130
-
): Promise<Result<RecordOutput<Output>, string>> {
131
-
const collection = schema.object.shape.$type.expected;
132
-
133
-
try {
134
-
// Call the cached function
135
-
const rawValue = await cache.fetchRecord(`at://${repo}/${collection}/${rkey}`);
136
-
137
-
const parsed = safeParse(schema, rawValue.value);
138
-
if (!parsed.ok) return err(parsed.message);
139
-
140
-
return ok({
141
-
uri: rawValue.uri,
142
-
cid: rawValue.cid,
143
-
record: parsed.value as Output
144
-
});
145
-
} catch (e) {
146
-
return err(String(e));
147
-
}
148
-
}
149
-
150
-
async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> {
151
-
repo = repo ?? this.user?.did;
152
-
if (!repo) return err('not authenticated');
153
-
return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record);
154
-
}
155
-
156
-
async listRecords<Collection extends keyof Records>(
157
-
collection: Collection,
158
-
cursor?: string,
159
-
limit: number = 100
160
-
): Promise<
161
-
Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string>
162
-
> {
163
-
if (!this.atcute || !this.user) return err('not authenticated');
164
-
const res = await this.atcute.get('com.atproto.repo.listRecords', {
165
-
params: {
166
-
repo: this.user.did,
167
-
collection,
168
-
cursor,
169
-
limit
170
-
}
171
-
});
172
-
if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`);
173
-
174
-
for (const record of res.data.records) {
175
-
await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24);
176
-
}
177
-
return ok(res.data);
178
-
}
179
-
180
-
async listRecordsAll<Collection extends keyof Records>(
181
-
collection: Collection
182
-
): Promise<ReturnType<typeof this.listRecords>> {
183
-
const data: InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']> = {
184
-
records: []
185
-
};
186
-
187
-
let end = false;
188
-
while (!end) {
189
-
const res = await this.listRecords(collection, data.cursor);
190
-
if (!res.ok) return res;
191
-
data.cursor = res.value.cursor;
192
-
data.records.push(...res.value.records);
193
-
end = !res.value.cursor;
194
-
}
195
-
196
-
return ok(data);
197
-
}
198
-
199
-
async getBacklinksUri(
200
-
uri: ResourceUri,
201
-
source: BacklinksSource
202
-
): Promise<Result<Backlinks, string>> {
203
-
const parsedResourceUri = expect(parseCanonicalResourceUri(uri));
204
-
return await this.getBacklinks(
205
-
parsedResourceUri.repo,
206
-
parsedResourceUri.collection,
207
-
parsedResourceUri.rkey,
208
-
source
209
-
);
210
-
}
211
-
212
-
async getBacklinks(
213
-
repo: ActorIdentifier,
214
-
collection: Nsid,
215
-
rkey: RecordKey,
216
-
source: BacklinksSource,
217
-
limit?: number
218
-
): Promise<Result<Backlinks, string>> {
219
-
const did = await resolveHandle(repo);
220
-
if (!did.ok) return err(`cant resolve handle: ${did.error}`);
221
-
222
-
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000));
223
-
const query = fetchMicrocosm(constellationUrl, BacklinksQuery, {
224
-
subject: `at://${did.value}/${collection}/${rkey}`,
225
-
source,
226
-
limit: limit || 100
227
-
});
228
-
229
-
const results = await Promise.race([query, timeout]);
230
-
if (!results) return err('cant fetch backlinks: timeout');
231
-
232
-
return results;
233
-
}
234
-
}
235
-
236
-
export const newPublicClient = async (ident: ActorIdentifier): Promise<AtpClient> => {
237
-
const atp = new AtpClient();
238
-
const didDoc = await resolveDidDoc(ident);
239
-
if (!didDoc.ok) {
240
-
console.error('failed to resolve did doc', didDoc.error);
241
-
return atp;
242
-
}
243
-
atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) });
244
-
atp.user = { did: didDoc.value.did, handle: didDoc.value.handle };
245
-
return atp;
246
-
};
247
-
248
-
// Wrappers that use the cache
249
-
250
-
export const resolveHandle = async (
251
-
identifier: ActorIdentifier
252
-
): Promise<Result<AtprotoDid, string>> => {
253
-
if (isDid(identifier)) return ok(identifier as AtprotoDid);
254
-
255
-
try {
256
-
const did = await cache.resolveHandle(identifier);
257
-
return ok(did);
258
-
} catch (e) {
259
-
return err(String(e));
260
-
}
261
-
};
262
-
263
-
export const resolveDidDoc = async (ident: ActorIdentifier): Promise<Result<MiniDoc, string>> => {
264
-
try {
265
-
const doc = await cache.resolveDidDoc(ident);
266
-
return ok(doc);
267
-
} catch (e) {
268
-
return err(String(e));
269
-
}
270
-
};
271
-
272
-
type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>;
273
-
export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
274
-
export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>;
275
-
276
-
export const streamNotifications = (
277
-
subjects: Did[],
278
-
...sources: BacklinksSource[]
279
-
): NotificationsStream => {
280
-
const url = new URL(spacedustUrl);
281
-
url.protocol = 'wss:';
282
-
url.pathname = '/subscribe';
283
-
const searchParams = [];
284
-
sources.every((source) => searchParams.push(['wantedSources', source]));
285
-
subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject]));
286
-
subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`]));
287
-
searchParams.push(['instant', 'true']);
288
-
url.search = `?${new URLSearchParams(searchParams)}`;
289
-
// console.log(`streaming notifications: ${url}`);
290
-
const encoder = WebSocket.getDefaultEncoder<undefined, Notification>();
291
-
const ws = new WebSocket<typeof encoder>(url.toString(), {
292
-
encoder
293
-
});
294
-
return ws;
295
-
};
296
-
297
-
const fetchMicrocosm = async <
298
-
Schema extends XRPCQueryMetadata,
299
-
Input extends Schema['params'] extends ObjectSchema ? InferOutput<Schema['params']> : undefined,
300
-
Output extends InferXRPCBodyOutput<Schema['output']>
301
-
>(
302
-
api: URL,
303
-
schema: Schema,
304
-
params: Input,
305
-
init?: RequestInit
306
-
): Promise<Result<Output, string>> => {
307
-
if (!schema.output || schema.output.type === 'blob') return err('schema must be blob');
308
-
api.pathname = `/xrpc/${schema.nsid}`;
309
-
api.search = params ? `?${new URLSearchParams(params)}` : '';
310
-
try {
311
-
const body = await fetchJson(api, init);
312
-
if (!body.ok) return err(body.error);
313
-
const parsed = safeParse(schema.output.schema, body.value);
314
-
if (!parsed.ok) return err(parsed.message);
315
-
return ok(parsed.value as Output);
316
-
} catch (error) {
317
-
return err(`FetchError: ${error}`);
318
-
}
319
-
};
320
-
321
-
const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => {
322
-
try {
323
-
const response = await fetch(url, init);
324
-
const body = await response.json();
325
-
if (response.status === 400) return err(`${body.error}: ${body.message}`);
326
-
if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`);
327
-
return ok(body);
328
-
} catch (error) {
329
-
return err(`FetchError: ${error}`);
330
-
}
331
-
};
+1
-1
src/lib/at/constellation.ts
+1
-1
src/lib/at/constellation.ts
+82
-48
src/lib/at/fetch.ts
+82
-48
src/lib/at/fetch.ts
···
4
4
type Cid,
5
5
type ResourceUri
6
6
} from '@atcute/lexicons';
7
-
import { type AtpClient } from './client';
8
-
import { err, expect, ok, type Result } from '$lib/result';
7
+
import { type AtpClient } from './client.svelte';
8
+
import { err, expect, ok, type Ok, type Result } from '$lib/result';
9
9
import type { Backlinks } from './constellation';
10
10
import { AppBskyFeedPost } from '@atcute/bluesky';
11
-
import type { AtprotoDid } from '@atcute/lexicons/syntax';
11
+
import type { Did, RecordKey } from '@atcute/lexicons/syntax';
12
+
import { replySource, toCanonicalUri } from '$lib';
12
13
13
14
export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main };
14
15
export type PostWithBacklinks = PostWithUri & {
15
-
replies: Backlinks;
16
+
replies?: Backlinks;
16
17
};
17
-
export type PostsWithReplyBacklinks = PostWithBacklinks[];
18
18
19
-
const replySource = 'app.bsky.feed.post:reply.parent.uri';
20
-
21
-
export const fetchPostsWithBacklinks = async (
19
+
export const fetchPosts = async (
20
+
subject: Did,
22
21
client: AtpClient,
23
22
cursor?: string,
24
-
limit?: number
25
-
): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => {
26
-
const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit);
23
+
limit?: number,
24
+
withBacklinks: boolean = true
25
+
): Promise<Result<{ posts: PostWithBacklinks[]; cursor?: string }, string>> => {
26
+
const recordsList = await client.listRecords(subject, 'app.bsky.feed.post', cursor, limit);
27
27
if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`);
28
28
cursor = recordsList.value.cursor;
29
29
const records = recordsList.value.records;
30
+
31
+
if (!withBacklinks) {
32
+
return ok({
33
+
posts: records.map((r) => ({
34
+
uri: r.uri,
35
+
cid: r.cid,
36
+
record: r.value as AppBskyFeedPost.Main
37
+
})),
38
+
cursor
39
+
});
40
+
}
30
41
31
42
try {
32
43
const allBacklinks = await Promise.all(
33
44
records.map(async (r): Promise<PostWithBacklinks> => {
34
-
const replies = await client.getBacklinksUri(r.uri, replySource);
35
-
if (!replies.ok) throw `cant fetch replies: ${replies.error}`;
45
+
const result = await client.getBacklinks(r.uri, replySource);
46
+
if (!result.ok) throw `cant fetch replies: ${result.error}`;
47
+
const replies = result.value;
36
48
return {
37
49
uri: r.uri,
38
50
cid: r.cid,
39
51
record: r.value as AppBskyFeedPost.Main,
40
-
replies: replies.value
52
+
replies
41
53
};
42
54
})
43
55
);
···
47
59
}
48
60
};
49
61
62
+
export type HydrateOptions = {
63
+
downwards: 'sameAuthor' | 'none';
64
+
};
65
+
50
66
export const hydratePosts = async (
51
67
client: AtpClient,
52
-
repo: AtprotoDid,
53
-
data: PostsWithReplyBacklinks
68
+
repo: Did,
69
+
data: PostWithBacklinks[],
70
+
cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined,
71
+
options?: Partial<HydrateOptions>
54
72
): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
55
73
let posts: Map<ResourceUri, PostWithUri> = new Map();
56
74
try {
57
75
const allPosts = await Promise.all(
58
76
data.map(async (post) => {
59
77
const result: PostWithUri[] = [post];
60
-
const replies = await Promise.all(
61
-
post.replies.records.map(async (r) => {
62
-
const reply = await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey);
63
-
if (!reply.ok) throw `cant fetch reply: ${reply.error}`;
64
-
return reply.value;
65
-
})
66
-
);
67
-
result.push(...replies);
78
+
if (post.replies) {
79
+
const replies = await Promise.all(
80
+
post.replies.records.map(async (r) => {
81
+
const reply =
82
+
cacheFn(r.did, r.rkey) ??
83
+
(await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey));
84
+
if (!reply.ok) throw `cant fetch reply: ${reply.error}`;
85
+
return reply.value;
86
+
})
87
+
);
88
+
result.push(...replies);
89
+
}
68
90
return result;
69
91
})
70
92
);
···
76
98
const fetchUpwardsChain = async (post: PostWithUri) => {
77
99
let parent = post.record.reply?.parent;
78
100
while (parent) {
101
+
const parentUri = parent.uri as CanonicalResourceUri;
79
102
// if we already have this parent, then we already fetched this chain / are fetching it
80
-
if (posts.has(parent.uri as CanonicalResourceUri)) return;
81
-
const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri);
103
+
if (posts.has(parentUri)) return;
104
+
const parsedParentUri = expect(parseCanonicalResourceUri(parentUri));
105
+
const p =
106
+
cacheFn(parsedParentUri.repo, parsedParentUri.rkey) ??
107
+
(await client.getRecord(
108
+
AppBskyFeedPost.mainSchema,
109
+
parsedParentUri.repo,
110
+
parsedParentUri.rkey
111
+
));
82
112
if (p.ok) {
83
113
posts.set(p.value.uri, p.value);
84
114
parent = p.value.record.reply?.parent;
···
90
120
};
91
121
await Promise.all(posts.values().map(fetchUpwardsChain));
92
122
93
-
try {
94
-
const fetchDownwardsChain = async (post: PostWithUri) => {
95
-
const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri));
96
-
if (repo === postRepo) return;
123
+
if (options?.downwards !== 'none') {
124
+
try {
125
+
const fetchDownwardsChain = async (post: PostWithUri) => {
126
+
const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri));
127
+
if (repo === postRepo) return;
97
128
98
-
// get chains that are the same author until we exhaust them
99
-
const backlinks = await client.getBacklinksUri(post.uri, replySource);
100
-
if (!backlinks.ok) return;
129
+
// get chains that are the same author until we exhaust them
130
+
const backlinks = await client.getBacklinks(post.uri, replySource);
131
+
if (!backlinks.ok) return;
101
132
102
-
const promises = [];
103
-
for (const reply of backlinks.value.records) {
104
-
if (reply.did !== postRepo) continue;
105
-
// if we already have this reply, then we already fetched this chain / are fetching it
106
-
if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue;
107
-
const record = await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey);
108
-
if (!record.ok) break; // TODO: this doesnt handle deleted posts in between
109
-
posts.set(record.value.uri, record.value);
110
-
promises.push(fetchDownwardsChain(record.value));
111
-
}
133
+
const promises = [];
134
+
for (const reply of backlinks.value.records) {
135
+
if (reply.did !== postRepo) continue;
136
+
// if we already have this reply, then we already fetched this chain / are fetching it
137
+
if (posts.has(toCanonicalUri(reply))) continue;
138
+
const record =
139
+
cacheFn(reply.did, reply.rkey) ??
140
+
(await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey));
141
+
if (!record.ok) break; // TODO: this doesnt handle deleted posts in between
142
+
posts.set(record.value.uri, record.value);
143
+
promises.push(fetchDownwardsChain(record.value));
144
+
}
112
145
113
-
await Promise.all(promises);
114
-
};
115
-
await Promise.all(posts.values().map(fetchDownwardsChain));
116
-
} catch (error) {
117
-
return err(`cant fetch post reply chain: ${error}`);
146
+
await Promise.all(promises);
147
+
};
148
+
await Promise.all(posts.values().map(fetchDownwardsChain));
149
+
} catch (error) {
150
+
return err(`cant fetch post reply chain: ${error}`);
151
+
}
118
152
}
119
153
120
154
return ok(posts);
+9
src/lib/at/index.ts
+9
src/lib/at/index.ts
···
1
+
import { settings } from '$lib/settings';
2
+
import type { Did } from '@atcute/lexicons';
3
+
import { get } from 'svelte/store';
4
+
5
+
export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot);
6
+
export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust);
7
+
export const constellationUrl: URL = new URL(get(settings).endpoints.constellation);
8
+
9
+
export const httpToDidWeb = (url: string): Did => `did:web:${new URL(url).hostname}`;
+1
-1
src/lib/at/oauth.ts
+1
-1
src/lib/at/oauth.ts
···
14
14
WebDidDocumentResolver,
15
15
XrpcHandleResolver
16
16
} from '@atcute/identity-resolver';
17
-
import { slingshotUrl } from './client';
18
17
import type { ActorIdentifier } from '@atcute/lexicons';
19
18
import { err, ok, type Result } from '$lib/result';
20
19
import type { AtprotoDid } from '@atcute/lexicons/syntax';
21
20
import { clientId, oauthMetadata, redirectUri } from '$lib/oauth';
21
+
import { slingshotUrl } from '.';
22
22
23
23
configureOAuth({
24
24
metadata: {
+5
src/lib/at/types.ts
+5
src/lib/at/types.ts
+8
-12
src/lib/cache.ts
+8
-12
src/lib/cache.ts
···
29
29
private writeFlushScheduled = false;
30
30
31
31
constructor() {
32
-
if (typeof indexedDB === 'undefined') {
33
-
return;
34
-
}
32
+
if (typeof indexedDB === 'undefined') return;
35
33
36
34
this.dbPromise = new Promise((resolve, reject) => {
37
35
const request = indexedDB.open(DB_NAME, DB_VERSION);
···
45
43
46
44
request.onupgradeneeded = (event) => {
47
45
const db = (event.target as IDBOpenDBRequest).result;
48
-
if (!db.objectStoreNames.contains(STORE_NAME)) {
49
-
db.createObjectStore(STORE_NAME);
50
-
}
46
+
if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
51
47
};
52
48
});
53
49
}
···
165
161
batch.forEach((op) => {
166
162
try {
167
163
let request: IDBRequest;
168
-
if (op.type === 'put') {
169
-
request = store.put(op.value, op.key);
170
-
} else {
171
-
request = store.delete(op.key);
172
-
}
164
+
if (op.type === 'put') request = store.put(op.value, op.key);
165
+
else request = store.delete(op.key);
173
166
174
167
request.onsuccess = () => op.resolve();
175
168
request.onerror = () => op.reject(request.error);
···
208
201
}
209
202
210
203
// noops
204
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
211
205
async getTTL(key: string): Promise<void> {
212
206
return;
213
207
}
···
215
209
return;
216
210
}
217
211
}
212
+
213
+
export const ttl = 60 * 60 * 3; // 3 hours
218
214
219
215
export const cache = createCache({
220
216
storage: {
···
223
219
storage: new IDBStorage()
224
220
}
225
221
},
226
-
ttl: 60 * 60 * 24, // 24 hours
222
+
ttl,
227
223
onError: (err) => console.error(err)
228
224
});
+240
src/lib/following.ts
+240
src/lib/following.ts
···
1
+
import { type ActorIdentifier, type Did, type ResourceUri } from '@atcute/lexicons';
2
+
import type { PostWithUri } from './at/fetch';
3
+
import type { BacklinksSource } from './at/constellation';
4
+
import { extractDidFromUri, repostSource } from '$lib';
5
+
import type { AppBskyGraphFollow } from '@atcute/bluesky';
6
+
7
+
export type Sort = 'recent' | 'active' | 'conversational';
8
+
9
+
export const sortFollowedUser = (
10
+
sort: Sort,
11
+
statsA: NonNullable<ReturnType<typeof calculateFollowedUserStats>>,
12
+
statsB: NonNullable<ReturnType<typeof calculateFollowedUserStats>>
13
+
) => {
14
+
if (sort === 'conversational') {
15
+
if (Math.abs(statsB.conversationalScore! - statsA.conversationalScore!) > 0.1)
16
+
return statsB.conversationalScore! - statsA.conversationalScore!;
17
+
} else {
18
+
if (sort === 'active')
19
+
if (Math.abs(statsB.activeScore! - statsA.activeScore!) > 0.0001)
20
+
return statsB.activeScore! - statsA.activeScore!;
21
+
}
22
+
return statsB.lastPostAt!.getTime() - statsA.lastPostAt!.getTime();
23
+
};
24
+
25
+
const userStatsCache = new Map<
26
+
Did,
27
+
{ timestamp: number; stats: ReturnType<typeof _calculateStats> }
28
+
>();
29
+
const STATS_CACHE_TTL = 60 * 1000;
30
+
31
+
export const calculateFollowedUserStats = (
32
+
sort: Sort,
33
+
did: Did,
34
+
posts: Map<Did, Map<ResourceUri, PostWithUri>>,
35
+
interactionScores: Map<ActorIdentifier, number> | null,
36
+
now: number
37
+
) => {
38
+
if (sort === 'active') {
39
+
const cached = userStatsCache.get(did);
40
+
if (cached && now - cached.timestamp < STATS_CACHE_TTL) {
41
+
const postsMap = posts.get(did);
42
+
if (postsMap && postsMap.size > 0) return { ...cached.stats, did };
43
+
}
44
+
}
45
+
46
+
const stats = _calculateStats(sort, did, posts, interactionScores, now);
47
+
48
+
if (stats && sort === 'active') userStatsCache.set(did, { timestamp: now, stats });
49
+
50
+
return stats;
51
+
};
52
+
53
+
const _calculateStats = (
54
+
sort: Sort,
55
+
did: Did,
56
+
posts: Map<Did, Map<ResourceUri, PostWithUri>>,
57
+
interactionScores: Map<ActorIdentifier, number> | null,
58
+
now: number
59
+
) => {
60
+
const postsMap = posts.get(did);
61
+
if (!postsMap || postsMap.size === 0) return null;
62
+
63
+
let lastPostAtTime = 0;
64
+
let activeScore = 0;
65
+
let recentPostCount = 0;
66
+
const quarterPosts = 6 * 60 * 60 * 1000;
67
+
const gravity = 2.0;
68
+
69
+
for (const post of postsMap.values()) {
70
+
const t = new Date(post.record.createdAt).getTime();
71
+
if (t > lastPostAtTime) lastPostAtTime = t;
72
+
const ageMs = Math.max(0, now - t);
73
+
if (ageMs < quarterPosts) recentPostCount++;
74
+
if (sort === 'active') {
75
+
const ageHours = ageMs / (1000 * 60 * 60);
76
+
activeScore += 1 / Math.pow(ageHours + 1, gravity);
77
+
}
78
+
}
79
+
80
+
let conversationalScore = 0;
81
+
if (sort === 'conversational' && interactionScores)
82
+
conversationalScore = interactionScores.get(did) || 0;
83
+
84
+
return {
85
+
did,
86
+
lastPostAt: new Date(lastPostAtTime),
87
+
activeScore,
88
+
conversationalScore,
89
+
recentPostCount
90
+
};
91
+
};
92
+
93
+
const quoteWeight = 4;
94
+
const replyWeight = 6;
95
+
const repostWeight = 2;
96
+
97
+
const oneDay = 24 * 60 * 60 * 1000;
98
+
const halfLifeMs = 3 * oneDay;
99
+
const decayLambda = 0.693 / halfLifeMs;
100
+
101
+
const rateBaseline = 1;
102
+
const ratePower = 0.5;
103
+
const windowSize = 7 * oneDay;
104
+
105
+
const rateCache = new Map<Did, { rate: number; calculatedAt: number; postCount: number }>();
106
+
107
+
const getPostRate = (did: Did, posts: Map<ResourceUri, PostWithUri>, now: number): number => {
108
+
const cached = rateCache.get(did);
109
+
if (cached && cached.postCount === posts.size && now - cached.calculatedAt < 5 * 60 * 1000)
110
+
return cached.rate;
111
+
112
+
let volume = 0;
113
+
let minTime = now;
114
+
let maxTime = 0;
115
+
let hasRecentPosts = false;
116
+
117
+
for (const [, post] of posts) {
118
+
const t = new Date(post.record.createdAt).getTime();
119
+
if (now - t < windowSize) {
120
+
volume += 1;
121
+
if (t < minTime) minTime = t;
122
+
if (t > maxTime) maxTime = t;
123
+
hasRecentPosts = true;
124
+
}
125
+
}
126
+
127
+
let rate = 0;
128
+
if (hasRecentPosts) {
129
+
const days = Math.max((maxTime - minTime) / oneDay, 1);
130
+
rate = volume / days;
131
+
}
132
+
133
+
rateCache.set(did, { rate, calculatedAt: now, postCount: posts.size });
134
+
return rate;
135
+
};
136
+
137
+
export const calculateInteractionScores = (
138
+
user: Did,
139
+
followsMap: Map<ResourceUri, AppBskyGraphFollow.Main>,
140
+
allPosts: Map<Did, Map<ResourceUri, PostWithUri>>,
141
+
allBacklinks: Map<BacklinksSource, Map<ResourceUri, Map<Did, Set<string>>>>,
142
+
replyIndex: Map<Did, Set<ResourceUri>>,
143
+
now: number
144
+
) => {
145
+
const scores = new Map<Did, number>();
146
+
147
+
const decay = (time: number) => {
148
+
const age = Math.max(0, now - time);
149
+
return Math.exp(-decayLambda * age);
150
+
};
151
+
152
+
const addScore = (did: Did, weight: number, time: number) => {
153
+
const current = scores.get(did) ?? 0;
154
+
scores.set(did, current + weight * decay(time));
155
+
};
156
+
157
+
// 1. process my posts (me -> others)
158
+
const myPosts = allPosts.get(user);
159
+
if (myPosts) {
160
+
const seenRoots = new Set<ResourceUri>();
161
+
for (const post of myPosts.values()) {
162
+
const t = new Date(post.record.createdAt).getTime();
163
+
164
+
if (post.record.reply) {
165
+
const parentUri = post.record.reply.parent.uri;
166
+
const rootUri = post.record.reply.root.uri;
167
+
168
+
const targetDid = extractDidFromUri(parentUri);
169
+
if (targetDid && targetDid !== user) addScore(targetDid, replyWeight, t);
170
+
171
+
if (parentUri !== rootUri && !seenRoots.has(rootUri)) {
172
+
const rootDid = extractDidFromUri(rootUri);
173
+
if (rootDid && rootDid !== user) addScore(rootDid, replyWeight, t);
174
+
seenRoots.add(rootUri);
175
+
}
176
+
}
177
+
178
+
if (post.record.embed?.$type === 'app.bsky.embed.record') {
179
+
const targetDid = extractDidFromUri(post.record.embed.record.uri);
180
+
if (targetDid && targetDid !== user) addScore(targetDid, quoteWeight, t);
181
+
}
182
+
}
183
+
}
184
+
185
+
// 2. process others -> me (using reply index)
186
+
const repliesToMe = replyIndex.get(user);
187
+
if (repliesToMe) {
188
+
for (const uri of repliesToMe) {
189
+
const authorDid = extractDidFromUri(uri);
190
+
if (!authorDid || authorDid === user) continue;
191
+
192
+
const postsMap = allPosts.get(authorDid);
193
+
const post = postsMap?.get(uri);
194
+
if (!post) continue;
195
+
196
+
const t = new Date(post.record.createdAt).getTime();
197
+
addScore(authorDid, replyWeight, t);
198
+
}
199
+
}
200
+
201
+
// 3. process reposts on my posts
202
+
const repostBacklinks = allBacklinks.get(repostSource);
203
+
if (repostBacklinks && myPosts) {
204
+
for (const [uri, myPost] of myPosts) {
205
+
const didMap = repostBacklinks.get(uri);
206
+
if (!didMap) continue;
207
+
208
+
const t = new Date(myPost.record.createdAt).getTime();
209
+
const adds = new Map<Did, { score: number; repostCount: number }>();
210
+
211
+
for (const [did, rkeys] of didMap) {
212
+
if (did === user) continue;
213
+
214
+
let add = adds.get(did) ?? { score: 0, repostCount: 0 };
215
+
const diminishFactor = 9;
216
+
217
+
// each rkey is a separate repost record, apply diminishing returns
218
+
for (let i = 0; i < rkeys.size; i++) {
219
+
const weight = repostWeight * (diminishFactor / (add.repostCount + diminishFactor));
220
+
add = {
221
+
score: add.score + weight,
222
+
repostCount: add.repostCount + 1
223
+
};
224
+
}
225
+
adds.set(did, add);
226
+
}
227
+
228
+
for (const [did, add] of adds.entries()) addScore(did, add.score, t);
229
+
}
230
+
}
231
+
232
+
// normalize by posting rate
233
+
for (const [did, score] of scores) {
234
+
const posts = allPosts.get(did);
235
+
const rate = posts ? getPostRate(did, posts, now) : 0;
236
+
scores.set(did, score / Math.pow(rate + rateBaseline, ratePower));
237
+
}
238
+
239
+
return scores;
240
+
};
+41
src/lib/index.ts
+41
src/lib/index.ts
···
1
+
import type {
2
+
CanonicalResourceUri,
3
+
Did,
4
+
ParsedCanonicalResourceUri,
5
+
ParsedResourceUri,
6
+
ResourceUri
7
+
} from '@atcute/lexicons';
8
+
import type { Backlink, BacklinksSource } from './at/constellation';
9
+
import { parse as parseTid } from '@atcute/tid';
10
+
11
+
export const toResourceUri = (parsed: ParsedResourceUri): ResourceUri => {
12
+
return `at://${parsed.repo}${parsed.collection ? `/${parsed.collection}${parsed.rkey ? `/${parsed.rkey}` : ''}` : ''}`;
13
+
};
14
+
export const toCanonicalUri = (
15
+
parsed: ParsedCanonicalResourceUri | Backlink
16
+
): CanonicalResourceUri => {
17
+
if ('did' in parsed) return `at://${parsed.did}/${parsed.collection}/${parsed.rkey}`;
18
+
return `at://${parsed.repo}/${parsed.collection}/${parsed.rkey}${parsed.fragment ? `#${parsed.fragment}` : ''}`;
19
+
};
20
+
21
+
export const extractDidFromUri = (uri: string): Did | null => {
22
+
if (!uri.startsWith('at://')) return null;
23
+
const idx = uri.indexOf('/', 5);
24
+
if (idx === -1) return uri.slice(5) as Did;
25
+
return uri.slice(5, idx) as Did;
26
+
};
27
+
28
+
export const likeSource: BacklinksSource = 'app.bsky.feed.like:subject.uri';
29
+
export const repostSource: BacklinksSource = 'app.bsky.feed.repost:subject.uri';
30
+
export const replySource: BacklinksSource = 'app.bsky.feed.post:reply.parent.uri';
31
+
export const replyRootSource: BacklinksSource = 'app.bsky.feed.post:reply.root.uri';
32
+
export const blockSource: BacklinksSource = 'app.bsky.graph.block:subject';
33
+
34
+
export const timestampFromCursor = (cursor: string | undefined) => {
35
+
if (!cursor) return undefined;
36
+
try {
37
+
return parseTid(cursor).timestamp;
38
+
} catch {
39
+
return undefined;
40
+
}
41
+
};
+2
-1
src/lib/oauth.ts
+2
-1
src/lib/oauth.ts
···
7
7
client_uri: domain,
8
8
logo_uri: `${domain}/favicon.png`,
9
9
redirect_uris: [`${domain}/`],
10
-
scope: 'atproto repo:*?action=create&action=update&action=delete blob:*/*',
10
+
scope:
11
+
'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* blob:*/*',
11
12
grant_types: ['authorization_code', 'refresh_token'],
12
13
response_types: ['code'],
13
14
token_endpoint_auth_method: 'none',
+15
-11
src/lib/result.ts
+15
-11
src/lib/result.ts
···
1
-
export type Result<T, E> =
2
-
| {
3
-
ok: true;
4
-
value: T;
5
-
}
6
-
| {
7
-
ok: false;
8
-
error: E;
9
-
};
1
+
export type Ok<T> = {
2
+
ok: true;
3
+
value: T;
4
+
};
5
+
export type Err<E> = {
6
+
ok: false;
7
+
error: E;
8
+
};
9
+
export type Result<T, E> = Ok<T> | Err<E>;
10
10
11
-
export const ok = <T, E>(value: T): Result<T, E> => {
11
+
export const ok = <T>(value: T): Ok<T> => {
12
12
return { ok: true, value };
13
13
};
14
-
export const err = <T, E>(error: E): Result<T, E> => {
14
+
export const err = <E>(error: E): Err<E> => {
15
+
// console.error(error);
15
16
return { ok: false, error };
16
17
};
17
18
export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
···
26
27
}
27
28
return err(v.error);
28
29
};
30
+
31
+
export type OkType<R> = R extends { ok: true; value: infer T } ? T : never;
32
+
export type ErrType<R> = R extends { ok: false; error: infer E } ? E : never;
+1
-1
src/lib/richtext/index.ts
+1
-1
src/lib/richtext/index.ts
···
1
1
import RichtextBuilder, { type BakedRichtext } from '@atcute/bluesky-richtext-builder';
2
2
import { tokenize, type Token } from '$lib/richtext/parser';
3
3
import type { Did, GenericUri, Handle } from '@atcute/lexicons';
4
-
import { resolveHandle } from '$lib/at/client';
4
+
import { resolveHandle } from '$lib/at/client.svelte';
5
5
6
6
export const parseToRichText = (text: string): ReturnType<typeof processTokens> =>
7
7
processTokens(tokenize(text));
+156
src/lib/router.svelte.ts
+156
src/lib/router.svelte.ts
···
1
+
/* eslint-disable svelte/no-navigation-without-resolve */
2
+
import { pushState, replaceState } from '$app/navigation';
3
+
import { SvelteMap } from 'svelte/reactivity';
4
+
5
+
export const routes = [
6
+
{ path: '/', order: 0 },
7
+
{ path: '/following', order: 1 },
8
+
{ path: '/notifications', order: 2 },
9
+
{ path: '/settings/:tab', order: 3 },
10
+
{ path: '/profile/:actor', order: 4 }
11
+
] as const;
12
+
13
+
export type RouteConfig = (typeof routes)[number];
14
+
export type RoutePath = RouteConfig['path'];
15
+
16
+
type ExtractParams<Path extends string> =
17
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
18
+
Path extends `${infer Start}/:${infer Param}/${infer Rest}`
19
+
? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string }
20
+
: // eslint-disable-next-line @typescript-eslint/no-unused-vars
21
+
Path extends `${infer Start}/:${infer Param}`
22
+
? { [K in Param]: string }
23
+
: Record<string, never>;
24
+
25
+
export type Route<K extends RoutePath = RoutePath> = {
26
+
[T in K]: {
27
+
params: ExtractParams<T>;
28
+
path: T;
29
+
order: number;
30
+
url: string;
31
+
};
32
+
}[K];
33
+
34
+
type RouteNode = {
35
+
children: Map<string, RouteNode>;
36
+
paramName?: string;
37
+
paramChild?: RouteNode;
38
+
config?: RouteConfig;
39
+
};
40
+
41
+
const fallbackRoute: Route<'/'> = {
42
+
params: {},
43
+
path: '/',
44
+
order: 0,
45
+
url: '/'
46
+
};
47
+
48
+
export class Router {
49
+
current = $state<Route>(fallbackRoute);
50
+
51
+
direction = $state<'left' | 'right' | 'none'>('none');
52
+
scrollPositions = new SvelteMap<string, number>();
53
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
54
+
private root: RouteNode = { children: new Map() };
55
+
56
+
constructor() {
57
+
for (const route of routes) this.addRoute(route);
58
+
}
59
+
60
+
private addRoute(config: RouteConfig) {
61
+
const segments = config.path.split('/').filter(Boolean);
62
+
let node = this.root;
63
+
64
+
for (const segment of segments) {
65
+
if (segment.startsWith(':')) {
66
+
const paramName = segment.slice(1);
67
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
68
+
if (!node.paramChild) node.paramChild = { children: new Map(), paramName };
69
+
node = node.paramChild;
70
+
} else {
71
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
72
+
if (!node.children.has(segment)) node.children.set(segment, { children: new Map() });
73
+
node = node.children.get(segment)!;
74
+
}
75
+
}
76
+
node.config = config;
77
+
}
78
+
79
+
init() {
80
+
if (typeof window === 'undefined') return;
81
+
// initialize state
82
+
this._updateState(window.location.pathname);
83
+
// update state on browser navigation
84
+
window.addEventListener('popstate', () => this._updateState(window.location.pathname));
85
+
}
86
+
87
+
match(urlPath: string): Route | undefined {
88
+
const segments = urlPath.split('/').filter(Boolean);
89
+
const params: Record<string, string> = {};
90
+
91
+
let node = this.root;
92
+
93
+
for (const segment of segments) {
94
+
if (node.children.has(segment)) {
95
+
node = node.children.get(segment)!;
96
+
} else if (node.paramChild) {
97
+
node = node.paramChild;
98
+
if (node.paramName) params[node.paramName] = decodeURIComponent(segment);
99
+
} else {
100
+
return undefined;
101
+
}
102
+
}
103
+
104
+
if (node.config)
105
+
return {
106
+
params: params as unknown,
107
+
path: node.config.path,
108
+
order: node.config.order,
109
+
url: urlPath
110
+
} as Route<typeof node.config.path>;
111
+
112
+
return undefined;
113
+
}
114
+
115
+
updateDirection(newOrder: number, oldOrder: number) {
116
+
if (newOrder === oldOrder) this.direction = 'none';
117
+
else if (newOrder > oldOrder) this.direction = 'right';
118
+
else this.direction = 'left';
119
+
}
120
+
121
+
private _updateState(url: string) {
122
+
const target = this.match(url);
123
+
if (!target) return;
124
+
125
+
// save scroll position
126
+
if (typeof window !== 'undefined') this.scrollPositions.set(this.current.url, window.scrollY);
127
+
128
+
this.updateDirection(target.order, this.current.order);
129
+
this.current = target;
130
+
131
+
if (typeof window !== 'undefined') {
132
+
setTimeout(() => {
133
+
const savedScroll = this.scrollPositions.get(target.url) ?? 0;
134
+
window.scrollTo({ top: savedScroll, behavior: 'auto' });
135
+
}, 0);
136
+
}
137
+
}
138
+
139
+
navigate(url: string, { replace = false } = {}) {
140
+
if (typeof window === 'undefined') return;
141
+
if (this.current.url === url) return;
142
+
143
+
if (replace) replaceState(url, {});
144
+
else pushState(url, {});
145
+
146
+
this._updateState(url);
147
+
}
148
+
149
+
replace(url: string) {
150
+
this.navigate(url, { replace: true });
151
+
}
152
+
153
+
back() {
154
+
if (typeof window !== 'undefined') history.back();
155
+
}
156
+
}
+591
-115
src/lib/state.svelte.ts
+591
-115
src/lib/state.svelte.ts
···
1
1
import { writable } from 'svelte/store';
2
2
import {
3
3
AtpClient,
4
-
newPublicClient,
4
+
setRecordCache,
5
5
type NotificationsStream,
6
6
type NotificationsStreamEvent
7
-
} from './at/client';
8
-
import { SvelteMap, SvelteDate } from 'svelte/reactivity';
9
-
import type { Did, InferOutput, ResourceUri } from '@atcute/lexicons';
10
-
import type { Backlink } from './at/constellation';
11
-
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch';
7
+
} from './at/client.svelte';
8
+
import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity';
9
+
import type { Did, Handle, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons';
10
+
import { fetchPosts, hydratePosts, type HydrateOptions, type PostWithUri } from './at/fetch';
12
11
import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax';
13
-
import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky';
14
-
import type { ComAtprotoRepoListRecords } from '@atcute/atproto';
12
+
import {
13
+
AppBskyActorProfile,
14
+
AppBskyFeedPost,
15
+
AppBskyGraphBlock,
16
+
type AppBskyGraphFollow
17
+
} from '@atcute/bluesky';
15
18
import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream';
16
-
import { expect } from './result';
19
+
import { expect, ok } from './result';
20
+
import type { Backlink, BacklinksSource } from './at/constellation';
21
+
import { now as tidNow } from '@atcute/tid';
22
+
import type { Records } from '@atcute/lexicons/ambient';
23
+
import {
24
+
blockSource,
25
+
extractDidFromUri,
26
+
likeSource,
27
+
replyRootSource,
28
+
replySource,
29
+
repostSource,
30
+
timestampFromCursor,
31
+
toCanonicalUri
32
+
} from '$lib';
33
+
import { Router } from './router.svelte';
34
+
import type { Account } from './accounts';
17
35
18
36
export const notificationStream = writable<NotificationsStream | null>(null);
19
37
export const jetstream = writable<JetstreamSubscription | null>(null);
20
38
21
-
export type PostActions = {
22
-
like: Backlink | null;
23
-
repost: Backlink | null;
24
-
// reply: Backlink | null;
25
-
// quote: Backlink | null;
39
+
export const profiles = new SvelteMap<Did, AppBskyActorProfile.Main>();
40
+
export const handles = new SvelteMap<Did, Handle>();
41
+
42
+
// source -> subject -> did (who did the interaction) -> rkey
43
+
export type BacklinksMap = SvelteMap<
44
+
BacklinksSource,
45
+
SvelteMap<ResourceUri, SvelteMap<Did, SvelteSet<RecordKey>>>
46
+
>;
47
+
export const allBacklinks: BacklinksMap = new SvelteMap();
48
+
49
+
export const addBacklinks = (
50
+
subject: ResourceUri,
51
+
source: BacklinksSource,
52
+
links: Iterable<Backlink>
53
+
) => {
54
+
let subjectMap = allBacklinks.get(source);
55
+
if (!subjectMap) {
56
+
subjectMap = new SvelteMap();
57
+
allBacklinks.set(source, subjectMap);
58
+
}
59
+
60
+
let didMap = subjectMap.get(subject);
61
+
if (!didMap) {
62
+
didMap = new SvelteMap();
63
+
subjectMap.set(subject, didMap);
64
+
}
65
+
66
+
for (const link of links) {
67
+
let rkeys = didMap.get(link.did);
68
+
if (!rkeys) {
69
+
rkeys = new SvelteSet();
70
+
didMap.set(link.did, rkeys);
71
+
}
72
+
rkeys.add(link.rkey);
73
+
}
74
+
};
75
+
76
+
export const removeBacklinks = (
77
+
subject: ResourceUri,
78
+
source: BacklinksSource,
79
+
links: Iterable<Backlink>
80
+
) => {
81
+
const didMap = allBacklinks.get(source)?.get(subject);
82
+
if (!didMap) return;
83
+
84
+
for (const link of links) {
85
+
const rkeys = didMap.get(link.did);
86
+
if (!rkeys) continue;
87
+
rkeys.delete(link.rkey);
88
+
if (rkeys.size === 0) didMap.delete(link.did);
89
+
}
90
+
};
91
+
92
+
export const findBacklinksBy = (subject: ResourceUri, source: BacklinksSource, did: Did) => {
93
+
const rkeys = allBacklinks.get(source)?.get(subject)?.get(did) ?? [];
94
+
// reconstruct the collection from the source
95
+
const collection = source.split(':')[0] as Nsid;
96
+
return rkeys.values().map((rkey) => ({ did, collection, rkey }));
97
+
};
98
+
99
+
export const hasBacklink = (subject: ResourceUri, source: BacklinksSource, did: Did): boolean => {
100
+
return allBacklinks.get(source)?.get(subject)?.has(did) ?? false;
101
+
};
102
+
103
+
export const getAllBacklinksFor = (subject: ResourceUri, source: BacklinksSource): Backlink[] => {
104
+
const subjectMap = allBacklinks.get(source);
105
+
if (!subjectMap) return [];
106
+
107
+
const didMap = subjectMap.get(subject);
108
+
if (!didMap) return [];
109
+
110
+
const collection = source.split(':')[0] as Nsid;
111
+
const result: Backlink[] = [];
112
+
113
+
for (const [did, rkeys] of didMap)
114
+
for (const rkey of rkeys) result.push({ did, collection, rkey });
115
+
116
+
return result;
117
+
};
118
+
119
+
export const isBlockedBy = (subject: Did, blocker: Did): boolean => {
120
+
return hasBacklink(`at://${subject}`, 'app.bsky.graph.block:subject', blocker);
121
+
};
122
+
123
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
124
+
const getNestedValue = (obj: any, path: string[]): any => {
125
+
return path.reduce((current, key) => current?.[key], obj);
126
+
};
127
+
128
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
129
+
const setNestedValue = (obj: any, path: string[], value: any): void => {
130
+
const lastKey = path[path.length - 1];
131
+
const parent = path.slice(0, -1).reduce((current, key) => {
132
+
if (current[key] === undefined) current[key] = {};
133
+
return current[key];
134
+
}, obj);
135
+
parent[lastKey] = value;
136
+
};
137
+
138
+
export const backlinksCursors = new SvelteMap<
139
+
Did,
140
+
SvelteMap<BacklinksSource, string | undefined>
141
+
>();
142
+
143
+
export const fetchLinksUntil = async (
144
+
subject: Did,
145
+
client: AtpClient,
146
+
backlinkSource: BacklinksSource,
147
+
timestamp: number = -1
148
+
) => {
149
+
let cursorMap = backlinksCursors.get(subject);
150
+
if (!cursorMap) {
151
+
cursorMap = new SvelteMap<BacklinksSource, string | undefined>();
152
+
backlinksCursors.set(subject, cursorMap);
153
+
}
154
+
155
+
const [_collection, source] = backlinkSource.split(':');
156
+
const collection = _collection as keyof Records;
157
+
const cursor = cursorMap.get(backlinkSource);
158
+
159
+
// if already fetched we dont need to fetch again
160
+
const cursorTimestamp = timestampFromCursor(cursor);
161
+
if (cursorTimestamp && cursorTimestamp <= timestamp) return;
162
+
163
+
console.log(`${subject}: fetchLinksUntil`, backlinkSource, cursor, timestamp);
164
+
const result = await client.listRecordsUntil(subject, collection, cursor, timestamp);
165
+
166
+
if (!result.ok) {
167
+
console.error('failed to fetch links until', result.error);
168
+
return;
169
+
}
170
+
cursorMap.set(backlinkSource, result.value.cursor);
171
+
172
+
const path = source.split('.');
173
+
for (const record of result.value.records) {
174
+
const uri = getNestedValue(record.value, path);
175
+
const parsedUri = parseCanonicalResourceUri(record.uri);
176
+
if (!parsedUri.ok) continue;
177
+
addBacklinks(uri, `${collection}:${source}`, [
178
+
{
179
+
did: parsedUri.value.repo,
180
+
collection: parsedUri.value.collection,
181
+
rkey: parsedUri.value.rkey
182
+
}
183
+
]);
184
+
}
26
185
};
27
-
export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
186
+
187
+
export const deletePostBacklink = async (
188
+
client: AtpClient,
189
+
post: PostWithUri,
190
+
source: BacklinksSource
191
+
) => {
192
+
const did = client.user?.did;
193
+
if (!did) return;
194
+
const collection = source.split(':')[0] as Nsid;
195
+
const links = findBacklinksBy(post.uri, source, did);
196
+
removeBacklinks(post.uri, source, links);
197
+
await Promise.allSettled(
198
+
links.map((link) =>
199
+
client.user?.atcute.post('com.atproto.repo.deleteRecord', {
200
+
input: { repo: did, collection, rkey: link.rkey! }
201
+
})
202
+
)
203
+
);
204
+
};
205
+
206
+
export const createPostBacklink = async (
207
+
client: AtpClient,
208
+
post: PostWithUri,
209
+
source: BacklinksSource
210
+
) => {
211
+
const did = client.user?.did;
212
+
if (!did) return;
213
+
const [_collection, subject] = source.split(':');
214
+
const collection = _collection as Nsid;
215
+
const rkey = tidNow();
216
+
addBacklinks(post.uri, source, [
217
+
{
218
+
did,
219
+
collection,
220
+
rkey
221
+
}
222
+
]);
223
+
const record = {
224
+
$type: collection,
225
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
226
+
createdAt: new Date().toISOString()
227
+
};
228
+
const subjectPath = subject.split('.');
229
+
setNestedValue(record, subjectPath, post.uri);
230
+
setNestedValue(record, [...subjectPath.slice(0, -1), 'cid'], post.cid);
231
+
await client.user?.atcute.post('com.atproto.repo.createRecord', {
232
+
input: {
233
+
repo: did,
234
+
collection,
235
+
rkey,
236
+
record
237
+
}
238
+
});
239
+
};
28
240
29
241
export const pulsingPostId = writable<string | null>(null);
30
242
31
243
export const viewClient = new AtpClient();
32
-
export const clients = new SvelteMap<AtprotoDid, AtpClient>();
33
-
export const getClient = async (did: AtprotoDid): Promise<AtpClient> => {
34
-
if (!clients.has(did)) clients.set(did, await newPublicClient(did));
35
-
return clients.get(did)!;
36
-
};
244
+
export const clients = new SvelteMap<Did, AtpClient>();
37
245
38
246
export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>();
39
247
···
41
249
did: Did,
42
250
followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]>
43
251
) => {
44
-
if (!follows.has(did)) {
45
-
follows.set(did, new SvelteMap(followMap));
252
+
let map = follows.get(did)!;
253
+
if (!map) {
254
+
map = new SvelteMap(followMap);
255
+
follows.set(did, map);
46
256
return;
47
257
}
48
-
const map = follows.get(did)!;
49
258
for (const [uri, record] of followMap) map.set(uri, record);
50
259
};
51
260
52
-
export const fetchFollows = async (did: AtprotoDid) => {
53
-
const client = await getClient(did);
54
-
const res = await client.listRecordsAll('app.bsky.graph.follow');
55
-
if (!res.ok) return;
261
+
export const fetchFollows = async (
262
+
account: Account
263
+
): Promise<IteratorObject<AppBskyGraphFollow.Main>> => {
264
+
const client = clients.get(account.did)!;
265
+
const res = await client.listRecordsUntil(account.did, 'app.bsky.graph.follow');
266
+
if (!res.ok) {
267
+
console.error("can't fetch follows:", res.error);
268
+
return [].values();
269
+
}
56
270
addFollows(
57
-
did,
271
+
account.did,
58
272
res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main])
59
273
);
274
+
return res.value.records.values().map((follow) => follow.value as AppBskyGraphFollow.Main);
60
275
};
61
276
62
-
export const fetchFollowPosts = async (did: AtprotoDid) => {
63
-
const client = await getClient(did);
64
-
const res = await client.listRecords('app.bsky.feed.post');
277
+
// this fetches up to three days of posts and interactions for using in following list
278
+
export const fetchForInteractions = async (client: AtpClient, subject: Did) => {
279
+
const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000;
280
+
281
+
const res = await client.listRecordsUntil(subject, 'app.bsky.feed.post', undefined, threeDaysAgo);
65
282
if (!res.ok) return;
66
-
addPostsRaw(did, res.value);
283
+
const postsWithUri = res.value.records.map(
284
+
(post) =>
285
+
({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri
286
+
);
287
+
addPosts(postsWithUri);
288
+
289
+
const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1;
290
+
const timestamp = Math.min(cursorTimestamp, threeDaysAgo);
291
+
console.log(`${subject}: fetchForInteractions`, res.value.cursor, timestamp);
292
+
await Promise.all([repostSource].map((s) => fetchLinksUntil(subject, client, s, timestamp)));
67
293
};
68
294
69
-
export const posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
70
-
export const cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
295
+
// if did is in set, we have fetched blocks for them already (against logged in users)
296
+
export const blockFlags = new SvelteMap<Did, SvelteSet<Did>>();
71
297
72
-
export const addPostsRaw = (
73
-
did: Did,
74
-
_posts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']>
75
-
) => {
76
-
const postsWithUri = new SvelteMap(
77
-
_posts.records.map((post) => [
78
-
post.uri,
79
-
{ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri
80
-
])
298
+
export const fetchBlocked = async (client: AtpClient, subject: Did, blocker: Did) => {
299
+
const subjectUri = `at://${subject}` as ResourceUri;
300
+
const res = await client.getBacklinks(subjectUri, blockSource, [blocker], 1);
301
+
if (!res.ok) return false;
302
+
if (res.value.total > 0) addBacklinks(subjectUri, blockSource, res.value.records);
303
+
304
+
// mark as fetched
305
+
let flags = blockFlags.get(subject);
306
+
if (!flags) {
307
+
flags = new SvelteSet();
308
+
blockFlags.set(subject, flags);
309
+
}
310
+
flags.add(blocker);
311
+
312
+
return res.value.total > 0;
313
+
};
314
+
315
+
export const fetchBlocks = async (account: Account) => {
316
+
const client = clients.get(account.did)!;
317
+
const res = await client.listRecordsUntil(account.did, 'app.bsky.graph.block');
318
+
if (!res.ok) return;
319
+
for (const block of res.value.records) {
320
+
const record = block.value as AppBskyGraphBlock.Main;
321
+
const parsedUri = expect(parseCanonicalResourceUri(block.uri));
322
+
addBacklinks(`at://${record.subject}`, blockSource, [
323
+
{
324
+
did: parsedUri.repo,
325
+
collection: parsedUri.collection,
326
+
rkey: parsedUri.rkey
327
+
}
328
+
]);
329
+
}
330
+
};
331
+
332
+
export const createBlock = async (client: AtpClient, targetDid: Did) => {
333
+
const userDid = client.user?.did;
334
+
if (!userDid) return;
335
+
336
+
const rkey = tidNow();
337
+
const targetUri = `at://${targetDid}` as ResourceUri;
338
+
339
+
addBacklinks(targetUri, blockSource, [
340
+
{
341
+
did: userDid,
342
+
collection: 'app.bsky.graph.block',
343
+
rkey
344
+
}
345
+
]);
346
+
347
+
const record: AppBskyGraphBlock.Main = {
348
+
$type: 'app.bsky.graph.block',
349
+
subject: targetDid,
350
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
351
+
createdAt: new Date().toISOString()
352
+
};
353
+
354
+
await client.user?.atcute.post('com.atproto.repo.createRecord', {
355
+
input: {
356
+
repo: userDid,
357
+
collection: 'app.bsky.graph.block',
358
+
rkey,
359
+
record
360
+
}
361
+
});
362
+
};
363
+
364
+
export const deleteBlock = async (client: AtpClient, targetDid: Did) => {
365
+
const userDid = client.user?.did;
366
+
if (!userDid) return;
367
+
368
+
const targetUri = `at://${targetDid}` as ResourceUri;
369
+
const links = findBacklinksBy(targetUri, blockSource, userDid);
370
+
371
+
removeBacklinks(targetUri, blockSource, links);
372
+
373
+
await Promise.allSettled(
374
+
links.map((link) =>
375
+
client.user?.atcute.post('com.atproto.repo.deleteRecord', {
376
+
input: {
377
+
repo: userDid,
378
+
collection: 'app.bsky.graph.block',
379
+
rkey: link.rkey
380
+
}
381
+
})
382
+
)
81
383
);
82
-
addPosts(did, postsWithUri);
83
-
cursors.set(did, { value: _posts.cursor, end: _posts.cursor === undefined });
384
+
};
385
+
386
+
export const isBlockedByUser = (targetDid: Did, userDid: Did): boolean => {
387
+
return isBlockedBy(targetDid, userDid);
388
+
};
389
+
390
+
export const isUserBlockedBy = (userDid: Did, targetDid: Did): boolean => {
391
+
return isBlockedBy(userDid, targetDid);
392
+
};
393
+
394
+
export const hasBlockRelationship = (did1: Did, did2: Did): boolean => {
395
+
return isBlockedBy(did1, did2) || isBlockedBy(did2, did1);
84
396
};
85
397
86
-
export const addPosts = (did: Did, _posts: Iterable<[ResourceUri, PostWithUri]>) => {
87
-
if (!posts.has(did)) {
88
-
posts.set(did, new SvelteMap(_posts));
89
-
return;
398
+
export const getBlockRelationship = (
399
+
userDid: Did,
400
+
targetDid: Did
401
+
): { userBlocked: boolean; blockedByTarget: boolean } => {
402
+
return {
403
+
userBlocked: isBlockedBy(targetDid, userDid),
404
+
blockedByTarget: isBlockedBy(userDid, targetDid)
405
+
};
406
+
};
407
+
408
+
export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
409
+
export type DeletedPostInfo = { reply?: PostWithUri['record']['reply'] };
410
+
export const deletedPosts = new SvelteMap<ResourceUri, DeletedPostInfo>();
411
+
// did -> post uris that are replies to that did
412
+
export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>();
413
+
414
+
export const getPost = (did: Did, rkey: RecordKey) =>
415
+
allPosts.get(did)?.get(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey }));
416
+
const hydrateCacheFn: Parameters<typeof hydratePosts>[3] = (did, rkey) => {
417
+
const cached = getPost(did, rkey);
418
+
return cached ? ok(cached) : undefined;
419
+
};
420
+
421
+
export const addPosts = (newPosts: Iterable<PostWithUri>) => {
422
+
for (const post of newPosts) {
423
+
const parsedUri = expect(parseCanonicalResourceUri(post.uri));
424
+
let posts = allPosts.get(parsedUri.repo);
425
+
if (!posts) {
426
+
posts = new SvelteMap();
427
+
allPosts.set(parsedUri.repo, posts);
428
+
}
429
+
posts.set(post.uri, post);
430
+
if (post.record.reply) {
431
+
const link = {
432
+
did: parsedUri.repo,
433
+
collection: parsedUri.collection,
434
+
rkey: parsedUri.rkey
435
+
};
436
+
addBacklinks(post.record.reply.parent.uri, replySource, [link]);
437
+
addBacklinks(post.record.reply.root.uri, replyRootSource, [link]);
438
+
439
+
// update reply index
440
+
const parentDid = extractDidFromUri(post.record.reply.parent.uri);
441
+
if (parentDid) {
442
+
let set = replyIndex.get(parentDid);
443
+
if (!set) {
444
+
set = new SvelteSet();
445
+
replyIndex.set(parentDid, set);
446
+
}
447
+
set.add(post.uri);
448
+
}
449
+
}
90
450
}
91
-
const map = posts.get(did)!;
92
-
for (const [uri, record] of _posts) map.set(uri, record);
93
451
};
94
452
95
-
export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => {
96
-
const client = await getClient(did);
453
+
export const deletePost = (uri: ResourceUri) => {
454
+
const did = extractDidFromUri(uri)!;
455
+
const post = allPosts.get(did)?.get(uri);
456
+
if (!post) return;
457
+
allPosts.get(did)?.delete(uri);
458
+
// remove reply from index
459
+
const subjectDid = extractDidFromUri(post.record.reply?.parent.uri ?? '');
460
+
if (subjectDid) replyIndex.get(subjectDid)?.delete(uri);
461
+
deletedPosts.set(uri, { reply: post.record.reply });
462
+
};
97
463
98
-
const cursor = cursors.get(did);
464
+
export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>();
465
+
export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>();
466
+
467
+
const traversePostChain = (post: PostWithUri) => {
468
+
const result = [post.uri];
469
+
const parentUri = post.record.reply?.parent.uri;
470
+
if (parentUri) {
471
+
const parentPost = allPosts.get(extractDidFromUri(parentUri)!)?.get(parentUri);
472
+
if (parentPost) result.push(...traversePostChain(parentPost));
473
+
}
474
+
return result;
475
+
};
476
+
export const addTimeline = (did: Did, uris: Iterable<ResourceUri>) => {
477
+
let timeline = timelines.get(did);
478
+
if (!timeline) {
479
+
timeline = new SvelteSet();
480
+
timelines.set(did, timeline);
481
+
}
482
+
for (const uri of uris) {
483
+
const post = allPosts.get(did)?.get(uri);
484
+
// we need to traverse the post chain to add all posts in the chain to the timeline
485
+
// because the parent posts might not be in the timeline yet
486
+
const chain = post ? traversePostChain(post) : [uri];
487
+
for (const uri of chain) timeline.add(uri);
488
+
}
489
+
};
490
+
491
+
export const fetchTimeline = async (
492
+
client: AtpClient,
493
+
subject: Did,
494
+
limit: number = 6,
495
+
withBacklinks: boolean = true,
496
+
hydrateOptions?: Partial<HydrateOptions>
497
+
) => {
498
+
const cursor = postCursors.get(subject);
99
499
if (cursor && cursor.end) return;
100
500
101
-
const accPosts = await fetchPostsWithBacklinks(client, cursor?.value, limit);
102
-
if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`;
501
+
const accPosts = await fetchPosts(subject, client, cursor?.value, limit, withBacklinks);
502
+
if (!accPosts.ok) throw `cant fetch posts ${subject}: ${accPosts.error}`;
103
503
104
504
// if the cursor is undefined, we've reached the end of the timeline
105
-
if (!accPosts.value.cursor) {
106
-
cursors.set(did, { ...cursor, end: true });
107
-
return;
505
+
const newCursor = { value: accPosts.value.cursor, end: !accPosts.value.cursor };
506
+
postCursors.set(subject, newCursor);
507
+
const hydrated = await hydratePosts(
508
+
client,
509
+
subject,
510
+
accPosts.value.posts,
511
+
hydrateCacheFn,
512
+
hydrateOptions
513
+
);
514
+
if (!hydrated.ok) throw `cant hydrate posts ${subject}: ${hydrated.error}`;
515
+
516
+
addPosts(hydrated.value.values());
517
+
addTimeline(subject, hydrated.value.keys());
518
+
519
+
if (client.user?.did) {
520
+
const userDid = client.user.did;
521
+
// check if any of the post authors block the user
522
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
523
+
let distinctDids = new Set(hydrated.value.keys().map((uri) => extractDidFromUri(uri)!));
524
+
distinctDids.delete(userDid); // dont need to check if user blocks themselves
525
+
const alreadyFetched = blockFlags.get(userDid);
526
+
if (alreadyFetched) distinctDids = distinctDids.difference(alreadyFetched);
527
+
if (distinctDids.size > 0)
528
+
await Promise.all(distinctDids.values().map((did) => fetchBlocked(client, userDid, did)));
108
529
}
109
530
110
-
cursors.set(did, { value: accPosts.value.cursor, end: false });
111
-
const hydrated = await hydratePosts(client, did, accPosts.value.posts);
112
-
if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`;
531
+
console.log(`${subject}: fetchTimeline`, accPosts.value.cursor);
532
+
return newCursor;
533
+
};
113
534
114
-
addPosts(did, hydrated.value);
535
+
export const fetchInteractionsToTimelineEnd = async (
536
+
client: AtpClient,
537
+
interactor: Did,
538
+
subject: Did
539
+
) => {
540
+
const cursor = postCursors.get(subject);
541
+
if (!cursor) return;
542
+
const timestamp = timestampFromCursor(cursor.value);
543
+
await Promise.all(
544
+
[likeSource, repostSource].map((s) => fetchLinksUntil(interactor, client, s, timestamp))
545
+
);
115
546
};
116
547
117
-
export const handleJetstreamEvent = (event: JetstreamEvent) => {
548
+
export const fetchInitial = async (account: Account) => {
549
+
const client = clients.get(account.did)!;
550
+
await Promise.all([
551
+
fetchBlocks(account),
552
+
fetchForInteractions(client, account.did),
553
+
fetchFollows(account).then((follows) =>
554
+
Promise.all(follows.map((follow) => fetchForInteractions(client, follow.subject)) ?? [])
555
+
)
556
+
]);
557
+
};
558
+
559
+
export const handleJetstreamEvent = async (event: JetstreamEvent) => {
118
560
if (event.kind !== 'commit') return;
119
561
120
562
const { did, commit } = event;
121
-
if (commit.collection !== 'app.bsky.feed.post') return;
563
+
const uri: ResourceUri = toCanonicalUri({ did, ...commit });
564
+
if (commit.collection === 'app.bsky.feed.post') {
565
+
if (commit.operation === 'create') {
566
+
const record = commit.record as AppBskyFeedPost.Main;
567
+
const posts = [
568
+
{
569
+
record,
570
+
uri,
571
+
cid: commit.cid
572
+
}
573
+
];
574
+
await setRecordCache(uri, record);
575
+
const client = clients.get(did) ?? viewClient;
576
+
const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn);
577
+
if (!hydrated.ok) {
578
+
console.error(`cant hydrate posts ${did}: ${hydrated.error}`);
579
+
return;
580
+
}
581
+
addPosts(hydrated.value.values());
582
+
addTimeline(did, hydrated.value.keys());
583
+
if (record.reply) {
584
+
const parentDid = extractDidFromUri(record.reply.parent.uri)!;
585
+
addTimeline(parentDid, [uri]);
586
+
// const rootDid = extractDidFromUri(record.reply.root.uri)!;
587
+
// addTimeline(rootDid, [uri]);
588
+
}
589
+
} else if (commit.operation === 'delete') {
590
+
deletePost(uri);
591
+
}
592
+
}
593
+
};
122
594
123
-
const uri: ResourceUri = `at://${did}/${commit.collection}/${commit.rkey}`;
595
+
const handlePostNotification = async (event: NotificationsStreamEvent & { type: 'message' }) => {
596
+
const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject));
597
+
const did = parsedSubjectUri.repo as AtprotoDid;
598
+
const client = clients.get(did);
599
+
if (!client) {
600
+
console.error(`${did}: cant handle post notification, client not found !?`);
601
+
return;
602
+
}
603
+
const subjectPost = await client.getRecord(
604
+
AppBskyFeedPost.mainSchema,
605
+
did,
606
+
parsedSubjectUri.rkey
607
+
);
608
+
if (!subjectPost.ok) return;
124
609
125
-
if (commit.operation === 'create') {
126
-
const { cid, record } = commit;
610
+
const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
611
+
const posts = [
612
+
{
613
+
record: subjectPost.value.record,
614
+
uri: event.data.link.subject,
615
+
cid: subjectPost.value.cid,
616
+
replies: {
617
+
cursor: null,
618
+
total: 1,
619
+
records: [
620
+
{
621
+
did: parsedSourceUri.repo,
622
+
collection: parsedSourceUri.collection,
623
+
rkey: parsedSourceUri.rkey
624
+
}
625
+
]
626
+
}
627
+
}
628
+
];
629
+
const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn);
630
+
if (!hydrated.ok) {
631
+
console.error(`cant hydrate posts ${did}: ${hydrated.error}`);
632
+
return;
633
+
}
127
634
128
-
const post: PostWithUri = {
129
-
uri,
130
-
cid,
131
-
// assume record is valid, we trust the jetstream
132
-
record: record as AppBskyFeedPost.Main
133
-
};
635
+
// console.log(hydrated);
636
+
addPosts(hydrated.value.values());
637
+
addTimeline(did, hydrated.value.keys());
638
+
};
134
639
135
-
addPosts(did, [[uri, post]]);
136
-
} else if (commit.operation === 'delete') {
137
-
if (posts.has(did)) {
138
-
posts.get(did)?.delete(uri);
640
+
const handleBacklink = (event: NotificationsStreamEvent & { type: 'message' }) => {
641
+
const parsedSource = expect(parseCanonicalResourceUri(event.data.link.source_record));
642
+
addBacklinks(event.data.link.subject, event.data.link.source, [
643
+
{
644
+
did: parsedSource.repo,
645
+
collection: parsedSource.collection,
646
+
rkey: parsedSource.rkey
139
647
}
140
-
}
648
+
]);
141
649
};
142
650
143
651
export const handleNotification = async (event: NotificationsStreamEvent) => {
144
652
if (event.type === 'message') {
145
-
const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject));
146
-
const did = parsedSubjectUri.repo as AtprotoDid;
147
-
const client = await getClient(did);
148
-
const subjectPost = await client.getRecord(
149
-
AppBskyFeedPost.mainSchema,
150
-
did,
151
-
parsedSubjectUri.rkey
152
-
);
153
-
if (!subjectPost.ok) return;
154
-
155
-
const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
156
-
const hydrated = await hydratePosts(client, did, [
157
-
{
158
-
record: subjectPost.value.record,
159
-
uri: event.data.link.subject,
160
-
cid: subjectPost.value.cid,
161
-
replies: {
162
-
cursor: null,
163
-
total: 1,
164
-
records: [
165
-
{
166
-
did: parsedSourceUri.repo,
167
-
collection: parsedSourceUri.collection,
168
-
rkey: parsedSourceUri.rkey
169
-
}
170
-
]
171
-
}
172
-
}
173
-
]);
174
-
if (!hydrated.ok) {
175
-
console.error(`cant hydrate posts ${did}: ${hydrated.error}`);
176
-
return;
177
-
}
178
-
179
-
// console.log(hydrated);
180
-
addPosts(did, hydrated.value);
653
+
if (event.data.link.source.startsWith('app.bsky.feed.post')) handlePostNotification(event);
654
+
else handleBacklink(event);
181
655
}
182
656
};
183
657
···
187
661
setInterval(() => {
188
662
currentTime.setTime(Date.now());
189
663
}, 1000);
664
+
665
+
export const router = new Router();
+1
-3
src/lib/theme.ts
+1
-3
src/lib/theme.ts
···
18
18
const id = input.split(':').pop() || input;
19
19
20
20
hash = 0;
21
-
for (let i = 0; i < Math.min(10, id.length); i++) {
22
-
hash = (hash << 4) + id.charCodeAt(i);
23
-
}
21
+
for (let i = 0; i < Math.min(10, id.length); i++) hash = (hash << 4) + id.charCodeAt(i);
24
22
hash = hash >>> 0;
25
23
26
24
// magic mixing
+26
-22
src/lib/thread.ts
+26
-22
src/lib/thread.ts
···
1
+
// updated src/lib/thread.ts
2
+
1
3
import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons';
2
4
import type { Account } from './accounts';
3
5
import { expect } from './result';
4
6
import type { PostWithUri } from './at/fetch';
7
+
import { isBlockedBy } from './state.svelte';
5
8
6
9
export type ThreadPost = {
7
10
data: PostWithUri;
···
11
14
parentUri: ResourceUri | null;
12
15
depth: number;
13
16
newestTime: number;
17
+
isBlocked?: boolean;
14
18
};
15
19
16
20
export type Thread = {
···
21
25
};
22
26
23
27
export const buildThreads = (
24
-
accounts: Did[],
28
+
account: Did,
29
+
timeline: Set<ResourceUri>,
25
30
posts: Map<Did, Map<ResourceUri, PostWithUri>>
26
31
): Thread[] => {
27
32
const threadMap = new Map<ResourceUri, ThreadPost[]>();
28
33
29
34
// group posts by root uri into "thread" chains
30
-
for (const account of accounts) {
31
-
const timeline = posts.get(account);
32
-
if (!timeline) continue;
33
-
for (const [uri, data] of timeline) {
34
-
const parsedUri = expect(parseCanonicalResourceUri(uri));
35
-
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
36
-
const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null;
35
+
for (const uri of timeline) {
36
+
const parsedUri = expect(parseCanonicalResourceUri(uri));
37
+
const data = posts.get(parsedUri.repo)?.get(uri);
38
+
if (!data) continue;
39
+
40
+
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
41
+
const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null;
37
42
38
-
const post: ThreadPost = {
39
-
data,
40
-
account,
41
-
did: parsedUri.repo,
42
-
rkey: parsedUri.rkey,
43
-
parentUri,
44
-
depth: 0,
45
-
newestTime: new Date(data.record.createdAt).getTime()
46
-
};
43
+
const post: ThreadPost = {
44
+
data,
45
+
account,
46
+
did: parsedUri.repo,
47
+
rkey: parsedUri.rkey,
48
+
parentUri,
49
+
depth: 0,
50
+
newestTime: new Date(data.record.createdAt).getTime(),
51
+
isBlocked: isBlockedBy(parsedUri.repo, account)
52
+
};
47
53
48
-
if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
54
+
if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
49
55
50
-
threadMap.get(rootUri)!.push(post);
51
-
}
56
+
threadMap.get(rootUri)!.push(post);
52
57
}
53
58
54
59
const threads: Thread[] = [];
···
151
156
152
157
threads.sort((a, b) => b.newestTime - a.newestTime);
153
158
154
-
// console.log(threads);
155
-
156
159
return threads;
157
160
};
158
161
···
168
171
169
172
export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) =>
170
173
threads.filter((thread) => {
174
+
if (thread.posts.length === 0) return false;
171
175
if (!opts.viewOwnPosts) return hasNonOwnPost(thread.posts, accounts);
172
176
return true;
173
177
});
-345
src/routes/+page.svelte
-345
src/routes/+page.svelte
···
1
-
<script lang="ts">
2
-
import PostComposer, { type State as PostComposerState } from '$components/PostComposer.svelte';
3
-
import AccountSelector from '$components/AccountSelector.svelte';
4
-
import SettingsView from '$components/SettingsView.svelte';
5
-
import NotificationsView from '$components/NotificationsView.svelte';
6
-
import FollowingView from '$components/FollowingView.svelte';
7
-
import TimelineView from '$components/TimelineView.svelte';
8
-
import { AtpClient, streamNotifications } from '$lib/at/client';
9
-
import { accounts, type Account } from '$lib/accounts';
10
-
import { onMount, tick } from 'svelte';
11
-
import { SvelteMap } from 'svelte/reactivity';
12
-
import {
13
-
clients,
14
-
cursors,
15
-
fetchFollowPosts,
16
-
fetchFollows,
17
-
follows,
18
-
notificationStream,
19
-
posts,
20
-
viewClient,
21
-
jetstream,
22
-
handleJetstreamEvent,
23
-
handleNotification
24
-
} from '$lib/state.svelte';
25
-
import { get } from 'svelte/store';
26
-
import Icon from '@iconify/svelte';
27
-
import { sessions } from '$lib/at/oauth';
28
-
import type { AtprotoDid, Did } from '@atcute/lexicons/syntax';
29
-
import type { PageProps } from './+page';
30
-
import { JetstreamSubscription } from '@atcute/jetstream';
31
-
import { settings } from '$lib/settings';
32
-
33
-
const { data: loadData }: PageProps = $props();
34
-
35
-
// svelte-ignore state_referenced_locally
36
-
let errors = $state(loadData.client.ok ? [] : [loadData.client.error]);
37
-
let errorsOpen = $state(false);
38
-
39
-
let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null);
40
-
$effect(() => {
41
-
if (selectedDid) localStorage.setItem('selectedDid', selectedDid);
42
-
else localStorage.removeItem('selectedDid');
43
-
});
44
-
const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null);
45
-
46
-
const loginAccount = async (account: Account) => {
47
-
if (clients.has(account.did)) return;
48
-
const client = new AtpClient();
49
-
const result = await client.login(await sessions.get(account.did));
50
-
if (!result.ok) {
51
-
errors.push(`failed to login into @${account.handle ?? account.did}: ${result.error}`);
52
-
return;
53
-
}
54
-
clients.set(account.did, client);
55
-
};
56
-
const handleAccountSelected = async (did: AtprotoDid) => {
57
-
selectedDid = did;
58
-
const account = $accounts.find((acc) => acc.did === did);
59
-
if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute))
60
-
await loginAccount(account);
61
-
};
62
-
const handleLogout = async (did: AtprotoDid) => {
63
-
await sessions.remove(did);
64
-
const newAccounts = $accounts.filter((acc) => acc.did !== did);
65
-
$accounts = newAccounts;
66
-
clients.delete(did);
67
-
posts.delete(did);
68
-
cursors.delete(did);
69
-
handleAccountSelected(newAccounts[0]?.did);
70
-
};
71
-
72
-
type View = 'timeline' | 'notifications' | 'following' | 'settings';
73
-
let currentView = $state<View>('timeline');
74
-
let animClass = $state('animate-fade-in-scale');
75
-
let scrollPositions = new SvelteMap<View, number>();
76
-
77
-
const viewOrder: Record<View, number> = {
78
-
timeline: 0,
79
-
following: 1,
80
-
notifications: 2,
81
-
settings: 3
82
-
};
83
-
84
-
const switchView = async (newView: View) => {
85
-
if (currentView === newView) return;
86
-
scrollPositions.set(currentView, window.scrollY);
87
-
88
-
const direction = viewOrder[newView] > viewOrder[currentView] ? 'right' : 'left';
89
-
animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left';
90
-
currentView = newView;
91
-
92
-
await tick();
93
-
94
-
window.scrollTo({ top: scrollPositions.get(newView) || 0, behavior: 'instant' });
95
-
};
96
-
97
-
let postComposerState = $state<PostComposerState>({ type: 'null' });
98
-
let showScrollToTop = $state(false);
99
-
const handleScroll = () => {
100
-
if (currentView === 'timeline') showScrollToTop = window.scrollY > 300;
101
-
};
102
-
const scrollToTop = () => {
103
-
window.scrollTo({ top: 0, behavior: 'smooth' });
104
-
};
105
-
106
-
onMount(() => {
107
-
window.addEventListener('scroll', handleScroll);
108
-
109
-
accounts.subscribe((newAccounts) => {
110
-
get(notificationStream)?.stop();
111
-
// jetstream.set(null);
112
-
if (newAccounts.length === 0) return;
113
-
notificationStream.set(
114
-
streamNotifications(
115
-
newAccounts.map((account) => account.did),
116
-
'app.bsky.feed.post:reply.parent.uri',
117
-
'app.bsky.feed.post:embed.record.record.uri',
118
-
'app.bsky.feed.post:embed.record.uri'
119
-
)
120
-
);
121
-
});
122
-
notificationStream.subscribe((stream) => {
123
-
if (!stream) return;
124
-
stream.listen(handleNotification);
125
-
});
126
-
127
-
console.log(`creating jetstream subscription to ${$settings.endpoints.jetstream}`);
128
-
const jetstreamSub = new JetstreamSubscription({
129
-
url: $settings.endpoints.jetstream,
130
-
wantedCollections: ['app.bsky.feed.post'],
131
-
wantedDids: ['did:web:guestbook.gaze.systems'] // initially contain sentinel
132
-
});
133
-
jetstream.set(jetstreamSub);
134
-
135
-
(async () => {
136
-
console.log('polling for jetstream...');
137
-
for await (const event of jetstreamSub) handleJetstreamEvent(event);
138
-
})();
139
-
140
-
if ($accounts.length > 0) {
141
-
if (loadData.client.ok && loadData.client.value) {
142
-
const loggedInDid = loadData.client.value.user!.did as AtprotoDid;
143
-
selectedDid = loggedInDid;
144
-
clients.set(loggedInDid, loadData.client.value);
145
-
}
146
-
if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did;
147
-
// console.log('onMount selectedDid', selectedDid);
148
-
Promise.all($accounts.map(loginAccount)).then(() => {
149
-
$accounts.forEach((account) =>
150
-
fetchFollows(account.did).then(() =>
151
-
follows
152
-
.get(account.did)
153
-
?.forEach((follow) => fetchFollowPosts(follow.subject as AtprotoDid))
154
-
)
155
-
);
156
-
});
157
-
} else {
158
-
selectedDid = null;
159
-
}
160
-
161
-
return () => window.removeEventListener('scroll', handleScroll);
162
-
});
163
-
164
-
$effect(() => {
165
-
const wantedDids: Did[] = ['did:web:guestbook.gaze.systems'];
166
-
167
-
for (const followMap of follows.values())
168
-
for (const follow of followMap.values()) wantedDids.push(follow.subject);
169
-
for (const account of $accounts) wantedDids.push(account.did);
170
-
171
-
console.log('updating jetstream options:', wantedDids);
172
-
$jetstream?.updateOptions({ wantedDids });
173
-
});
174
-
</script>
175
-
176
-
{#snippet appButton(
177
-
onClick: () => void,
178
-
icon: string,
179
-
ariaLabel: string,
180
-
isActive: boolean,
181
-
iconHover?: string
182
-
)}
183
-
<button
184
-
onclick={onClick}
185
-
class="group rounded-sm p-2 transition-all hover:scale-110 hover:shadow-lg
186
-
{isActive
187
-
? 'bg-(--nucleus-accent)/25 text-(--nucleus-accent)'
188
-
: 'bg-(--nucleus-accent)/10 text-(--nucleus-accent) hover:bg-(--nucleus-accent)/15'}"
189
-
aria-label={ariaLabel}
190
-
>
191
-
<Icon class="group-hover:hidden" {icon} width={28} />
192
-
<Icon class="hidden group-hover:block" icon={iconHover ?? icon} width={28} />
193
-
</button>
194
-
{/snippet}
195
-
196
-
<div class="mx-auto flex min-h-dvh max-w-2xl flex-col">
197
-
<div class="flex-1">
198
-
<!-- timeline -->
199
-
<TimelineView
200
-
class={currentView === 'timeline' ? `${animClass}` : 'hidden'}
201
-
client={selectedClient ?? viewClient}
202
-
bind:postComposerState
203
-
/>
204
-
205
-
{#if currentView === 'settings'}
206
-
<div class={animClass}>
207
-
<SettingsView />
208
-
</div>
209
-
{/if}
210
-
{#if currentView === 'notifications'}
211
-
<div class={animClass}>
212
-
<NotificationsView />
213
-
</div>
214
-
{/if}
215
-
{#if currentView === 'following'}
216
-
<div class={animClass}>
217
-
<FollowingView selectedClient={selectedClient!} selectedDid={selectedDid!} />
218
-
</div>
219
-
{/if}
220
-
</div>
221
-
222
-
<!-- header / footer -->
223
-
<div id="app-footer" class="sticky bottom-0 z-10 mt-4">
224
-
{#if errors.length > 0}
225
-
<div class="relative m-3 mb-1 error-disclaimer">
226
-
<div class="flex items-center gap-2 text-red-500">
227
-
<Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
228
-
there are ({errors.length}) errors
229
-
<div class="grow"></div>
230
-
<button onclick={() => (errorsOpen = !errorsOpen)} class="action-button p-1 px-1.5"
231
-
>{errorsOpen ? 'hide details' : 'see details'}</button
232
-
>
233
-
</div>
234
-
{#if errorsOpen}
235
-
<div
236
-
class="absolute right-0 bottom-full left-0 z-10 mb-2 flex animate-fade-in-scale-fast flex-col gap-1 error-disclaimer shadow-lg transition-all"
237
-
>
238
-
{#each errors as error, idx (idx)}
239
-
<p>โข {error}</p>
240
-
{/each}
241
-
</div>
242
-
{/if}
243
-
</div>
244
-
{/if}
245
-
246
-
<div
247
-
class="
248
-
{currentView === 'timeline' || currentView === 'following' ? '' : 'hidden'}
249
-
z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all
250
-
"
251
-
>
252
-
<!-- composer and error disclaimer (above thread list, not scrollable) -->
253
-
<div class="footer-border-bg rounded-sm px-0.5 py-0.5">
254
-
<div class="footer-bg flex gap-2 rounded-sm p-1.5 shadow-2xl">
255
-
<AccountSelector
256
-
client={viewClient}
257
-
accounts={$accounts}
258
-
bind:selectedDid
259
-
onAccountSelected={handleAccountSelected}
260
-
onLogout={handleLogout}
261
-
/>
262
-
263
-
{#if selectedClient}
264
-
<div class="flex-1">
265
-
<PostComposer
266
-
client={selectedClient}
267
-
onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)}
268
-
bind:_state={postComposerState}
269
-
/>
270
-
</div>
271
-
{:else}
272
-
<div
273
-
class="flex flex-1 items-center justify-center rounded-sm border-2 border-(--nucleus-accent)/20 bg-(--nucleus-accent)/4 px-4 py-2.5 backdrop-blur-sm"
274
-
>
275
-
<p class="text-sm opacity-80">select or add an account to post</p>
276
-
</div>
277
-
{/if}
278
-
279
-
{#if postComposerState.type === 'null' && showScrollToTop}
280
-
{@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top', false)}
281
-
{/if}
282
-
</div>
283
-
</div>
284
-
</div>
285
-
286
-
<div id="footer-portal" class="contents"></div>
287
-
288
-
<div class="footer-border-bg rounded-t-sm px-0.5 pt-0.5">
289
-
<div class="footer-bg rounded-t-sm">
290
-
<div class="flex items-center gap-1.5 px-2 py-1">
291
-
<div class="mb-2">
292
-
<h1 class="text-3xl font-bold tracking-tight">nucleus</h1>
293
-
<div class="mt-1 flex gap-2">
294
-
<div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div>
295
-
<div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div>
296
-
</div>
297
-
</div>
298
-
<div class="grow"></div>
299
-
{@render appButton(
300
-
() => switchView('timeline'),
301
-
'heroicons:home',
302
-
'timeline',
303
-
currentView === 'timeline',
304
-
'heroicons:home-solid'
305
-
)}
306
-
{@render appButton(
307
-
() => switchView('following'),
308
-
'heroicons:users',
309
-
'following',
310
-
currentView === 'following',
311
-
'heroicons:users-solid'
312
-
)}
313
-
{@render appButton(
314
-
() => switchView('notifications'),
315
-
'heroicons:bell',
316
-
'notifications',
317
-
currentView === 'notifications',
318
-
'heroicons:bell-solid'
319
-
)}
320
-
{@render appButton(
321
-
() => switchView('settings'),
322
-
'heroicons:cog-6-tooth',
323
-
'settings',
324
-
currentView === 'settings',
325
-
'heroicons:cog-6-tooth-solid'
326
-
)}
327
-
</div>
328
-
</div>
329
-
</div>
330
-
</div>
331
-
</div>
332
-
333
-
<style>
334
-
.footer-bg {
335
-
background: linear-gradient(
336
-
to right,
337
-
color-mix(in srgb, var(--nucleus-accent) 18%, var(--nucleus-bg)),
338
-
color-mix(in srgb, var(--nucleus-accent2) 13%, var(--nucleus-bg))
339
-
);
340
-
}
341
-
342
-
.footer-border-bg {
343
-
background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));
344
-
}
345
-
</style>
-45
src/routes/+page.ts
-45
src/routes/+page.ts
···
1
-
import { replaceState } from '$app/navigation';
2
-
import { addAccount, loggingIn } from '$lib/accounts';
3
-
import { AtpClient } from '$lib/at/client';
4
-
import { flow, sessions } from '$lib/at/oauth';
5
-
import { err, ok, type Result } from '$lib/result';
6
-
import type { PageLoad } from './$types';
7
-
8
-
export type PageProps = {
9
-
data: {
10
-
client: Result<AtpClient | null, string>;
11
-
};
12
-
};
13
-
14
-
export const load: PageLoad = async (): Promise<PageProps['data']> => {
15
-
return { client: await handleLogin() };
16
-
};
17
-
18
-
const handleLogin = async (): Promise<Result<AtpClient | null, string>> => {
19
-
const account = loggingIn.get();
20
-
if (!account) return ok(null);
21
-
22
-
const currentUrl = new URL(window.location.href);
23
-
// scrub history so auth state cant be replayed
24
-
try {
25
-
replaceState('', '/');
26
-
} catch {
27
-
// if router was unitialized then we probably dont need to scrub anyway
28
-
// so its fine
29
-
}
30
-
31
-
loggingIn.set(null);
32
-
await sessions.remove(account.did);
33
-
const agent = await flow.finalize(currentUrl);
34
-
if (!agent.ok || !agent.value) {
35
-
if (!agent.ok) return err(agent.error);
36
-
return err('no session was logged into?!');
37
-
}
38
-
39
-
const client = new AtpClient();
40
-
const result = await client.login(agent.value);
41
-
if (!result.ok) return err(result.error);
42
-
43
-
addAccount(account);
44
-
return ok(client);
45
-
};
+351
src/routes/[...catchall]/+page.svelte
+351
src/routes/[...catchall]/+page.svelte
···
1
+
<script lang="ts">
2
+
import PostComposer, { type State as PostComposerState } from '$components/PostComposer.svelte';
3
+
import AccountSelector from '$components/AccountSelector.svelte';
4
+
import SettingsView from '$components/SettingsView.svelte';
5
+
import NotificationsView from '$components/NotificationsView.svelte';
6
+
import FollowingView from '$components/FollowingView.svelte';
7
+
import TimelineView from '$components/TimelineView.svelte';
8
+
import ProfileView from '$components/ProfileView.svelte';
9
+
import { AtpClient, streamNotifications } from '$lib/at/client.svelte';
10
+
import { accounts, type Account } from '$lib/accounts';
11
+
import { onMount } from 'svelte';
12
+
import {
13
+
clients,
14
+
postCursors,
15
+
follows,
16
+
notificationStream,
17
+
viewClient,
18
+
jetstream,
19
+
handleJetstreamEvent,
20
+
handleNotification,
21
+
addPosts,
22
+
addTimeline,
23
+
router,
24
+
fetchInitial
25
+
} from '$lib/state.svelte';
26
+
import { get } from 'svelte/store';
27
+
import Icon from '@iconify/svelte';
28
+
import { sessions } from '$lib/at/oauth';
29
+
import type { AtprotoDid, Did } from '@atcute/lexicons/syntax';
30
+
import type { PageProps } from './+page';
31
+
import { JetstreamSubscription } from '@atcute/jetstream';
32
+
import { settings } from '$lib/settings';
33
+
import type { Sort } from '$lib/following';
34
+
import { SvelteMap } from 'svelte/reactivity';
35
+
36
+
const { data: loadData }: PageProps = $props();
37
+
38
+
const currentRoute = $derived(router.current);
39
+
40
+
// svelte-ignore state_referenced_locally
41
+
let errors = $state(loadData.client.ok ? [] : [loadData.client.error]);
42
+
let errorsOpen = $state(false);
43
+
44
+
let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null);
45
+
$effect(() => {
46
+
if (selectedDid) localStorage.setItem('selectedDid', selectedDid);
47
+
else localStorage.removeItem('selectedDid');
48
+
});
49
+
const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : undefined);
50
+
51
+
const loginAccount = async (account: Account) => {
52
+
if (clients.has(account.did)) return;
53
+
const client = new AtpClient();
54
+
const result = await client.login(await sessions.get(account.did));
55
+
if (!result.ok) {
56
+
errors.push(`failed to login into @${account.handle ?? account.did}: ${result.error}`);
57
+
return;
58
+
}
59
+
clients.set(account.did, client);
60
+
};
61
+
const handleAccountSelected = async (did: AtprotoDid) => {
62
+
selectedDid = did;
63
+
const account = $accounts.find((acc) => acc.did === did);
64
+
if (account && (!clients.has(account.did) || !clients.get(account.did)?.user))
65
+
await loginAccount(account);
66
+
};
67
+
const handleLogout = async (did: AtprotoDid) => {
68
+
await sessions.remove(did);
69
+
const newAccounts = $accounts.filter((acc) => acc.did !== did);
70
+
$accounts = newAccounts;
71
+
clients.delete(did);
72
+
postCursors.delete(did);
73
+
handleAccountSelected(newAccounts[0]?.did);
74
+
};
75
+
76
+
let followingSort = $state('active' as Sort);
77
+
78
+
// Animation logic derived from router direction
79
+
let animClass = $state('animate-fade-in-scale');
80
+
$effect(() => {
81
+
if (router.direction === 'right') animClass = 'animate-slide-in-right';
82
+
else if (router.direction === 'left') animClass = 'animate-slide-in-left';
83
+
else animClass = 'animate-fade-in-scale';
84
+
});
85
+
86
+
let postComposerState = $state<PostComposerState>({
87
+
focus: 'null',
88
+
text: '',
89
+
blobsState: new SvelteMap()
90
+
});
91
+
let showScrollToTop = $state(false);
92
+
const handleScroll = () => {
93
+
if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor')
94
+
showScrollToTop = window.scrollY > 300;
95
+
};
96
+
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' });
97
+
98
+
onMount(() => {
99
+
router.init();
100
+
101
+
window.addEventListener('scroll', handleScroll);
102
+
103
+
accounts.subscribe((newAccounts) => {
104
+
get(notificationStream)?.stop();
105
+
// jetstream.set(null);
106
+
if (newAccounts.length === 0) return;
107
+
notificationStream.set(
108
+
streamNotifications(
109
+
newAccounts.map((account) => account.did),
110
+
'app.bsky.feed.post:reply.parent.uri',
111
+
'app.bsky.feed.post:embed.record.record.uri',
112
+
'app.bsky.feed.post:embed.record.uri',
113
+
'app.bsky.feed.repost:subject.uri',
114
+
'app.bsky.feed.like:subject.uri',
115
+
'app.bsky.graph.follow:subject',
116
+
'app.bsky.graph.block:subject'
117
+
)
118
+
);
119
+
});
120
+
notificationStream.subscribe((stream) => {
121
+
if (!stream) return;
122
+
stream.listen(handleNotification);
123
+
});
124
+
125
+
console.log(`creating jetstream subscription to ${$settings.endpoints.jetstream}`);
126
+
const jetstreamSub = new JetstreamSubscription({
127
+
url: $settings.endpoints.jetstream,
128
+
wantedCollections: ['app.bsky.feed.post'],
129
+
// this is here because if wantedDids is zero jetstream will send all events
130
+
wantedDids: ['did:web:guestbook.gaze.systems']
131
+
});
132
+
jetstream.set(jetstreamSub);
133
+
134
+
(async () => {
135
+
console.log('polling for jetstream...');
136
+
for await (const event of jetstreamSub) handleJetstreamEvent(event);
137
+
})();
138
+
139
+
if ($accounts.length > 0) {
140
+
if (loadData.client.ok && loadData.client.value) {
141
+
const loggedInDid = loadData.client.value.user!.did as AtprotoDid;
142
+
selectedDid = loggedInDid;
143
+
clients.set(loggedInDid, loadData.client.value);
144
+
}
145
+
if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did;
146
+
// console.log('onMount selectedDid', selectedDid);
147
+
Promise.all($accounts.map(loginAccount)).then(() => $accounts.forEach(fetchInitial));
148
+
} else {
149
+
selectedDid = null;
150
+
}
151
+
152
+
return () => window.removeEventListener('scroll', handleScroll);
153
+
});
154
+
155
+
$effect(() => {
156
+
const wantedDids: Did[] = ['did:web:guestbook.gaze.systems'];
157
+
const followDids = follows
158
+
.values()
159
+
.flatMap((followMap) => followMap.values().map((follow) => follow.subject));
160
+
const accountDids = $accounts.values().map((account) => account.did);
161
+
wantedDids.push(...followDids, ...accountDids);
162
+
// console.log('updating jetstream options:', wantedDids);
163
+
$jetstream?.updateOptions({ wantedDids });
164
+
});
165
+
</script>
166
+
167
+
{#snippet appButton(
168
+
onClick: () => void,
169
+
icon: string,
170
+
ariaLabel: string,
171
+
isActive: boolean,
172
+
iconHover?: string
173
+
)}
174
+
<button
175
+
onclick={onClick}
176
+
class="group rounded-sm p-2 transition-all hover:scale-110 hover:shadow-lg
177
+
{isActive
178
+
? 'bg-(--nucleus-accent)/25 text-(--nucleus-accent)'
179
+
: 'bg-(--nucleus-accent)/10 text-(--nucleus-accent) hover:bg-(--nucleus-accent)/15'}"
180
+
aria-label={ariaLabel}
181
+
>
182
+
<Icon class="group-hover:hidden" {icon} width={28} />
183
+
<Icon class="hidden group-hover:block" icon={iconHover ?? icon} width={28} />
184
+
</button>
185
+
{/snippet}
186
+
187
+
{#snippet routeButton({
188
+
route,
189
+
path = route,
190
+
icon,
191
+
iconHover = `${icon}-solid`,
192
+
ariaLabel = path.split('/').pop() ?? path
193
+
}: {
194
+
route: (typeof currentRoute)['path'];
195
+
path?: string;
196
+
icon: string;
197
+
ariaLabel?: string;
198
+
iconHover?: string;
199
+
})}
200
+
{@render appButton(
201
+
() => router.navigate(path),
202
+
icon,
203
+
ariaLabel,
204
+
currentRoute.path === route,
205
+
iconHover
206
+
)}
207
+
{/snippet}
208
+
209
+
<div class="mx-auto flex min-h-dvh max-w-2xl flex-col">
210
+
<div class="flex-1">
211
+
<!-- timeline -->
212
+
<TimelineView
213
+
class={currentRoute.path === '/' ? `${animClass}` : 'hidden'}
214
+
client={selectedClient}
215
+
showReplies={true}
216
+
bind:postComposerState
217
+
/>
218
+
219
+
{#if currentRoute.path === '/settings/:tab'}
220
+
<div class={animClass}>
221
+
<SettingsView tab={currentRoute.params.tab} />
222
+
</div>
223
+
{:else if currentRoute.path === '/notifications'}
224
+
<div class={animClass}>
225
+
<NotificationsView />
226
+
</div>
227
+
{:else if currentRoute.path === '/following'}
228
+
<div class={animClass}>
229
+
<FollowingView client={selectedClient} bind:followingSort />
230
+
</div>
231
+
{:else if currentRoute.path === '/profile/:actor'}
232
+
{#key currentRoute.params.actor}
233
+
<div class={animClass}>
234
+
<ProfileView
235
+
client={selectedClient ?? viewClient}
236
+
onBack={() => router.back()}
237
+
actor={currentRoute.params.actor}
238
+
bind:postComposerState
239
+
/>
240
+
</div>
241
+
{/key}
242
+
{/if}
243
+
</div>
244
+
245
+
<!-- header / footer -->
246
+
<div id="app-footer" class="sticky bottom-0 z-10 mt-4">
247
+
{#if errors.length > 0}
248
+
<div class="relative m-3 mb-1 error-disclaimer">
249
+
<div class="flex items-center gap-2 text-red-500">
250
+
<Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
251
+
there are ({errors.length}) errors
252
+
<div class="grow"></div>
253
+
<button onclick={() => (errorsOpen = !errorsOpen)} class="action-button p-1 px-1.5"
254
+
>{errorsOpen ? 'hide details' : 'see details'}</button
255
+
>
256
+
</div>
257
+
{#if errorsOpen}
258
+
<div
259
+
class="absolute right-0 bottom-full left-0 z-10 mb-2 flex animate-fade-in-scale-fast flex-col gap-1 error-disclaimer shadow-lg transition-all"
260
+
>
261
+
{#each errors as error, idx (idx)}
262
+
<p>โข {error}</p>
263
+
{/each}
264
+
</div>
265
+
{/if}
266
+
</div>
267
+
{/if}
268
+
269
+
<div
270
+
class="
271
+
{['/', '/following', '/profile/:actor'].includes(router.current.path) ? '' : 'hidden'}
272
+
z-20 w-full max-w-2xl p-2.5 px-4 pb-1.25 transition-all
273
+
"
274
+
>
275
+
<!-- composer and error disclaimer (above thread list, not scrollable) -->
276
+
<div class="footer-border-bg rounded-sm p-0.5">
277
+
<div class="footer-bg flex gap-2 rounded-sm p-1.5">
278
+
<AccountSelector
279
+
client={viewClient}
280
+
accounts={$accounts}
281
+
bind:selectedDid
282
+
onAccountSelected={handleAccountSelected}
283
+
onLogout={handleLogout}
284
+
/>
285
+
286
+
{#if selectedClient}
287
+
<div class="flex-1">
288
+
<PostComposer
289
+
client={selectedClient}
290
+
onPostSent={(post) => {
291
+
addPosts([post]);
292
+
addTimeline(selectedDid!, [post.uri]);
293
+
}}
294
+
bind:_state={postComposerState}
295
+
/>
296
+
</div>
297
+
{:else}
298
+
<div
299
+
class="flex flex-1 items-center justify-center rounded-sm border-2 border-(--nucleus-accent)/20 bg-(--nucleus-accent)/4 px-4 py-2.5 backdrop-blur-sm"
300
+
>
301
+
<p class="text-sm opacity-80">select or add an account to post</p>
302
+
</div>
303
+
{/if}
304
+
305
+
{#if postComposerState.focus === 'null' && showScrollToTop}
306
+
{@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top', false)}
307
+
{/if}
308
+
</div>
309
+
</div>
310
+
</div>
311
+
312
+
<div id="footer-portal" class="contents"></div>
313
+
314
+
<div class="footer-border-bg rounded-t-sm px-0.75 pt-0.75">
315
+
<div class="footer-bg rounded-t-sm">
316
+
<div class="flex items-center gap-1.5 px-2 py-1">
317
+
<div class="mb-2">
318
+
<h1 class="text-3xl font-bold tracking-tight">nucleus</h1>
319
+
<div class="mt-1 flex gap-2">
320
+
<div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div>
321
+
<div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div>
322
+
</div>
323
+
</div>
324
+
<div class="grow"></div>
325
+
{@render routeButton({ route: '/', icon: 'heroicons:home' })}
326
+
{@render routeButton({ route: '/following', icon: 'heroicons:users' })}
327
+
{@render routeButton({ route: '/notifications', icon: 'heroicons:bell' })}
328
+
{@render routeButton({
329
+
path: '/settings/advanced',
330
+
route: '/settings/:tab',
331
+
icon: 'heroicons:cog-6-tooth'
332
+
})}
333
+
</div>
334
+
</div>
335
+
</div>
336
+
</div>
337
+
</div>
338
+
339
+
<style>
340
+
.footer-bg {
341
+
background: linear-gradient(
342
+
to right,
343
+
color-mix(in srgb, var(--nucleus-accent) 18%, var(--nucleus-bg)),
344
+
color-mix(in srgb, var(--nucleus-accent2) 13%, var(--nucleus-bg))
345
+
);
346
+
}
347
+
348
+
.footer-border-bg {
349
+
background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));
350
+
}
351
+
</style>
+46
src/routes/[...catchall]/+page.ts
+46
src/routes/[...catchall]/+page.ts
···
1
+
import { addAccount, loggingIn } from '$lib/accounts';
2
+
import { AtpClient } from '$lib/at/client.svelte';
3
+
import { flow, sessions } from '$lib/at/oauth';
4
+
import { err, ok, type Result } from '$lib/result';
5
+
import type { PageLoad } from './$types';
6
+
7
+
export const prerender = false;
8
+
9
+
export type PageProps = {
10
+
data: {
11
+
client: Result<AtpClient | null, string>;
12
+
};
13
+
};
14
+
15
+
export const load: PageLoad = async (): Promise<PageProps['data']> => {
16
+
return { client: await handleLogin() };
17
+
};
18
+
19
+
const handleLogin = async (): Promise<Result<AtpClient | null, string>> => {
20
+
const account = loggingIn.get();
21
+
if (!account) return ok(null);
22
+
23
+
const currentUrl = new URL(window.location.href);
24
+
// scrub history so auth state cant be replayed
25
+
try {
26
+
history.replaceState(null, '', '/');
27
+
} catch {
28
+
// if router was unitialized then we probably dont need to scrub anyway
29
+
// so its fine
30
+
}
31
+
32
+
loggingIn.set(null);
33
+
await sessions.remove(account.did);
34
+
const agent = await flow.finalize(currentUrl);
35
+
if (!agent.ok || !agent.value) {
36
+
if (!agent.ok) return err(agent.error);
37
+
return err('no session was logged into?!');
38
+
}
39
+
40
+
const client = new AtpClient();
41
+
const result = await client.login(agent.value);
42
+
if (!result.ok) return err(result.error);
43
+
44
+
addAccount(account);
45
+
return ok(client);
46
+
};
+4
-1
svelte.config.js
+4
-1
svelte.config.js
···
11
11
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
12
12
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
13
13
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
14
-
adapter: adapter(),
14
+
adapter: adapter({
15
+
fallback: 'index.html',
16
+
precompress: true
17
+
}),
15
18
alias: {
16
19
$lib: 'src/lib',
17
20
'$lib/*': 'src/lib/*',