replies timeline only, appview-less bluesky client

Compare changes

Choose any two refs to compare.

+13
.zed/settings.json
··· 1 + { 2 + "lsp": { 3 + "vtsls": { 4 + "settings": { 5 + "typescript": { 6 + "suggestionActions": { 7 + "enabled": false 8 + } 9 + } 10 + } 11 + } 12 + } 13 + }
+102 -51
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.18": "3.0.18_svelte@5.46.1__acorn@8.15.0", 25 26 "npm:@types/node@^25.0.3": "25.0.3", 26 27 "npm:@wora/cache-persist@^2.2.1": "2.2.1", 27 28 "npm:async-cache-dedupe@^3.4.0": "3.4.0", 28 29 "npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.39.2", 29 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", 30 31 "npm:eslint@^9.39.2": "9.39.2", 31 - "npm:globals@^16.5.0": "16.5.0", 32 + "npm:globals@17": "17.0.0", 32 33 "npm:hash-wasm@^4.12.0": "4.12.0", 33 34 "npm:lru-cache@^11.2.4": "11.2.4", 35 + "npm:photoswipe@^5.4.4": "5.4.4", 34 36 "npm:prettier-plugin-svelte@^3.4.1": "3.4.1_prettier@3.7.4_svelte@5.46.1__acorn@8.15.0", 35 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", 36 38 "npm:prettier@^3.7.4": "3.7.4", ··· 41 43 "npm:svelte-portal@^2.2.1": "2.2.1", 42 44 "npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0", 43 45 "npm:tailwindcss@^4.1.18": "4.1.18", 44 - "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", 45 47 "npm:typescript@^5.9.3": "5.9.3", 46 48 "npm:vite@^7.3.0": "7.3.0_@types+node@25.0.3_picomatch@4.0.3" 47 49 }, 48 50 "npm": { 49 - "@atcute/atproto@3.1.9": { 50 - "integrity": "sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==", 51 + "@atcute/atproto@3.1.10": { 52 + "integrity": "sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==", 51 53 "dependencies": [ 52 54 "@atcute/lexicons" 53 55 ] ··· 73 75 "@atcute/lexicons" 74 76 ] 75 77 }, 76 - "@atcute/client@4.1.1": { 77 - "integrity": "sha512-FROCbTTCeL5u4tO/n72jDEKyKqjdlXMB56Ehve3W/gnnLGCYWvN42sS7tvL1Mgu6sbO3yZwsXKDrmM2No4XpjA==", 78 + "@atcute/client@4.2.0": { 79 + "integrity": "sha512-vYixpXevM+dkzN4HGTfmxCJBOXNSRAMki1bfi1atFdmZGo9n1zStyClHqn0SRs8I5nOQoVG1XBkYAGSFJWqy2Q==", 78 80 "dependencies": [ 79 81 "@atcute/identity", 80 82 "@atcute/lexicons" ··· 108 110 "yocto-queue@1.2.2" 109 111 ] 110 112 }, 111 - "@atcute/lexicons@1.2.5": { 112 - "integrity": "sha512-9yO9WdgxW8jZ7SbzUycH710z+JmsQ9W9n5S6i6eghYju32kkluFmgBeS47r8e8p2+Dv4DemS7o/3SUGsX9FR5Q==", 113 + "@atcute/lexicons@1.2.6": { 114 + "integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==", 113 115 "dependencies": [ 116 + "@atcute/uint8array", 117 + "@atcute/util-text", 114 118 "@standard-schema/spec", 115 119 "esm-env" 116 120 ] ··· 132 136 "nanoid@5.1.6" 133 137 ] 134 138 }, 135 - "@atcute/tid@1.0.3": { 136 - "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 137 152 }, 138 153 "@atcute/uint8array@1.0.6": { 139 154 "integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==" ··· 142 157 "integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==", 143 158 "dependencies": [ 144 159 "@badrap/valita" 160 + ] 161 + }, 162 + "@atcute/util-text@0.0.1": { 163 + "integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==", 164 + "dependencies": [ 165 + "unicode-segmenter" 145 166 ] 146 167 }, 147 168 "@badrap/valita@0.4.6": { ··· 277 298 "os": ["win32"], 278 299 "cpu": ["x64"] 279 300 }, 280 - "@eslint-community/eslint-utils@4.9.0_eslint@9.39.2": { 281 - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", 301 + "@eslint-community/eslint-utils@4.9.1_eslint@9.39.2": { 302 + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", 282 303 "dependencies": [ 283 304 "eslint", 284 305 "eslint-visitor-keys@3.4.3" ··· 716 737 "vite" 717 738 ] 718 739 }, 740 + "@tutorlatin/svelte-tiny-virtual-list@3.0.18_svelte@5.46.1__acorn@8.15.0": { 741 + "integrity": "sha512-In7ASkVkLhg0ClWEVA50J/hWrGovwkw7dfHYlUyQJz4rPvh1TGpfo0lN1mXWERj2bkWY8AYITSbBZnVz34tcfA==", 742 + "dependencies": [ 743 + "svelte" 744 + ] 745 + }, 719 746 "@types/cookie@0.6.0": { 720 747 "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" 721 748 }, ··· 724 751 }, 725 752 "@types/json-schema@7.0.15": { 726 753 "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" 754 + }, 755 + "@types/node@22.19.3": { 756 + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", 757 + "dependencies": [ 758 + "undici-types@6.21.0" 759 + ] 727 760 }, 728 761 "@types/node@25.0.3": { 729 762 "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", 730 763 "dependencies": [ 731 - "undici-types" 764 + "undici-types@7.16.0" 732 765 ] 733 766 }, 734 - "@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": { 735 - "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==", 736 769 "dependencies": [ 737 770 "@eslint-community/regexpp", 738 771 "@typescript-eslint/parser", ··· 747 780 "typescript" 748 781 ] 749 782 }, 750 - "@typescript-eslint/parser@8.50.1_eslint@9.39.2_typescript@5.9.3": { 751 - "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==", 752 785 "dependencies": [ 753 786 "@typescript-eslint/scope-manager", 754 787 "@typescript-eslint/types", ··· 759 792 "typescript" 760 793 ] 761 794 }, 762 - "@typescript-eslint/project-service@8.50.1_typescript@5.9.3": { 763 - "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==", 764 797 "dependencies": [ 765 798 "@typescript-eslint/tsconfig-utils", 766 799 "@typescript-eslint/types", ··· 768 801 "typescript" 769 802 ] 770 803 }, 771 - "@typescript-eslint/scope-manager@8.50.1": { 772 - "integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==", 804 + "@typescript-eslint/scope-manager@8.51.0": { 805 + "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", 773 806 "dependencies": [ 774 807 "@typescript-eslint/types", 775 808 "@typescript-eslint/visitor-keys" 776 809 ] 777 810 }, 778 - "@typescript-eslint/tsconfig-utils@8.50.1_typescript@5.9.3": { 779 - "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==", 780 813 "dependencies": [ 781 814 "typescript" 782 815 ] 783 816 }, 784 - "@typescript-eslint/type-utils@8.50.1_eslint@9.39.2_typescript@5.9.3": { 785 - "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==", 786 819 "dependencies": [ 787 820 "@typescript-eslint/types", 788 821 "@typescript-eslint/typescript-estree", ··· 793 826 "typescript" 794 827 ] 795 828 }, 796 - "@typescript-eslint/types@8.50.1": { 797 - "integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==" 829 + "@typescript-eslint/types@8.51.0": { 830 + "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==" 798 831 }, 799 - "@typescript-eslint/typescript-estree@8.50.1_typescript@5.9.3": { 800 - "integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==", 832 + "@typescript-eslint/typescript-estree@8.51.0_typescript@5.9.3": { 833 + "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", 801 834 "dependencies": [ 802 835 "@typescript-eslint/project-service", 803 836 "@typescript-eslint/tsconfig-utils", ··· 811 844 "typescript" 812 845 ] 813 846 }, 814 - "@typescript-eslint/utils@8.50.1_eslint@9.39.2_typescript@5.9.3": { 815 - "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==", 816 849 "dependencies": [ 817 850 "@eslint-community/eslint-utils", 818 851 "@typescript-eslint/scope-manager", ··· 822 855 "typescript" 823 856 ] 824 857 }, 825 - "@typescript-eslint/visitor-keys@8.50.1": { 826 - "integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==", 858 + "@typescript-eslint/visitor-keys@8.51.0": { 859 + "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", 827 860 "dependencies": [ 828 861 "@typescript-eslint/types", 829 862 "eslint-visitor-keys@4.2.1" ··· 1093 1126 "eslint-visitor-keys@4.2.1" 1094 1127 ] 1095 1128 }, 1096 - "esquery@1.6.0": { 1097 - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", 1129 + "esquery@1.7.0": { 1130 + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", 1098 1131 "dependencies": [ 1099 1132 "estraverse" 1100 1133 ] ··· 1178 1211 "globals@16.5.0": { 1179 1212 "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==" 1180 1213 }, 1214 + "globals@17.0.0": { 1215 + "integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==" 1216 + }, 1181 1217 "graceful-fs@4.2.11": { 1182 1218 "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" 1183 1219 }, ··· 1403 1439 "natural-compare@1.4.0": { 1404 1440 "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" 1405 1441 }, 1442 + "node-gyp-build@4.8.4": { 1443 + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", 1444 + "bin": true 1445 + }, 1406 1446 "obliterator@2.0.5": { 1407 1447 "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==" 1408 1448 }, ··· 1446 1486 }, 1447 1487 "path-key@3.1.1": { 1448 1488 "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" 1489 + }, 1490 + "photoswipe@5.4.4": { 1491 + "integrity": "sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==" 1449 1492 }, 1450 1493 "picocolors@1.1.1": { 1451 1494 "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" ··· 1694 1737 "totalist@3.0.1": { 1695 1738 "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" 1696 1739 }, 1697 - "ts-api-utils@2.1.0_typescript@5.9.3": { 1698 - "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==", 1699 1742 "dependencies": [ 1700 1743 "typescript" 1701 1744 ] ··· 1712 1755 "type-fest@4.41.0": { 1713 1756 "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==" 1714 1757 }, 1715 - "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": { 1716 - "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==", 1717 1760 "dependencies": [ 1718 1761 "@typescript-eslint/eslint-plugin", 1719 1762 "@typescript-eslint/parser", ··· 1727 1770 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1728 1771 "bin": true 1729 1772 }, 1773 + "undici-types@6.21.0": { 1774 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" 1775 + }, 1730 1776 "undici-types@7.16.0": { 1731 1777 "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" 1778 + }, 1779 + "unicode-segmenter@0.14.5": { 1780 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" 1732 1781 }, 1733 1782 "uri-js@4.4.1": { 1734 1783 "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", ··· 1742 1791 "vite@7.3.0_@types+node@25.0.3_picomatch@4.0.3": { 1743 1792 "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", 1744 1793 "dependencies": [ 1745 - "@types/node", 1794 + "@types/node@25.0.3", 1746 1795 "esbuild", 1747 1796 "fdir", 1748 1797 "picomatch", ··· 1754 1803 "fsevents" 1755 1804 ], 1756 1805 "optionalPeers": [ 1757 - "@types/node" 1806 + "@types/node@25.0.3" 1758 1807 ], 1759 1808 "bin": true 1760 1809 }, ··· 1793 1842 "workspace": { 1794 1843 "packageJson": { 1795 1844 "dependencies": [ 1796 - "npm:@atcute/atproto@^3.1.9", 1845 + "npm:@atcute/atproto@^3.1.10", 1797 1846 "npm:@atcute/bluesky-richtext-builder@^2.0.4", 1798 1847 "npm:@atcute/bluesky-richtext-segmenter@^2.0.4", 1799 1848 "npm:@atcute/bluesky@^3.2.14", 1800 - "npm:@atcute/client@^4.1.1", 1849 + "npm:@atcute/client@^4.2.0", 1801 1850 "npm:@atcute/identity-resolver@^1.2.1", 1802 1851 "npm:@atcute/identity@^1.1.3", 1803 1852 "npm:@atcute/jetstream@^1.1.2", 1804 - "npm:@atcute/lexicons@^1.2.5", 1853 + "npm:@atcute/lexicons@^1.2.6", 1805 1854 "npm:@atcute/oauth-browser-client@^2.0.3", 1806 - "npm:@atcute/tid@^1.0.3", 1855 + "npm:@atcute/tid@^1.1.1", 1807 1856 "npm:@eslint/compat@2", 1808 1857 "npm:@eslint/js@^9.39.2", 1809 1858 "npm:@floating-ui/dom@^1.7.4", ··· 1814 1863 "npm:@sveltejs/vite-plugin-svelte@^6.2.1", 1815 1864 "npm:@tailwindcss/forms@~0.5.11", 1816 1865 "npm:@tailwindcss/vite@^4.1.18", 1866 + "npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.18", 1817 1867 "npm:@types/node@^25.0.3", 1818 1868 "npm:@wora/cache-persist@^2.2.1", 1819 1869 "npm:async-cache-dedupe@^3.4.0", 1820 1870 "npm:eslint-config-prettier@^10.1.8", 1821 1871 "npm:eslint-plugin-svelte@^3.13.1", 1822 1872 "npm:eslint@^9.39.2", 1823 - "npm:globals@^16.5.0", 1873 + "npm:globals@17", 1824 1874 "npm:hash-wasm@^4.12.0", 1825 1875 "npm:lru-cache@^11.2.4", 1876 + "npm:photoswipe@^5.4.4", 1826 1877 "npm:prettier-plugin-svelte@^3.4.1", 1827 1878 "npm:prettier-plugin-tailwindcss@~0.7.2", 1828 1879 "npm:prettier@^3.7.4", ··· 1833 1884 "npm:svelte-portal@^2.2.1", 1834 1885 "npm:svelte@^5.46.1", 1835 1886 "npm:tailwindcss@^4.1.18", 1836 - "npm:typescript-eslint@^8.50.1", 1887 + "npm:typescript-eslint@^8.51.0", 1837 1888 "npm:typescript@^5.9.3", 1838 1889 "npm:vite@^7.3.0" 1839 1890 ]
+2 -2
nix/modules.nix
··· 14 14 ]; 15 15 }; 16 16 17 - outputHash = "sha256-s5rq8htDjR0I8MxPtLq1NYIywXGEdYbZZvE7I5+TCIU="; 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 -6
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.18", 30 31 "@wora/cache-persist": "^2.2.1", 31 32 "async-cache-dedupe": "^3.4.0", 32 33 "hash-wasm": "^4.12.0", 33 34 "lru-cache": "^11.2.4", 35 + "photoswipe": "^5.4.4", 34 36 "svelte-device-info": "^1.0.6", 35 37 "svelte-infinite": "^0.5.1", 36 38 "svelte-portal": "^2.2.1" ··· 48 50 "eslint": "^9.39.2", 49 51 "eslint-config-prettier": "^10.1.8", 50 52 "eslint-plugin-svelte": "^3.13.1", 51 - "globals": "^16.5.0", 53 + "globals": "^17.0.0", 52 54 "prettier": "^3.7.4", 53 55 "prettier-plugin-svelte": "^3.4.1", 54 56 "prettier-plugin-tailwindcss": "^0.7.2", ··· 57 59 "svelte-check": "^4.3.5", 58 60 "tailwindcss": "^4.1.18", 59 61 "typescript": "^5.9.3", 60 - "typescript-eslint": "^8.50.1", 62 + "typescript-eslint": "^8.51.0", 61 63 "vite": "^7.3.0" 62 64 } 63 65 }
+4
src/app.css
··· 125 125 .animate-slide-in-left { 126 126 animation: slide-in-from-left 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; 127 127 } 128 + 129 + .post-dropdown { 130 + @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60; 131 + }
+2 -1
src/app.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="utf-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> 6 + <meta name="theme-color" content="#11001c" /> 6 7 %sveltekit.head% 7 8 </head> 8 9 <body data-sveltekit-preload-data="hover">
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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>
+114 -227
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'; 9 - import { flip } from 'svelte/animate'; 10 - import { cubicOut } from 'svelte/easing'; 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'; 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'; 11 14 12 15 interface Props { 13 - selectedDid: Did; 14 - selectedClient: AtpClient; 16 + client: AtpClient | undefined; 17 + followingSort: Sort; 15 18 } 16 19 17 - const { selectedDid, selectedClient }: Props = $props(); 20 + let { client, followingSort = $bindable('active') }: Props = $props(); 18 21 19 - type Sort = 'recent' | 'active' | 'conversational'; 20 - let followingSort: Sort = $state('active' as Sort); 22 + const selectedDid = $derived(client?.user?.did); 23 + const followsMap = $derived(selectedDid ? follows.get(selectedDid) : undefined); 21 24 22 - const interactionScores = $derived.by(() => { 23 - if (followingSort !== 'conversational') return null; 25 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 + let sortedFollowing = $state<{ did: Did; data: any }[]>([]); 24 27 25 - // eslint-disable-next-line svelte/prefer-svelte-reactivity 26 - const scores = new Map<ActorIdentifier, number>(); 27 - const now = currentTime.getTime(); 28 + let isLongCalculation = $state(false); 29 + let calculationTimer: ReturnType<typeof setTimeout> | undefined; 28 30 29 - // Interactions are full weight for the first 3 days, then start decreasing linearly 30 - // until 2 weeks, after which they decrease exponentially. 31 - // Keep the same overall exponential timescale as before (half-life ~30 days). 32 - const oneDay = 24 * 60 * 60 * 1000; 33 - const halfLifeMs = 30 * oneDay; 34 - const decayLambda = 0.693 / halfLifeMs; 35 - const threeDays = 3 * oneDay; 36 - 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()); 37 34 38 - const decay = (time: number) => { 39 - const age = Math.max(0, now - time); 35 + const updateList = async () => { 36 + // Reset timer and loading state at start 37 + if (calculationTimer) clearTimeout(calculationTimer); 38 + isLongCalculation = false; 40 39 41 - // Full weight for recent interactions within 3 days 42 - if (age <= threeDays) return 1; 40 + if (!followsMap || !selectedDid) { 41 + sortedFollowing = []; 42 + return; 43 + } 43 44 44 - // Between 3 days and 2 weeks, linearly interpolate down to the value 45 - // that the exponential would have at 2 weeks to keep continuity. 46 - if (age <= twoWeeks) { 47 - const expAtTwoWeeks = Math.exp(-decayLambda * twoWeeks); 48 - const t = (age - threeDays) / (twoWeeks - threeDays); // 0..1 49 - // linear ramp from 1 -> expAtTwoWeeks 50 - return 1 - t * (1 - expAtTwoWeeks); 51 - } 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)); 52 49 53 - // After 2 weeks, exponential decay based on the chosen lambda 54 - return Math.exp(-decayLambda * age); 55 - }; 50 + const interactionScores = 51 + followingSort === 'conversational' 52 + ? calculateInteractionScores( 53 + selectedDid, 54 + followsMap, 55 + allPosts, 56 + allBacklinks, 57 + replyIndex, 58 + staticNow 59 + ) 60 + : null; 56 61 57 - const replyWeight = 4; 58 - const repostWeight = 2; 59 - const likeWeight = 1; 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 + })); 60 72 61 - const myPosts = posts.get(selectedDid); 62 - if (myPosts) { 63 - for (const post of myPosts.values()) { 64 - if (post.record.reply) { 65 - const parentUri = post.record.reply.parent.uri; 66 - // only try to extract the DID 67 - const match = parentUri.match(/^at:\/\/([^/]+)/); 68 - if (match) { 69 - const targetDid = match[1] as Did; 70 - if (targetDid === selectedDid) continue; 71 - const s = scores.get(targetDid) || 0; 72 - scores.set( 73 - targetDid, 74 - s + replyWeight * decay(new Date(post.record.createdAt).getTime()) 75 - ); 76 - } 77 - } 78 - } 79 - } 73 + const following = userStatsList.filter((u) => u.data !== null); 74 + const sorted = [...following].sort((a, b) => sortFollowedUser(followingSort, a.data!, b.data!)); 80 75 81 - // interactions with others 82 - for (const [key, actions] of postActions) { 83 - const sepIndex = key.indexOf(':'); 84 - if (sepIndex === -1) continue; 85 - const did = key.slice(0, sepIndex) as Did; 86 - const uri = key.slice(sepIndex + 1) as ResourceUri; 76 + sortedFollowing = sorted; 87 77 88 - // only try to extract the DID 89 - const match = uri.match(/^at:\/\/([^/]+)/); 90 - if (!match) continue; 91 - const targetDid = match[1] as Did; 78 + // Clear timer and remove loading state immediately after done 79 + if (calculationTimer) clearTimeout(calculationTimer); 80 + isLongCalculation = false; 81 + }; 92 82 93 - if (did === targetDid) continue; 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(); 94 92 95 - let add = 0; 96 - if (actions.like) add += likeWeight; 97 - if (actions.repost) add += repostWeight; 93 + updateList(); 94 + }); 98 95 99 - if (add > 0) { 100 - const targetPosts = posts.get(targetDid); 101 - const post = targetPosts?.get(uri); 102 - if (post) { 103 - const time = new Date(post.record.createdAt).getTime(); 104 - add *= decay(time); 105 - } 106 - scores.set(targetDid, (scores.get(targetDid) || 0) + add); 107 - } 108 - } 96 + let listHeight = $state(0); 97 + let listContainer: HTMLDivElement | undefined = $state(); 109 98 110 - return scores; 111 - }); 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 + }; 112 107 113 - class FollowedUserStats { 114 - did: Did; 115 - profile: Promise<string | null | undefined>; 116 - handle: Promise<string>; 117 - 118 - constructor(did: Did) { 119 - this.did = did; 120 - this.profile = getClient(did as AtprotoDid) 121 - .then((client) => client.getProfile()) 122 - .then((profile) => { 123 - if (profile.ok) return profile.value.displayName; 124 - return null; 125 - }); 126 - this.handle = resolveDidDoc(did).then((doc) => { 127 - if (doc.ok) return doc.value.handle; 128 - return 'handle.invalid'; 129 - }); 108 + $effect(() => { 109 + if (listContainer) { 110 + calcHeight(); 111 + const observer = new ResizeObserver(calcHeight); 112 + observer.observe(document.body); 113 + return () => observer.disconnect(); 130 114 } 131 - 132 - data = $derived.by(() => { 133 - const postsMap = posts.get(this.did); 134 - if (!postsMap || postsMap.size === 0) return null; 135 - 136 - let lastPostAtTime = 0; 137 - let activeScore = 0; 138 - let recentPostCount = 0; 139 - const now = currentTime.getTime(); 140 - const quarterPosts = 6 * 60 * 60 * 1000; 141 - const gravity = 2.0; 142 - 143 - for (const post of postsMap.values()) { 144 - const t = new Date(post.record.createdAt).getTime(); 145 - if (t > lastPostAtTime) lastPostAtTime = t; 146 - const ageMs = Math.max(0, now - t); 147 - if (ageMs < quarterPosts) recentPostCount++; 148 - if (followingSort === 'active') { 149 - const ageHours = ageMs / (1000 * 60 * 60); 150 - // score = 1 / t^G 151 - activeScore += 1 / Math.pow(ageHours + 1, gravity); 152 - } 153 - } 154 - 155 - let conversationalScore = 0; 156 - if (followingSort === 'conversational' && interactionScores) 157 - conversationalScore = interactionScores.get(this.did) || 0; 158 - 159 - return { 160 - did: this.did, 161 - lastPostAt: new Date(lastPostAtTime), 162 - activeScore, 163 - conversationalScore, 164 - recentPostCount 165 - }; 166 - }); 167 - } 168 - 169 - const followsMap = $derived(follows.get(selectedDid)); 170 - 171 - const userStatsList = $derived( 172 - followsMap ? Array.from(followsMap.values()).map((f) => new FollowedUserStats(f.subject)) : [] 173 - ); 174 - 175 - const following = $derived(userStatsList.filter((u) => u.data !== null)); 176 - 177 - const sortedFollowing = $derived( 178 - [...following].sort((a, b) => { 179 - const statsA = a.data!; 180 - const statsB = b.data!; 181 - if (followingSort === 'conversational') { 182 - if (Math.abs(statsB.conversationalScore - statsA.conversationalScore) > 0.1) 183 - // sort based on conversational score 184 - return statsB.conversationalScore - statsA.conversationalScore; 185 - } else { 186 - if (followingSort === 'active') 187 - if (Math.abs(statsB.activeScore - statsA.activeScore) > 0.0001) 188 - // sort based on activity 189 - return statsB.activeScore - statsA.activeScore; 190 - } 191 - // use recent if scores are similar / we are using recent mode 192 - return statsB.lastPostAt.getTime() - statsA.lastPostAt.getTime(); 193 - }) 194 - ); 115 + }); 195 116 </script> 196 117 197 - {#snippet followingItems()} 198 - {#each sortedFollowing as user (user.did)} 199 - {@const stats = user.data!} 200 - {@const lastPostAt = stats.lastPostAt} 201 - {@const relTime = getRelativeTime(lastPostAt, currentTime)} 202 - {@const color = generateColorForDid(user.did)} 203 - <div animate:flip={{ duration: 350, easing: cubicOut }}> 204 - <div 205 - class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 206 - style={`--post-color: ${color};`} 207 - > 208 - <ProfilePicture client={selectedClient} did={user.did} size={10} /> 209 - <div class="min-w-0 flex-1 space-y-1"> 210 - <div 211 - class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)" 212 - style={`--post-color: ${color};`} 213 - > 214 - {#await Promise.all([user.profile, user.handle]) then [displayName, handle]} 215 - <span class="truncate">{displayName || handle}</span> 216 - <span class="truncate text-sm opacity-60">@{handle}</span> 217 - {/await} 218 - </div> 219 - <div class="flex gap-2 text-xs opacity-70"> 220 - <span 221 - class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 222 - ? 'text-(--nucleus-accent)' 223 - : ''} 224 - > 225 - posted {relTime} 226 - {relTime !== 'now' ? 'ago' : ''} 227 - </span> 228 - {#if stats.recentPostCount > 0} 229 - <span class="text-(--nucleus-accent2)"> 230 - {stats.recentPostCount} posts / 6h 231 - </span> 232 - {/if} 233 - {#if followingSort === 'conversational' && stats.conversationalScore > 0} 234 - <span class="ml-auto font-bold text-(--nucleus-accent)"> 235 - โ˜… {stats.conversationalScore.toFixed(1)} 236 - </span> 237 - {/if} 238 - </div> 239 - </div> 240 - </div> 241 - </div> 242 - {/each} 243 - {/snippet} 244 - 245 - <div class="p-2"> 246 - <div class="mb-4 flex flex-col justify-between gap-4 px-2 sm:flex-row sm:items-center"> 118 + <div class="flex h-full flex-col p-2"> 119 + <div class="mb-4 flex items-center justify-between gap-2 p-2 px-2 md:gap-4"> 247 120 <div> 248 - <h2 class="text-3xl font-bold">following</h2> 121 + <h2 class="text-2xl font-bold md:text-3xl">following</h2> 249 122 <div class="mt-2 flex gap-2"> 250 123 <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div> 251 124 <div class="h-1 w-11 rounded-full bg-(--nucleus-accent2)"></div> 252 125 </div> 253 126 </div> 254 - <div class="flex flex-wrap gap-2 text-sm"> 127 + <div class="flex gap-1 text-sm sm:gap-2"> 255 128 {#each ['recent', 'active', 'conversational'] as type (type)} 256 129 <button 257 130 class="rounded-sm px-2 py-1 transition-colors {followingSort === type ··· 265 138 </div> 266 139 </div> 267 140 268 - <div class="flex flex-col gap-2"> 269 - {#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} 270 145 <div class="flex justify-center py-8"> 271 146 <div 272 147 class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" 273 148 style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 274 149 ></div> 275 150 </div> 276 - {:else} 277 - {@render followingItems()} 151 + {:else if listHeight > 0} 152 + <VirtualList height={listHeight} itemCount={sortedFollowing.length} itemSize={76}> 153 + {#snippet item({ index, style }: { index: number; style: string })} 154 + {@const user = sortedFollowing[index]} 155 + <FollowingItem 156 + {style} 157 + did={user.did} 158 + stats={user.data!} 159 + {client} 160 + sort={followingSort} 161 + {currentTime} 162 + /> 163 + {/snippet} 164 + </VirtualList> 278 165 {/if} 279 166 </div> 280 167 </div>
+5
src/components/NotLoggedIn.svelte
··· 1 + <div class="flex justify-center py-4"> 2 + <p class="text-xl opacity-80"> 3 + <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 4 + </p> 5 + </div>
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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>
+14 -12
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> 156 162 157 163 <div 158 - use:portal={'#app-footer'} 164 + use:portal={'#footer-portal'} 159 165 class=" 160 - fixed bottom-[5dvh] z-20 w-full max-w-2xl p-4 pt-2 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)] 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
+234
src/components/TimelineView.svelte
··· 1 + <script lang="ts"> 2 + import BskyPost from './BskyPost.svelte'; 3 + import { type State as PostComposerState } from './PostComposer.svelte'; 4 + import { AtpClient } from '$lib/at/client.svelte'; 5 + import { accounts } from '$lib/accounts'; 6 + import { type ResourceUri } from '@atcute/lexicons'; 7 + import { SvelteSet } from 'svelte/reactivity'; 8 + import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 9 + import { 10 + postCursors, 11 + fetchTimeline, 12 + allPosts, 13 + timelines, 14 + fetchInteractionsToTimelineEnd 15 + } from '$lib/state.svelte'; 16 + import Icon from '@iconify/svelte'; 17 + import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 18 + import type { Did } from '@atcute/lexicons/syntax'; 19 + import NotLoggedIn from './NotLoggedIn.svelte'; 20 + 21 + interface Props { 22 + client?: AtpClient | null; 23 + targetDid?: Did; 24 + postComposerState: PostComposerState; 25 + class?: string; 26 + // whether to show replies that are not the user's own posts 27 + showReplies?: boolean; 28 + } 29 + 30 + let { 31 + client = null, 32 + targetDid = undefined, 33 + showReplies = true, 34 + postComposerState = $bindable(), 35 + class: className = '' 36 + }: Props = $props(); 37 + 38 + let reverseChronological = $state(true); 39 + let viewOwnPosts = $state(true); 40 + const expandedThreads = new SvelteSet<ResourceUri>(); 41 + 42 + const userDid = $derived(client?.user?.did); 43 + const did = $derived(targetDid ?? userDid); 44 + 45 + const threads = $derived( 46 + // todo: apply showReplies here 47 + filterThreads( 48 + did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [], 49 + $accounts, 50 + { viewOwnPosts } 51 + ) 52 + ); 53 + 54 + const loaderState = new LoaderState(); 55 + let scrollContainer = $state<HTMLDivElement>(); 56 + let loading = $state(false); 57 + let loadError = $state(''); 58 + 59 + const loadMore = async () => { 60 + if (loading || !client || !did) return; 61 + 62 + loading = true; 63 + loaderState.status = 'LOADING'; 64 + 65 + try { 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 + } 80 + loaderState.loaded(); 81 + } catch (error) { 82 + loadError = `${error}`; 83 + loaderState.error(); 84 + loading = false; 85 + return; 86 + } 87 + 88 + loading = false; 89 + const cursor = postCursors.get(did); 90 + if (cursor && cursor.end) loaderState.complete(); 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 + }); 121 + </script> 122 + 123 + {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 124 + <span 125 + class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 126 + > 127 + <span class="text-sm text-nowrap opacity-60">{reverse ? 'โ†ฑ' : 'โ†ณ'}</span> 128 + <BskyPost mini client={client!} {...post} /> 129 + </span> 130 + {/snippet} 131 + 132 + {#snippet threadsView()} 133 + {#each threads as thread, i (thread.rootUri)} 134 + <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> 135 + {#if thread.branchParentPost} 136 + {@render replyPost(thread.branchParentPost)} 137 + {/if} 138 + {#each thread.posts as post, idx (post.data.uri)} 139 + {@const mini = 140 + !expandedThreads.has(thread.rootUri) && 141 + thread.posts.length > 4 && 142 + idx > 0 && 143 + idx < thread.posts.length - 2} 144 + {#if !mini} 145 + <div class="mb-1.5"> 146 + <BskyPost 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 + }} 156 + {...post} 157 + /> 158 + </div> 159 + {:else if mini} 160 + {#if idx === 1} 161 + {@render replyPost(post, !reverseChronological)} 162 + <button 163 + class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,var(--nucleus-fg)_50%,var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)" 164 + onclick={() => expandedThreads.add(thread.rootUri)} 165 + > 166 + <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 167 + <Icon 168 + class="shrink-0" 169 + icon={reverseChronological 170 + ? 'heroicons:bars-arrow-up-solid' 171 + : 'heroicons:bars-arrow-down-solid'} 172 + width={32} 173 + /><span class="shrink-0 pb-1">view full chain</span> 174 + <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 175 + </button> 176 + {:else if idx === thread.posts.length - 3} 177 + {@render replyPost(post)} 178 + {/if} 179 + {/if} 180 + {/each} 181 + </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} 187 + {/each} 188 + {/snippet} 189 + 190 + <div 191 + class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 192 + bind:this={scrollContainer} 193 + > 194 + {#if targetDid || $accounts.length > 0} 195 + <InfiniteLoader 196 + {loaderState} 197 + triggerLoad={loadMore} 198 + loopDetectionTimeout={0} 199 + intersectionOptions={{ root: scrollContainer }} 200 + > 201 + {@render threadsView()} 202 + {#snippet noData()} 203 + <div class="flex justify-center py-4"> 204 + <p class="text-xl opacity-80"> 205 + all posts seen! <span class="text-2xl">:o</span> 206 + </p> 207 + </div> 208 + {/snippet} 209 + {#snippet loading()} 210 + <div class="flex justify-center"> 211 + <div 212 + class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" 213 + style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 214 + ></div> 215 + </div> 216 + {/snippet} 217 + {#snippet error()} 218 + <div class="flex flex-col gap-4 py-4"> 219 + <p class="text-xl opacity-80"> 220 + <span class="text-4xl">x_x</span> <br /> 221 + {loadError} 222 + </p> 223 + <div> 224 + <button class="flex action-button items-center gap-2" onclick={loadMore}> 225 + <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again 226 + </button> 227 + </div> 228 + </div> 229 + {/snippet} 230 + </InfiniteLoader> 231 + {:else} 232 + <NotLoggedIn /> 233 + {/if} 234 + </div>
+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
··· 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
··· 9 9 }); 10 10 export const BacklinksQuery = v.query('blue.microcosm.links.getBacklinks', { 11 11 params: v.object({ 12 - subject: v.resourceUriString(), 12 + subject: v.string(), 13 13 source: v.string(), 14 14 did: v.optional(v.array(v.didString())), 15 15 limit: v.optional(v.integer())
+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
··· 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
··· 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
··· 12 12 | AppBskyEmbedRecord.Main 13 13 | AppBskyEmbedRecordWithMedia.Main 14 14 | AppBskyEmbedVideo.Main; 15 + 16 + export type AppBskyEmbedMedia = 17 + | AppBskyEmbedImages.Main 18 + | AppBskyEmbedVideo.Main 19 + | AppBskyEmbedExternal.Main;
+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
··· 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
··· 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
··· 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
··· 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 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
··· 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 + }
+17 -4
src/lib/settings.ts
··· 25 25 }; 26 26 27 27 const createSettingsStore = () => { 28 - const stored = localStorage.getItem('settings'); 28 + // Prevent SSR crash if localStorage is missing 29 + const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('settings') : null; 29 30 30 31 const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings; 31 32 initial.endpoints = { ...defaultSettings.endpoints, ...initial.endpoints }; ··· 35 36 const { subscribe, set, update } = writable<Settings>(initial as Settings); 36 37 37 38 subscribe((settings) => { 39 + if (typeof document === 'undefined') return; 38 40 const theme = settings.theme; 39 41 document.documentElement.style.setProperty('--nucleus-bg', theme.bg); 40 42 document.documentElement.style.setProperty('--nucleus-fg', theme.fg); 41 43 document.documentElement.style.setProperty('--nucleus-accent', theme.accent); 42 44 document.documentElement.style.setProperty('--nucleus-accent2', theme.accent2); 45 + 46 + const oldMeta = document.querySelector('meta[name="theme-color"]'); 47 + if (oldMeta) oldMeta.remove(); 48 + 49 + const metaThemeColor = document.createElement('meta'); 50 + metaThemeColor.setAttribute('name', 'theme-color'); 51 + metaThemeColor.setAttribute('content', theme.bg); 52 + document.head.appendChild(metaThemeColor); 43 53 }); 44 54 45 55 return { 46 56 subscribe, 47 57 set: (value: Settings) => { 48 - localStorage.setItem('settings', JSON.stringify(value)); 58 + if (typeof localStorage !== 'undefined') 59 + localStorage.setItem('settings', JSON.stringify(value)); 49 60 set(value); 50 61 }, 51 62 update: (fn: (value: Settings) => Settings) => { 52 63 update((value) => { 53 64 const newValue = fn(value); 54 - localStorage.setItem('settings', JSON.stringify(newValue)); 65 + if (typeof localStorage !== 'undefined') 66 + localStorage.setItem('settings', JSON.stringify(newValue)); 55 67 return newValue; 56 68 }); 57 69 }, 58 70 reset: () => { 59 - localStorage.setItem('settings', JSON.stringify(defaultSettings)); 71 + if (typeof localStorage !== 'undefined') 72 + localStorage.setItem('settings', JSON.stringify(defaultSettings)); 60 73 set(defaultSettings); 61 74 } 62 75 };
+601 -79
src/lib/state.svelte.ts
··· 1 1 import { writable } from 'svelte/store'; 2 - import { AtpClient, newPublicClient, type NotificationsStream } from './at/client'; 3 - import { SvelteMap, SvelteDate } from 'svelte/reactivity'; 4 - import type { Did, InferOutput, ResourceUri } from '@atcute/lexicons'; 5 - import type { Backlink } from './at/constellation'; 6 - import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch'; 7 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 8 - import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; 9 - import type { ComAtprotoRepoListRecords } from '@atcute/atproto'; 2 + import { 3 + AtpClient, 4 + setRecordCache, 5 + type NotificationsStream, 6 + type NotificationsStreamEvent 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'; 11 + import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; 12 + import { 13 + AppBskyActorProfile, 14 + AppBskyFeedPost, 15 + AppBskyGraphBlock, 16 + type AppBskyGraphFollow 17 + } from '@atcute/bluesky'; 10 18 import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream'; 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'; 11 35 12 36 export const notificationStream = writable<NotificationsStream | null>(null); 13 37 export const jetstream = writable<JetstreamSubscription | null>(null); 14 38 15 - export type PostActions = { 16 - like: Backlink | null; 17 - repost: Backlink | null; 18 - // reply: Backlink | null; 19 - // 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 + } 20 74 }; 21 - export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>(); 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 + } 185 + }; 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 + }; 22 240 23 241 export const pulsingPostId = writable<string | null>(null); 24 242 25 243 export const viewClient = new AtpClient(); 26 - export const clients = new SvelteMap<AtprotoDid, AtpClient>(); 27 - export const getClient = async (did: AtprotoDid): Promise<AtpClient> => { 28 - if (!clients.has(did)) clients.set(did, await newPublicClient(did)); 29 - return clients.get(did)!; 30 - }; 244 + export const clients = new SvelteMap<Did, AtpClient>(); 31 245 32 246 export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>(); 33 247 ··· 35 249 did: Did, 36 250 followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]> 37 251 ) => { 38 - if (!follows.has(did)) { 39 - 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); 40 256 return; 41 257 } 42 - const map = follows.get(did)!; 43 258 for (const [uri, record] of followMap) map.set(uri, record); 44 259 }; 45 260 46 - export const fetchFollows = async (did: AtprotoDid) => { 47 - const client = await getClient(did); 48 - const res = await client.listRecordsAll('app.bsky.graph.follow'); 49 - 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 + } 50 270 addFollows( 51 - did, 271 + account.did, 52 272 res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main]) 53 273 ); 274 + return res.value.records.values().map((follow) => follow.value as AppBskyGraphFollow.Main); 54 275 }; 55 276 56 - export const fetchFollowPosts = async (did: AtprotoDid) => { 57 - const client = await getClient(did); 58 - 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); 59 282 if (!res.ok) return; 60 - 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))); 61 293 }; 62 294 63 - export const posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 64 - 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>>(); 297 + 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); 65 303 66 - export const addPostsRaw = ( 67 - did: Did, 68 - _posts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']> 69 - ) => { 70 - const postsWithUri = new SvelteMap( 71 - _posts.records.map((post) => [ 72 - post.uri, 73 - { cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri 74 - ]) 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 + ) 75 383 ); 76 - addPosts(did, postsWithUri); 77 - 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); 78 388 }; 79 389 80 - export const addPosts = (did: Did, _posts: Iterable<[ResourceUri, PostWithUri]>) => { 81 - if (!posts.has(did)) { 82 - posts.set(did, new SvelteMap(_posts)); 83 - return; 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); 396 + }; 397 + 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 + } 84 450 } 85 - const map = posts.get(did)!; 86 - for (const [uri, record] of _posts) map.set(uri, record); 451 + }; 452 + 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 }); 87 462 }; 88 463 89 - export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => { 90 - const client = await getClient(did); 464 + export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 465 + export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 91 466 92 - const cursor = cursors.get(did); 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); 93 499 if (cursor && cursor.end) return; 94 500 95 - const accPosts = await fetchPostsWithBacklinks(client, cursor?.value, limit); 96 - 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}`; 97 503 98 504 // if the cursor is undefined, we've reached the end of the timeline 99 - if (!accPosts.value.cursor) { 100 - cursors.set(did, { ...cursor, end: true }); 101 - 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))); 102 529 } 103 530 104 - cursors.set(did, { value: accPosts.value.cursor, end: false }); 105 - const hydrated = await hydratePosts(client, did, accPosts.value.posts); 106 - if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`; 531 + console.log(`${subject}: fetchTimeline`, accPosts.value.cursor); 532 + return newCursor; 533 + }; 534 + 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 + ); 546 + }; 107 547 108 - addPosts(did, hydrated.value); 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 + ]); 109 557 }; 110 558 111 - export const handleJetstreamEvent = (event: JetstreamEvent) => { 559 + export const handleJetstreamEvent = async (event: JetstreamEvent) => { 112 560 if (event.kind !== 'commit') return; 113 561 114 562 const { did, commit } = event; 115 - 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 + }; 116 594 117 - 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; 118 609 119 - if (commit.operation === 'create') { 120 - 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 + } 121 634 122 - const post: PostWithUri = { 123 - uri, 124 - cid, 125 - // assume record is valid, we trust the jetstream 126 - record: record as AppBskyFeedPost.Main 127 - }; 635 + // console.log(hydrated); 636 + addPosts(hydrated.value.values()); 637 + addTimeline(did, hydrated.value.keys()); 638 + }; 128 639 129 - addPosts(did, [[uri, post]]); 130 - } else if (commit.operation === 'delete') { 131 - if (posts.has(did)) { 132 - 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 133 647 } 648 + ]); 649 + }; 650 + 651 + export const handleNotification = async (event: NotificationsStreamEvent) => { 652 + if (event.type === 'message') { 653 + if (event.data.link.source.startsWith('app.bsky.feed.post')) handlePostNotification(event); 654 + else handleBacklink(event); 134 655 } 135 656 }; 136 657 137 658 export const currentTime = new SvelteDate(); 138 659 139 - if (typeof window !== 'undefined') { 660 + if (typeof window !== 'undefined') 140 661 setInterval(() => { 141 662 currentTime.setTime(Date.now()); 142 663 }, 1000); 143 - } 664 + 665 + export const router = new Router();
+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
··· 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 });
-548
src/routes/+page.svelte
··· 1 - <script lang="ts"> 2 - import BskyPost from '$components/BskyPost.svelte'; 3 - import PostComposer, { type State as PostComposerState } from '$components/PostComposer.svelte'; 4 - import AccountSelector from '$components/AccountSelector.svelte'; 5 - import SettingsView from '$components/SettingsView.svelte'; 6 - import NotificationsView from '$components/NotificationsView.svelte'; 7 - import FollowingView from '$components/FollowingView.svelte'; 8 - import { AtpClient, streamNotifications, type NotificationsStreamEvent } from '$lib/at/client'; 9 - import { accounts, type Account } from '$lib/accounts'; 10 - import { parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons'; 11 - import { onMount, tick } from 'svelte'; 12 - import { hydratePosts } from '$lib/at/fetch'; 13 - import { expect } from '$lib/result'; 14 - import { AppBskyFeedPost } from '@atcute/bluesky'; 15 - import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 16 - import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 17 - import { 18 - addPosts, 19 - clients, 20 - cursors, 21 - fetchFollowPosts, 22 - fetchFollows, 23 - fetchTimeline, 24 - follows, 25 - getClient, 26 - notificationStream, 27 - posts, 28 - viewClient, 29 - jetstream, 30 - handleJetstreamEvent 31 - } from '$lib/state.svelte'; 32 - import { get } from 'svelte/store'; 33 - import Icon from '@iconify/svelte'; 34 - import { sessions } from '$lib/at/oauth'; 35 - import type { AtprotoDid, Did } from '@atcute/lexicons/syntax'; 36 - import type { PageProps } from './+page'; 37 - import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 38 - import { JetstreamSubscription } from '@atcute/jetstream'; 39 - import { settings } from '$lib/settings'; 40 - 41 - const { data: loadData }: PageProps = $props(); 42 - 43 - // svelte-ignore state_referenced_locally 44 - let errors = $state(loadData.client.ok ? [] : [loadData.client.error]); 45 - let errorsOpen = $state(false); 46 - let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null); 47 - $effect(() => { 48 - if (selectedDid) localStorage.setItem('selectedDid', selectedDid); 49 - else localStorage.removeItem('selectedDid'); 50 - }); 51 - const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null); 52 - 53 - const loginAccount = async (account: Account) => { 54 - if (clients.has(account.did)) return; 55 - const client = new AtpClient(); 56 - const result = await client.login(await sessions.get(account.did)); 57 - if (!result.ok) { 58 - errors.push(`failed to login into @${account.handle ?? account.did}: ${result.error}`); 59 - return; 60 - } 61 - clients.set(account.did, client); 62 - }; 63 - const handleAccountSelected = async (did: AtprotoDid) => { 64 - selectedDid = did; 65 - const account = $accounts.find((acc) => acc.did === did); 66 - if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute)) 67 - await loginAccount(account); 68 - }; 69 - 70 - const handleLogout = async (did: AtprotoDid) => { 71 - await sessions.remove(did); 72 - const newAccounts = $accounts.filter((acc) => acc.did !== did); 73 - $accounts = newAccounts; 74 - clients.delete(did); 75 - posts.delete(did); 76 - cursors.delete(did); 77 - handleAccountSelected(newAccounts[0]?.did); 78 - }; 79 - 80 - type View = 'timeline' | 'notifications' | 'following' | 'settings'; 81 - let currentView = $state<View>('timeline'); 82 - let animClass = $state('animate-fade-in-scale'); 83 - let scrollPositions = new SvelteMap<View, number>(); 84 - 85 - const viewOrder: Record<View, number> = { 86 - timeline: 0, 87 - following: 1, 88 - notifications: 2, 89 - settings: 3 90 - }; 91 - 92 - const switchView = async (newView: View) => { 93 - if (currentView === newView) return; 94 - scrollPositions.set(currentView, window.scrollY); 95 - 96 - const direction = viewOrder[newView] > viewOrder[currentView] ? 'right' : 'left'; 97 - animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left'; 98 - currentView = newView; 99 - 100 - await tick(); 101 - 102 - window.scrollTo({ top: scrollPositions.get(newView) || 0, behavior: 'instant' }); 103 - }; 104 - let reverseChronological = $state(true); 105 - let viewOwnPosts = $state(true); 106 - 107 - const threads = $derived( 108 - filterThreads( 109 - buildThreads( 110 - $accounts.map((account) => account.did), 111 - posts 112 - ), 113 - $accounts, 114 - { viewOwnPosts } 115 - ) 116 - ); 117 - let postComposerState = $state<PostComposerState>({ type: 'null' }); 118 - 119 - const expandedThreads = new SvelteSet<ResourceUri>(); 120 - 121 - const fetchTimelines = (newAccounts: Account[]) => 122 - Promise.all(newAccounts.map((acc) => fetchTimeline(acc.did))); 123 - 124 - const handleNotification = async (event: NotificationsStreamEvent) => { 125 - if (event.type === 'message') { 126 - const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject)); 127 - const did = parsedSubjectUri.repo as AtprotoDid; 128 - const client = await getClient(did); 129 - const subjectPost = await client.getRecord( 130 - AppBskyFeedPost.mainSchema, 131 - did, 132 - parsedSubjectUri.rkey 133 - ); 134 - if (!subjectPost.ok) return; 135 - 136 - const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 137 - const hydrated = await hydratePosts(client, did, [ 138 - { 139 - record: subjectPost.value.record, 140 - uri: event.data.link.subject, 141 - cid: subjectPost.value.cid, 142 - replies: { 143 - cursor: null, 144 - total: 1, 145 - records: [ 146 - { 147 - did: parsedSourceUri.repo, 148 - collection: parsedSourceUri.collection, 149 - rkey: parsedSourceUri.rkey 150 - } 151 - ] 152 - } 153 - } 154 - ]); 155 - if (!hydrated.ok) { 156 - errors.push(`cant hydrate posts ${did}: ${hydrated.error}`); 157 - return; 158 - } 159 - 160 - // console.log(hydrated); 161 - addPosts(did, hydrated.value); 162 - } 163 - }; 164 - 165 - const loaderState = new LoaderState(); 166 - let scrollContainer = $state<HTMLDivElement>(); 167 - 168 - let loading = $state(false); 169 - let loadError = $state(''); 170 - let showScrollToTop = $state(false); 171 - 172 - const handleScroll = () => { 173 - if (currentView === 'timeline') showScrollToTop = window.scrollY > 300; 174 - }; 175 - const scrollToTop = () => { 176 - window.scrollTo({ top: 0, behavior: 'smooth' }); 177 - }; 178 - 179 - const loadMore = async () => { 180 - if (loading || $accounts.length === 0) return; 181 - 182 - loading = true; 183 - loaderState.status = 'LOADING'; 184 - 185 - try { 186 - await fetchTimelines($accounts); 187 - loaderState.loaded(); 188 - } catch (error) { 189 - loadError = `${error}`; 190 - loaderState.error(); 191 - loading = false; 192 - return; 193 - } 194 - 195 - loading = false; 196 - if (cursors.values().every((cursor) => cursor.end)) loaderState.complete(); 197 - }; 198 - 199 - onMount(() => { 200 - window.addEventListener('scroll', handleScroll); 201 - 202 - accounts.subscribe((newAccounts) => { 203 - get(notificationStream)?.stop(); 204 - // jetstream.set(null); 205 - if (newAccounts.length === 0) return; 206 - notificationStream.set( 207 - streamNotifications( 208 - newAccounts.map((account) => account.did), 209 - 'app.bsky.feed.post:reply.parent.uri', 210 - 'app.bsky.feed.post:embed.record.record.uri', 211 - 'app.bsky.feed.post:embed.record.uri' 212 - ) 213 - ); 214 - }); 215 - notificationStream.subscribe((stream) => { 216 - if (!stream) return; 217 - stream.listen(handleNotification); 218 - }); 219 - 220 - console.log(`creating jetstream subscription to ${$settings.endpoints.jetstream}`); 221 - const jetstreamSub = new JetstreamSubscription({ 222 - url: $settings.endpoints.jetstream, 223 - wantedCollections: ['app.bsky.feed.post'], 224 - wantedDids: ['did:web:guestbook.gaze.systems'] // initially contain sentinel 225 - }); 226 - jetstream.set(jetstreamSub); 227 - 228 - (async () => { 229 - console.log('polling for jetstream...'); 230 - for await (const event of jetstreamSub) handleJetstreamEvent(event); 231 - })(); 232 - 233 - if ($accounts.length > 0) { 234 - loaderState.status = 'LOADING'; 235 - if (loadData.client.ok && loadData.client.value) { 236 - const loggedInDid = loadData.client.value.user!.did as AtprotoDid; 237 - selectedDid = loggedInDid; 238 - clients.set(loggedInDid, loadData.client.value); 239 - } 240 - if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did; 241 - // console.log('onMount selectedDid', selectedDid); 242 - Promise.all($accounts.map(loginAccount)).then(() => { 243 - $accounts.forEach((account) => 244 - fetchFollows(account.did).then(() => 245 - follows 246 - .get(account.did) 247 - ?.forEach((follow) => fetchFollowPosts(follow.subject as AtprotoDid)) 248 - ) 249 - ); 250 - loadMore(); 251 - }); 252 - } else { 253 - selectedDid = null; 254 - } 255 - 256 - return () => window.removeEventListener('scroll', handleScroll); 257 - }); 258 - 259 - $effect(() => { 260 - const wantedDids: Did[] = ['did:web:guestbook.gaze.systems']; 261 - 262 - for (const followMap of follows.values()) 263 - for (const follow of followMap.values()) wantedDids.push(follow.subject); 264 - for (const account of $accounts) wantedDids.push(account.did); 265 - 266 - console.log('updating jetstream options:', wantedDids); 267 - $jetstream?.updateOptions({ wantedDids }); 268 - }); 269 - </script> 270 - 271 - {#snippet appButton( 272 - onClick: () => void, 273 - icon: string, 274 - ariaLabel: string, 275 - isActive: boolean, 276 - iconHover?: string 277 - )} 278 - <button 279 - onclick={onClick} 280 - class="group rounded-sm p-2 transition-all hover:scale-110 hover:shadow-lg 281 - {isActive 282 - ? 'bg-(--nucleus-accent)/25 text-(--nucleus-accent)' 283 - : 'bg-(--nucleus-accent)/10 text-(--nucleus-accent) hover:bg-(--nucleus-accent)/15'}" 284 - aria-label={ariaLabel} 285 - > 286 - <Icon class="group-hover:hidden" {icon} width={28} /> 287 - <Icon class="hidden group-hover:block" icon={iconHover ?? icon} width={28} /> 288 - </button> 289 - {/snippet} 290 - 291 - <div class="mx-auto flex min-h-dvh max-w-2xl flex-col"> 292 - <div class="flex-1"> 293 - <!-- timeline --> 294 - <div 295 - id="app-thread-list" 296 - class=" 297 - min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] 298 - {currentView === 'timeline' ? `${animClass}` : 'hidden'} 299 - " 300 - bind:this={scrollContainer} 301 - > 302 - {#if $accounts.length > 0} 303 - {@render renderThreads()} 304 - {:else} 305 - <div class="flex justify-center py-4"> 306 - <p class="text-xl opacity-80"> 307 - <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 308 - </p> 309 - </div> 310 - {/if} 311 - </div> 312 - {#if currentView === 'settings'} 313 - <div class={animClass}> 314 - <SettingsView /> 315 - </div> 316 - {/if} 317 - {#if currentView === 'notifications'} 318 - <div class={animClass}> 319 - <NotificationsView /> 320 - </div> 321 - {/if} 322 - {#if currentView === 'following'} 323 - <div class={animClass}> 324 - <FollowingView selectedClient={selectedClient!} selectedDid={selectedDid!} /> 325 - </div> 326 - {/if} 327 - </div> 328 - 329 - <!-- header / footer --> 330 - <div id="app-footer" class="sticky bottom-0 z-10 mt-4"> 331 - {#if errors.length > 0} 332 - <div class="relative m-3 mb-1 error-disclaimer"> 333 - <div class="flex items-center gap-2 text-red-500"> 334 - <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> 335 - there are ({errors.length}) errors 336 - <div class="grow"></div> 337 - <button onclick={() => (errorsOpen = !errorsOpen)} class="action-button p-1 px-1.5" 338 - >{errorsOpen ? 'hide details' : 'see details'}</button 339 - > 340 - </div> 341 - {#if errorsOpen} 342 - <div 343 - 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" 344 - > 345 - {#each errors as error, idx (idx)} 346 - <p>โ€ข {error}</p> 347 - {/each} 348 - </div> 349 - {/if} 350 - </div> 351 - {/if} 352 - 353 - <div 354 - class=" 355 - {currentView === 'timeline' ? '' : 'hidden'} 356 - fixed bottom-[5.5dvh] z-20 w-full max-w-2xl p-2.5 px-4 transition-all 357 - " 358 - > 359 - <!-- composer and error disclaimer (above thread list, not scrollable) --> 360 - <div class="footer-border-bg rounded-sm px-0.5 py-0.5"> 361 - <div class="footer-bg flex gap-2 rounded-sm p-1.5 shadow-2xl"> 362 - <AccountSelector 363 - client={viewClient} 364 - accounts={$accounts} 365 - bind:selectedDid 366 - onAccountSelected={handleAccountSelected} 367 - onLogout={handleLogout} 368 - /> 369 - 370 - {#if selectedClient} 371 - <div class="flex-1"> 372 - <PostComposer 373 - client={selectedClient} 374 - onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)} 375 - bind:_state={postComposerState} 376 - /> 377 - </div> 378 - {:else} 379 - <div 380 - 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" 381 - > 382 - <p class="text-sm opacity-80">select or add an account to post</p> 383 - </div> 384 - {/if} 385 - 386 - {#if postComposerState.type === 'null' && showScrollToTop} 387 - {@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top', false)} 388 - {/if} 389 - </div> 390 - </div> 391 - </div> 392 - 393 - <div class="footer-border-bg rounded-t-sm px-0.5 pt-0.5"> 394 - <div class="footer-bg rounded-t-sm"> 395 - <div class="flex items-center gap-1.5 px-2 py-1"> 396 - <div class="mb-2"> 397 - <h1 class="text-3xl font-bold tracking-tight">nucleus</h1> 398 - <div class="mt-1 flex gap-2"> 399 - <div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div> 400 - <div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div> 401 - </div> 402 - </div> 403 - <div class="grow"></div> 404 - {@render appButton( 405 - () => switchView('timeline'), 406 - 'heroicons:home', 407 - 'timeline', 408 - currentView === 'timeline', 409 - 'heroicons:home-solid' 410 - )} 411 - {@render appButton( 412 - () => switchView('following'), 413 - 'heroicons:users', 414 - 'following', 415 - currentView === 'following', 416 - 'heroicons:users-solid' 417 - )} 418 - {@render appButton( 419 - () => switchView('notifications'), 420 - 'heroicons:bell', 421 - 'notifications', 422 - currentView === 'notifications', 423 - 'heroicons:bell-solid' 424 - )} 425 - {@render appButton( 426 - () => switchView('settings'), 427 - 'heroicons:cog-6-tooth', 428 - 'settings', 429 - currentView === 'settings', 430 - 'heroicons:cog-6-tooth-solid' 431 - )} 432 - </div> 433 - </div> 434 - </div> 435 - </div> 436 - </div> 437 - 438 - {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 439 - <span 440 - class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 441 - > 442 - <span class="text-sm text-nowrap opacity-60">{reverse ? 'โ†ฑ' : 'โ†ณ'}</span> 443 - <BskyPost mini client={selectedClient ?? viewClient} {...post} /> 444 - </span> 445 - {/snippet} 446 - 447 - {#snippet threadsView()} 448 - {#each threads as thread (thread.rootUri)} 449 - <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> 450 - {#if thread.branchParentPost} 451 - {@render replyPost(thread.branchParentPost)} 452 - {/if} 453 - {#each thread.posts as post, idx (post.data.uri)} 454 - {@const mini = 455 - !expandedThreads.has(thread.rootUri) && 456 - thread.posts.length > 4 && 457 - idx > 0 && 458 - idx < thread.posts.length - 2} 459 - {#if !mini} 460 - <div class="mb-1.5"> 461 - <BskyPost 462 - client={selectedClient ?? viewClient} 463 - onQuote={(post) => (postComposerState = { type: 'focused', quoting: post })} 464 - onReply={(post) => (postComposerState = { type: 'focused', replying: post })} 465 - {...post} 466 - /> 467 - </div> 468 - {:else if mini} 469 - {#if idx === 1} 470 - {@render replyPost(post, !reverseChronological)} 471 - <button 472 - class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,var(--nucleus-fg)_50%,var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)" 473 - onclick={() => expandedThreads.add(thread.rootUri)} 474 - > 475 - <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 476 - <Icon 477 - class="shrink-0" 478 - icon={reverseChronological 479 - ? 'heroicons:bars-arrow-up-solid' 480 - : 'heroicons:bars-arrow-down-solid'} 481 - width={32} 482 - /><span class="shrink-0 pb-1">view full chain</span> 483 - <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 484 - </button> 485 - {:else if idx === thread.posts.length - 3} 486 - {@render replyPost(post)} 487 - {/if} 488 - {/if} 489 - {/each} 490 - </div> 491 - <div 492 - class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 493 - ></div> 494 - {/each} 495 - {/snippet} 496 - 497 - {#snippet renderThreads()} 498 - <InfiniteLoader 499 - {loaderState} 500 - triggerLoad={loadMore} 501 - loopDetectionTimeout={0} 502 - intersectionOptions={{ root: scrollContainer }} 503 - > 504 - {@render threadsView()} 505 - {#snippet noData()} 506 - <div class="flex justify-center py-4"> 507 - <p class="text-xl opacity-80"> 508 - all posts seen! <span class="text-2xl">:o</span> 509 - </p> 510 - </div> 511 - {/snippet} 512 - {#snippet loading()} 513 - <div class="flex justify-center"> 514 - <div 515 - class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" 516 - style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 517 - ></div> 518 - </div> 519 - {/snippet} 520 - {#snippet error()} 521 - <div class="flex flex-col gap-4 py-4"> 522 - <p class="text-xl opacity-80"> 523 - <span class="text-4xl">x_x</span> <br /> 524 - {loadError} 525 - </p> 526 - <div> 527 - <button class="flex action-button items-center gap-2" onclick={loadMore}> 528 - <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again 529 - </button> 530 - </div> 531 - </div> 532 - {/snippet} 533 - </InfiniteLoader> 534 - {/snippet} 535 - 536 - <style> 537 - .footer-bg { 538 - background: linear-gradient( 539 - to right, 540 - color-mix(in srgb, var(--nucleus-accent) 18%, var(--nucleus-bg)), 541 - color-mix(in srgb, var(--nucleus-accent2) 13%, var(--nucleus-bg)) 542 - ); 543 - } 544 - 545 - .footer-border-bg { 546 - background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2)); 547 - } 548 - </style>
-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
··· 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
··· 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
··· 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/*',