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 + }
+180 -52
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 + "npm:@atcute/bluesky-richtext-builder@^2.0.4": "2.0.4", 6 + "npm:@atcute/bluesky-richtext-segmenter@^2.0.4": "2.0.4", 5 7 "npm:@atcute/bluesky@^3.2.14": "3.2.14", 6 - "npm:@atcute/client@^4.1.1": "4.1.1", 8 + "npm:@atcute/client@^4.2.0": "4.2.0", 7 9 "npm:@atcute/identity-resolver@^1.2.1": "1.2.1_@atcute+identity@1.1.3", 8 10 "npm:@atcute/identity@^1.1.3": "1.1.3", 9 - "npm:@atcute/lexicons@^1.2.5": "1.2.5", 11 + "npm:@atcute/jetstream@^1.1.2": "1.1.2", 12 + "npm:@atcute/lexicons@^1.2.6": "1.2.6", 10 13 "npm:@atcute/oauth-browser-client@^2.0.3": "2.0.3_@atcute+identity@1.1.3", 11 - "npm:@atcute/tid@^1.0.3": "1.0.3", 14 + "npm:@atcute/tid@^1.1.1": "1.1.1", 12 15 "npm:@eslint/compat@2": "2.0.0_eslint@9.39.2", 13 16 "npm:@eslint/js@^9.39.2": "9.39.2", 14 17 "npm:@floating-ui/dom@^1.7.4": "1.7.4", ··· 19 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", 20 23 "npm:@tailwindcss/forms@~0.5.11": "0.5.11_tailwindcss@4.1.18", 21 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", 22 26 "npm:@types/node@^25.0.3": "25.0.3", 23 27 "npm:@wora/cache-persist@^2.2.1": "2.2.1", 28 + "npm:async-cache-dedupe@^3.4.0": "3.4.0", 24 29 "npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.39.2", 25 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", 26 31 "npm:eslint@^9.39.2": "9.39.2", 27 - "npm:globals@^16.5.0": "16.5.0", 32 + "npm:globals@17": "17.0.0", 28 33 "npm:hash-wasm@^4.12.0": "4.12.0", 29 34 "npm:lru-cache@^11.2.4": "11.2.4", 35 + "npm:photoswipe@^5.4.4": "5.4.4", 30 36 "npm:prettier-plugin-svelte@^3.4.1": "3.4.1_prettier@3.7.4_svelte@5.46.1__acorn@8.15.0", 31 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", 32 38 "npm:prettier@^3.7.4": "3.7.4", ··· 37 43 "npm:svelte-portal@^2.2.1": "2.2.1", 38 44 "npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0", 39 45 "npm:tailwindcss@^4.1.18": "4.1.18", 40 - "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", 41 47 "npm:typescript@^5.9.3": "5.9.3", 42 48 "npm:vite@^7.3.0": "7.3.0_@types+node@25.0.3_picomatch@4.0.3" 43 49 }, 44 50 "npm": { 45 - "@atcute/atproto@3.1.9": { 46 - "integrity": "sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==", 51 + "@atcute/atproto@3.1.10": { 52 + "integrity": "sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==", 47 53 "dependencies": [ 48 54 "@atcute/lexicons" 49 55 ] 50 56 }, 57 + "@atcute/bluesky-richtext-builder@2.0.4": { 58 + "integrity": "sha512-ydA9VWBPsBE/gbu1vYbmh7AZ8FLfxp+LE4eH5GgOTCOxwhs7Mgy1oHrHY+Er6gu6PfdoUoGso0uI3Wl3ZF/Mxg==", 59 + "dependencies": [ 60 + "@atcute/bluesky", 61 + "@atcute/lexicons" 62 + ] 63 + }, 64 + "@atcute/bluesky-richtext-segmenter@2.0.4": { 65 + "integrity": "sha512-6m5QEAv4lU3qTy5MeJXJRRG33acipYJnMW1T7W/KrMyThGhQ7jSTTh8Z48quElgivgX7MDj6o/ow1oLUsjsCKw==", 66 + "dependencies": [ 67 + "@atcute/bluesky", 68 + "@atcute/lexicons" 69 + ] 70 + }, 51 71 "@atcute/bluesky@3.2.14": { 52 72 "integrity": "sha512-XlVuF55AYIyplmKvlGLlj+cUvk9ggxNRPczkTPIY991xJ4qDxDHpBJ39ekAV4dWcuBoRo2o9JynzpafPu2ljDA==", 53 73 "dependencies": [ ··· 55 75 "@atcute/lexicons" 56 76 ] 57 77 }, 58 - "@atcute/client@4.1.1": { 59 - "integrity": "sha512-FROCbTTCeL5u4tO/n72jDEKyKqjdlXMB56Ehve3W/gnnLGCYWvN42sS7tvL1Mgu6sbO3yZwsXKDrmM2No4XpjA==", 78 + "@atcute/client@4.2.0": { 79 + "integrity": "sha512-vYixpXevM+dkzN4HGTfmxCJBOXNSRAMki1bfi1atFdmZGo9n1zStyClHqn0SRs8I5nOQoVG1XBkYAGSFJWqy2Q==", 60 80 "dependencies": [ 61 81 "@atcute/identity", 62 82 "@atcute/lexicons" ··· 78 98 "@badrap/valita" 79 99 ] 80 100 }, 81 - "@atcute/lexicons@1.2.5": { 82 - "integrity": "sha512-9yO9WdgxW8jZ7SbzUycH710z+JmsQ9W9n5S6i6eghYju32kkluFmgBeS47r8e8p2+Dv4DemS7o/3SUGsX9FR5Q==", 101 + "@atcute/jetstream@1.1.2": { 102 + "integrity": "sha512-u6p/h2xppp7LE6W/9xErAJ6frfN60s8adZuCKtfAaaBBiiYbb1CfpzN8Uc+2qtJZNorqGvuuDb5572Jmh7yHBQ==", 103 + "dependencies": [ 104 + "@atcute/lexicons", 105 + "@badrap/valita", 106 + "@mary-ext/event-iterator", 107 + "@mary-ext/simple-event-emitter", 108 + "partysocket", 109 + "type-fest", 110 + "yocto-queue@1.2.2" 111 + ] 112 + }, 113 + "@atcute/lexicons@1.2.6": { 114 + "integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==", 83 115 "dependencies": [ 116 + "@atcute/uint8array", 117 + "@atcute/util-text", 84 118 "@standard-schema/spec", 85 119 "esm-env" 86 120 ] ··· 102 136 "nanoid@5.1.6" 103 137 ] 104 138 }, 105 - "@atcute/tid@1.0.3": { 106 - "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 107 152 }, 108 153 "@atcute/uint8array@1.0.6": { 109 154 "integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==" ··· 114 159 "@badrap/valita" 115 160 ] 116 161 }, 162 + "@atcute/util-text@0.0.1": { 163 + "integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==", 164 + "dependencies": [ 165 + "unicode-segmenter" 166 + ] 167 + }, 117 168 "@badrap/valita@0.4.6": { 118 169 "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==" 119 170 }, ··· 247 298 "os": ["win32"], 248 299 "cpu": ["x64"] 249 300 }, 250 - "@eslint-community/eslint-utils@4.9.0_eslint@9.39.2": { 251 - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", 301 + "@eslint-community/eslint-utils@4.9.1_eslint@9.39.2": { 302 + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", 252 303 "dependencies": [ 253 304 "eslint", 254 305 "eslint-visitor-keys@3.4.3" ··· 389 440 "@jridgewell/sourcemap-codec" 390 441 ] 391 442 }, 443 + "@mary-ext/event-iterator@1.0.0": { 444 + "integrity": "sha512-l6gCPsWJ8aRCe/s7/oCmero70kDHgIK5m4uJvYgwEYTqVxoBOIXbKr5tnkLqUHEg6mNduB4IWvms3h70Hp9ADQ==", 445 + "dependencies": [ 446 + "yocto-queue@1.2.2" 447 + ] 448 + }, 449 + "@mary-ext/simple-event-emitter@1.0.0": { 450 + "integrity": "sha512-meA/zJZKIN1RVBNEYIbjufkUrW7/tRjHH60FjolpG1ixJKo76TB208qefQLNdOVDA7uIG0CGEDuhmMirtHKLAg==" 451 + }, 392 452 "@polka/url@1.0.0-next.29": { 393 453 "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==" 394 454 }, ··· 677 737 "vite" 678 738 ] 679 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 + }, 680 746 "@types/cookie@0.6.0": { 681 747 "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" 682 748 }, ··· 685 751 }, 686 752 "@types/json-schema@7.0.15": { 687 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 + ] 688 760 }, 689 761 "@types/node@25.0.3": { 690 762 "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", 691 763 "dependencies": [ 692 - "undici-types" 764 + "undici-types@7.16.0" 693 765 ] 694 766 }, 695 - "@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": { 696 - "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==", 697 769 "dependencies": [ 698 770 "@eslint-community/regexpp", 699 771 "@typescript-eslint/parser", ··· 708 780 "typescript" 709 781 ] 710 782 }, 711 - "@typescript-eslint/parser@8.50.1_eslint@9.39.2_typescript@5.9.3": { 712 - "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==", 713 785 "dependencies": [ 714 786 "@typescript-eslint/scope-manager", 715 787 "@typescript-eslint/types", ··· 720 792 "typescript" 721 793 ] 722 794 }, 723 - "@typescript-eslint/project-service@8.50.1_typescript@5.9.3": { 724 - "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==", 725 797 "dependencies": [ 726 798 "@typescript-eslint/tsconfig-utils", 727 799 "@typescript-eslint/types", ··· 729 801 "typescript" 730 802 ] 731 803 }, 732 - "@typescript-eslint/scope-manager@8.50.1": { 733 - "integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==", 804 + "@typescript-eslint/scope-manager@8.51.0": { 805 + "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", 734 806 "dependencies": [ 735 807 "@typescript-eslint/types", 736 808 "@typescript-eslint/visitor-keys" 737 809 ] 738 810 }, 739 - "@typescript-eslint/tsconfig-utils@8.50.1_typescript@5.9.3": { 740 - "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==", 741 813 "dependencies": [ 742 814 "typescript" 743 815 ] 744 816 }, 745 - "@typescript-eslint/type-utils@8.50.1_eslint@9.39.2_typescript@5.9.3": { 746 - "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==", 747 819 "dependencies": [ 748 820 "@typescript-eslint/types", 749 821 "@typescript-eslint/typescript-estree", ··· 754 826 "typescript" 755 827 ] 756 828 }, 757 - "@typescript-eslint/types@8.50.1": { 758 - "integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==" 829 + "@typescript-eslint/types@8.51.0": { 830 + "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==" 759 831 }, 760 - "@typescript-eslint/typescript-estree@8.50.1_typescript@5.9.3": { 761 - "integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==", 832 + "@typescript-eslint/typescript-estree@8.51.0_typescript@5.9.3": { 833 + "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", 762 834 "dependencies": [ 763 835 "@typescript-eslint/project-service", 764 836 "@typescript-eslint/tsconfig-utils", ··· 772 844 "typescript" 773 845 ] 774 846 }, 775 - "@typescript-eslint/utils@8.50.1_eslint@9.39.2_typescript@5.9.3": { 776 - "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==", 777 849 "dependencies": [ 778 850 "@eslint-community/eslint-utils", 779 851 "@typescript-eslint/scope-manager", ··· 783 855 "typescript" 784 856 ] 785 857 }, 786 - "@typescript-eslint/visitor-keys@8.50.1": { 787 - "integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==", 858 + "@typescript-eslint/visitor-keys@8.51.0": { 859 + "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", 788 860 "dependencies": [ 789 861 "@typescript-eslint/types", 790 862 "eslint-visitor-keys@4.2.1" ··· 826 898 }, 827 899 "aria-query@5.3.2": { 828 900 "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==" 901 + }, 902 + "async-cache-dedupe@3.4.0": { 903 + "integrity": "sha512-RkQr21CpltqMpbYpRaEAmF1BdUO5jnnS/scZkectmLiuWQ81w8u4lYraipbQf8zQ0yYvb3U0N1ozNAYmI4jQ3g==", 904 + "dependencies": [ 905 + "mnemonist", 906 + "safe-stable-stringify" 907 + ] 829 908 }, 830 909 "axobject-query@4.1.0": { 831 910 "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" ··· 1047 1126 "eslint-visitor-keys@4.2.1" 1048 1127 ] 1049 1128 }, 1050 - "esquery@1.6.0": { 1051 - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", 1129 + "esquery@1.7.0": { 1130 + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", 1052 1131 "dependencies": [ 1053 1132 "estraverse" 1054 1133 ] ··· 1070 1149 }, 1071 1150 "esutils@2.0.3": { 1072 1151 "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" 1152 + }, 1153 + "event-target-polyfill@0.0.4": { 1154 + "integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==" 1073 1155 }, 1074 1156 "fast-deep-equal@3.1.3": { 1075 1157 "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" ··· 1129 1211 "globals@16.5.0": { 1130 1212 "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==" 1131 1213 }, 1214 + "globals@17.0.0": { 1215 + "integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==" 1216 + }, 1132 1217 "graceful-fs@4.2.11": { 1133 1218 "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" 1134 1219 }, ··· 1328 1413 "brace-expansion@2.0.2" 1329 1414 ] 1330 1415 }, 1416 + "mnemonist@0.40.3": { 1417 + "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", 1418 + "dependencies": [ 1419 + "obliterator" 1420 + ] 1421 + }, 1331 1422 "mri@1.2.0": { 1332 1423 "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==" 1333 1424 }, ··· 1348 1439 "natural-compare@1.4.0": { 1349 1440 "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" 1350 1441 }, 1442 + "node-gyp-build@4.8.4": { 1443 + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", 1444 + "bin": true 1445 + }, 1446 + "obliterator@2.0.5": { 1447 + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==" 1448 + }, 1351 1449 "optionator@0.9.4": { 1352 1450 "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", 1353 1451 "dependencies": [ ··· 1362 1460 "p-limit@3.1.0": { 1363 1461 "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", 1364 1462 "dependencies": [ 1365 - "yocto-queue" 1463 + "yocto-queue@0.1.0" 1366 1464 ] 1367 1465 }, 1368 1466 "p-locate@5.0.0": { ··· 1377 1475 "callsites" 1378 1476 ] 1379 1477 }, 1478 + "partysocket@1.1.10": { 1479 + "integrity": "sha512-ACfn0P6lQuj8/AqB4L5ZDFcIEbpnIteNNObrlxqV1Ge80GTGhjuJ2sNKwNQlFzhGi4kI7fP/C1Eqh8TR78HjDQ==", 1480 + "dependencies": [ 1481 + "event-target-polyfill" 1482 + ] 1483 + }, 1380 1484 "path-exists@4.0.0": { 1381 1485 "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" 1382 1486 }, 1383 1487 "path-key@3.1.1": { 1384 1488 "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" 1489 + }, 1490 + "photoswipe@5.4.4": { 1491 + "integrity": "sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==" 1385 1492 }, 1386 1493 "picocolors@1.1.1": { 1387 1494 "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" ··· 1498 1605 "mri" 1499 1606 ] 1500 1607 }, 1608 + "safe-stable-stringify@2.5.0": { 1609 + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==" 1610 + }, 1501 1611 "semver@7.7.3": { 1502 1612 "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", 1503 1613 "bin": true ··· 1627 1737 "totalist@3.0.1": { 1628 1738 "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" 1629 1739 }, 1630 - "ts-api-utils@2.1.0_typescript@5.9.3": { 1631 - "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==", 1632 1742 "dependencies": [ 1633 1743 "typescript" 1634 1744 ] ··· 1642 1752 "prelude-ls" 1643 1753 ] 1644 1754 }, 1645 - "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": { 1646 - "integrity": "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==", 1755 + "type-fest@4.41.0": { 1756 + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==" 1757 + }, 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==", 1647 1760 "dependencies": [ 1648 1761 "@typescript-eslint/eslint-plugin", 1649 1762 "@typescript-eslint/parser", ··· 1657 1770 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1658 1771 "bin": true 1659 1772 }, 1773 + "undici-types@6.21.0": { 1774 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" 1775 + }, 1660 1776 "undici-types@7.16.0": { 1661 1777 "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" 1778 + }, 1779 + "unicode-segmenter@0.14.5": { 1780 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" 1662 1781 }, 1663 1782 "uri-js@4.4.1": { 1664 1783 "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", ··· 1672 1791 "vite@7.3.0_@types+node@25.0.3_picomatch@4.0.3": { 1673 1792 "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", 1674 1793 "dependencies": [ 1675 - "@types/node", 1794 + "@types/node@25.0.3", 1676 1795 "esbuild", 1677 1796 "fdir", 1678 1797 "picomatch", ··· 1684 1803 "fsevents" 1685 1804 ], 1686 1805 "optionalPeers": [ 1687 - "@types/node" 1806 + "@types/node@25.0.3" 1688 1807 ], 1689 1808 "bin": true 1690 1809 }, ··· 1713 1832 "yocto-queue@0.1.0": { 1714 1833 "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" 1715 1834 }, 1835 + "yocto-queue@1.2.2": { 1836 + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==" 1837 + }, 1716 1838 "zimmerframe@1.1.4": { 1717 1839 "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" 1718 1840 } ··· 1720 1842 "workspace": { 1721 1843 "packageJson": { 1722 1844 "dependencies": [ 1723 - "npm:@atcute/atproto@^3.1.9", 1845 + "npm:@atcute/atproto@^3.1.10", 1846 + "npm:@atcute/bluesky-richtext-builder@^2.0.4", 1847 + "npm:@atcute/bluesky-richtext-segmenter@^2.0.4", 1724 1848 "npm:@atcute/bluesky@^3.2.14", 1725 - "npm:@atcute/client@^4.1.1", 1849 + "npm:@atcute/client@^4.2.0", 1726 1850 "npm:@atcute/identity-resolver@^1.2.1", 1727 1851 "npm:@atcute/identity@^1.1.3", 1728 - "npm:@atcute/lexicons@^1.2.5", 1852 + "npm:@atcute/jetstream@^1.1.2", 1853 + "npm:@atcute/lexicons@^1.2.6", 1729 1854 "npm:@atcute/oauth-browser-client@^2.0.3", 1730 - "npm:@atcute/tid@^1.0.3", 1855 + "npm:@atcute/tid@^1.1.1", 1731 1856 "npm:@eslint/compat@2", 1732 1857 "npm:@eslint/js@^9.39.2", 1733 1858 "npm:@floating-ui/dom@^1.7.4", ··· 1738 1863 "npm:@sveltejs/vite-plugin-svelte@^6.2.1", 1739 1864 "npm:@tailwindcss/forms@~0.5.11", 1740 1865 "npm:@tailwindcss/vite@^4.1.18", 1866 + "npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.18", 1741 1867 "npm:@types/node@^25.0.3", 1742 1868 "npm:@wora/cache-persist@^2.2.1", 1869 + "npm:async-cache-dedupe@^3.4.0", 1743 1870 "npm:eslint-config-prettier@^10.1.8", 1744 1871 "npm:eslint-plugin-svelte@^3.13.1", 1745 1872 "npm:eslint@^9.39.2", 1746 - "npm:globals@^16.5.0", 1873 + "npm:globals@17", 1747 1874 "npm:hash-wasm@^4.12.0", 1748 1875 "npm:lru-cache@^11.2.4", 1876 + "npm:photoswipe@^5.4.4", 1749 1877 "npm:prettier-plugin-svelte@^3.4.1", 1750 1878 "npm:prettier-plugin-tailwindcss@~0.7.2", 1751 1879 "npm:prettier@^3.7.4", ··· 1756 1884 "npm:svelte-portal@^2.2.1", 1757 1885 "npm:svelte@^5.46.1", 1758 1886 "npm:tailwindcss@^4.1.18", 1759 - "npm:typescript-eslint@^8.50.1", 1887 + "npm:typescript-eslint@^8.51.0", 1760 1888 "npm:typescript@^5.9.3", 1761 1889 "npm:vite@^7.3.0" 1762 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;
+12 -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 - "@atcute/client": "^4.1.1", 19 + "@atcute/bluesky-richtext-builder": "^2.0.4", 20 + "@atcute/bluesky-richtext-segmenter": "^2.0.4", 21 + "@atcute/client": "^4.2.0", 20 22 "@atcute/identity": "^1.1.3", 21 23 "@atcute/identity-resolver": "^1.2.1", 22 - "@atcute/lexicons": "^1.2.5", 24 + "@atcute/jetstream": "^1.1.2", 25 + "@atcute/lexicons": "^1.2.6", 23 26 "@atcute/oauth-browser-client": "^2.0.3", 24 - "@atcute/tid": "^1.0.3", 27 + "@atcute/tid": "^1.1.1", 25 28 "@floating-ui/dom": "^1.7.4", 26 29 "@soffinal/websocket": "^0.2.1", 30 + "@tutorlatin/svelte-tiny-virtual-list": "^3.0.18", 27 31 "@wora/cache-persist": "^2.2.1", 32 + "async-cache-dedupe": "^3.4.0", 28 33 "hash-wasm": "^4.12.0", 29 34 "lru-cache": "^11.2.4", 35 + "photoswipe": "^5.4.4", 30 36 "svelte-device-info": "^1.0.6", 31 37 "svelte-infinite": "^0.5.1", 32 38 "svelte-portal": "^2.2.1" ··· 44 50 "eslint": "^9.39.2", 45 51 "eslint-config-prettier": "^10.1.8", 46 52 "eslint-plugin-svelte": "^3.13.1", 47 - "globals": "^16.5.0", 53 + "globals": "^17.0.0", 48 54 "prettier": "^3.7.4", 49 55 "prettier-plugin-svelte": "^3.4.1", 50 56 "prettier-plugin-tailwindcss": "^0.7.2", ··· 53 59 "svelte-check": "^4.3.5", 54 60 "tailwindcss": "^4.1.18", 55 61 "typescript": "^5.9.3", 56 - "typescript-eslint": "^8.50.1", 62 + "typescript-eslint": "^8.51.0", 57 63 "vite": "^7.3.0" 58 64 } 59 65 }
+34
src/app.css
··· 95 95 box-shadow: 0 0 20px 5px var(--nucleus-selected-post); 96 96 } 97 97 } 98 + 99 + @keyframes slide-in-from-right { 100 + from { 101 + transform: translateX(144px); 102 + opacity: 0; 103 + } 104 + to { 105 + transform: translateX(0); 106 + opacity: 1; 107 + } 108 + } 109 + 110 + @keyframes slide-in-from-left { 111 + from { 112 + transform: translateX(-144px); 113 + opacity: 0; 114 + } 115 + to { 116 + transform: translateX(0); 117 + opacity: 1; 118 + } 119 + } 120 + 121 + .animate-slide-in-right { 122 + animation: slide-in-from-right 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; 123 + } 124 + 125 + .animate-slide-in-left { 126 + animation: slide-in-from-left 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; 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">
+2 -2
src/components/AccountSelector.svelte
··· 1 1 <script lang="ts"> 2 2 import { generateColorForDid, loggingIn, type Account } from '$lib/accounts'; 3 - import { AtpClient } 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'; ··· 67 67 if (isHandle(loginHandle)) handle = loginHandle; 68 68 else throw 'handle is invalid'; 69 69 70 - let did = await client.resolveHandle(handle); 70 + let did = await resolveHandle(handle); 71 71 if (!did.ok) throw did.error; 72 72 73 73 await initiateLogin(did.value, handle);
+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>
+242 -410
src/components/BskyPost.svelte
··· 1 1 <script lang="ts"> 2 - import { 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 - import { clients, postActions, posts, pulsingPostId, type PostActions } from '$lib/state.svelte'; 28 - import * as TID from '@atcute/tid'; 16 + import { 17 + allPosts, 18 + pulsingPostId, 19 + currentTime, 20 + deletePostBacklink, 21 + createPostBacklink, 22 + router, 23 + profiles, 24 + handles, 25 + hasBacklink, 26 + getBlockRelationship, 27 + clients 28 + } from '$lib/state.svelte'; 29 29 import type { PostWithUri } from '$lib/at/fetch'; 30 - import { onMount } from 'svelte'; 31 - import { isActorIdentifier, type AtprotoDid } from '@atcute/lexicons/syntax'; 30 + import { onMount, type Snippet } from 'svelte'; 32 31 import { derived } from 'svelte/store'; 33 - import Device from 'svelte-device-info'; 34 32 import Dropdown from './Dropdown.svelte'; 35 - import { type AppBskyEmbeds } from '$lib/at/types'; 36 33 import { settings } from '$lib/settings'; 34 + import RichText from './RichText.svelte'; 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'; 37 40 38 41 interface Props { 39 42 client: AtpClient; ··· 47 50 isOnPostComposer?: boolean; 48 51 onQuote?: (quote: PostWithUri) => void; 49 52 onReply?: (reply: PostWithUri) => void; 53 + cornerFragment?: Snippet; 54 + isBlocked?: boolean; 50 55 } 51 56 52 57 const { ··· 58 63 mini, 59 64 onQuote, 60 65 onReply, 61 - isOnPostComposer = false /* replyBacklinks */ 66 + isOnPostComposer = false /* replyBacklinks */, 67 + cornerFragment, 68 + isBlocked = false 62 69 }: Props = $props(); 63 70 64 - const selectedDid = $derived(client.user?.did ?? null); 65 - const actionClient = $derived(clients.get(did as AtprotoDid)); 71 + const user = $derived(client.user); 72 + const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did)); 73 + 74 + const aturi = $derived(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); 75 + const color = $derived(generateColorForDid(did)); 66 76 67 - const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`; 68 - const color = generateColorForDid(did); 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 + ); 69 86 70 - let handle: ActorIdentifier = $state(did); 71 - const didDoc = client.resolveDidDoc(did).then((res) => { 72 - if (res.ok) handle = res.value.handle; 73 - return res; 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 + }); 74 96 }); 75 97 const post = data 76 98 ? Promise.resolve(ok(data)) 77 99 : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey); 78 - let profile: AppBskyActorProfile.Main | null = $state(null); 100 + let profile: AppBskyActorProfile.Main | null = $state(profiles.get(did) ?? null); 79 101 onMount(async () => { 80 102 const p = await client.getProfile(did); 81 103 if (!p.ok) return; 82 104 profile = p.value; 83 - console.log(profile.description); 105 + profiles.set(did, profile); 84 106 }); 85 - // const replies = replyBacklinks 86 - // ? Promise.resolve(ok(replyBacklinks)) 87 - // : client.getBacklinks( 88 - // identifier, 89 - // 'app.bsky.feed.post', 90 - // rkey, 91 - // 'app.bsky.feed.post:reply.parent.uri' 92 - // ); 93 107 94 - const postId = `timeline-post-${aturi}-${quoteDepth}`; 108 + const postId = $derived( 109 + `timeline-post-${did.replace(/[^a-zA-Z0-9]/g, '_')}-${rkey}-${quoteDepth}` 110 + ); 95 111 const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); 96 112 97 113 const scrollToAndPulse = (targetUri: ResourceUri) => { 98 114 const targetId = `timeline-post-${targetUri}-0`; 99 - console.log(`Scrolling to ${targetId}`); 100 115 const element = document.getElementById(targetId); 101 116 if (!element) return; 102 117 ··· 108 123 generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo) 109 124 ); 110 125 pulsingPostId.set(targetId); 111 - // Clear pulse after animation 112 126 setTimeout(() => pulsingPostId.set(null), 1200); 113 127 }, 400); 114 128 }; 115 129 116 - const getEmbedText = (embedType: string) => { 117 - switch (embedType) { 118 - case 'app.bsky.embed.external': 119 - return '๐Ÿ”— has external link'; 120 - case 'app.bsky.embed.record': 121 - return '๐Ÿ’ฌ has quote'; 122 - case 'app.bsky.embed.images': 123 - return '๐Ÿ–ผ๏ธ has images'; 124 - case 'app.bsky.embed.video': 125 - return '๐ŸŽฅ has video'; 126 - case 'app.bsky.embed.recordWithMedia': 127 - return '๐Ÿ“Ž has quote with media'; 128 - default: 129 - return 'โ“ has unknown embed'; 130 - } 131 - }; 132 - 133 - const getRelativeTime = (date: Date) => { 134 - const now = new Date(); 135 - const diff = now.getTime() - date.getTime(); 136 - const seconds = Math.floor(diff / 1000); 137 - const minutes = Math.floor(seconds / 60); 138 - const hours = Math.floor(minutes / 60); 139 - const days = Math.floor(hours / 24); 140 - const months = Math.floor(days / 30); 141 - const years = Math.floor(months / 12); 142 - 143 - if (years > 0) return `${years}y`; 144 - if (months > 0) return `${months}m`; 145 - if (days > 0) return `${days}d`; 146 - if (hours > 0) return `${hours}h`; 147 - if (minutes > 0) return `${minutes}m`; 148 - if (seconds > 0) return `${seconds}s`; 149 - return 'now'; 150 - }; 151 - 152 - const findBacklink = $derived(async (toDid: AtprotoDid, source: BacklinksSource) => { 153 - const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source); 154 - if (!backlinks.ok) return null; 155 - return backlinks.value.records.find((r) => r.did === toDid) ?? null; 156 - }); 157 - 158 - let findAllBacklinks = async (did: AtprotoDid | null) => { 159 - if (!did) return; 160 - if (postActions.has(`${did}:${aturi}`)) return; 161 - const backlinks = await Promise.all([ 162 - findBacklink(did, 'app.bsky.feed.like:subject.uri'), 163 - findBacklink(did, 'app.bsky.feed.repost:subject.uri') 164 - // findBacklink('app.bsky.feed.post:reply.parent.uri'), 165 - // findBacklink('app.bsky.feed.post:embed.record.uri') 166 - ]); 167 - const actions: PostActions = { 168 - like: backlinks[0], 169 - repost: backlinks[1] 170 - // reply: backlinks[2], 171 - // quote: backlinks[3] 172 - }; 173 - console.log('findAllBacklinks', did, aturi, actions); 174 - postActions.set(`${did}:${aturi}`, actions); 175 - }; 176 - onMount(() => { 177 - // findAllBacklinks($selectedDid); 178 - accounts.subscribe((accs) => { 179 - accs.map((acc) => acc.did).forEach((did) => findAllBacklinks(did)); 180 - }); 181 - }); 182 - 183 - const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => { 184 - // console.log('toggleLink', selectedDid, link, collection); 185 - if (!selectedDid) return null; 186 - const _post = await post; 187 - if (!_post.ok) return null; 188 - if (!link) { 189 - if (_post.value.cid) { 190 - const record = { 191 - $type: collection, 192 - subject: { 193 - cid: _post.value.cid, 194 - uri: aturi 195 - }, 196 - createdAt: new Date().toISOString() 197 - }; 198 - const rkey = TID.now(); 199 - // todo: handle errors 200 - client.atcute?.post('com.atproto.repo.createRecord', { 201 - input: { 202 - repo: selectedDid, 203 - collection, 204 - record, 205 - rkey 206 - } 207 - }); 208 - return { 209 - collection, 210 - did: selectedDid, 211 - rkey 212 - }; 213 - } 214 - } else { 215 - // todo: handle errors 216 - client.atcute?.post('com.atproto.repo.deleteRecord', { 217 - input: { 218 - repo: link.did, 219 - collection: link.collection, 220 - rkey: link.rkey 221 - } 222 - }); 223 - return null; 224 - } 225 - return link; 226 - }; 227 - 228 130 let actionsOpen = $state(false); 229 131 let actionsPos = $state({ x: 0, y: 0 }); 230 132 ··· 247 149 return; 248 150 } 249 151 250 - actionClient?.atcute 251 - ?.post('com.atproto.repo.deleteRecord', { 152 + clients 153 + .get(did) 154 + ?.user?.atcute.post('com.atproto.repo.deleteRecord', { 252 155 input: { 253 156 collection: 'app.bsky.feed.post', 254 157 repo: did, ··· 257 160 }) 258 161 .then((result) => { 259 162 if (!result.ok) return; 260 - posts.get(did)?.delete(aturi); 163 + allPosts.get(did)?.delete(aturi); 261 164 deleteState = 'deleted'; 262 165 }); 263 166 actionsOpen = false; 264 167 }; 265 168 266 169 let profileOpen = $state(false); 267 - let profilePopoutShowDid = $state(false); 268 170 </script> 269 171 270 - {#snippet embedBadge(embed: AppBskyEmbeds)} 271 - <span 272 - class="rounded-full px-2.5 py-0.5 text-xs font-medium" 273 - style=" 274 - background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent); 275 - color: {mini ? 'var(--nucleus-fg)' : color}; 276 - " 277 - > 278 - {getEmbedText(embed.$type!)} 279 - </span> 280 - {/snippet} 281 - 282 172 {#snippet profileInline()} 283 173 <button 284 174 class=" 285 - 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' : ''} 286 176 rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10 287 177 " 288 178 style="color: {color};" 289 - onclick={() => (profileOpen = !profileOpen)} 179 + onclick={() => ((profileOpen = false), router.navigate(`/profile/${did}`))} 290 180 > 291 181 <ProfilePicture {client} {did} size={8} /> 292 182 293 183 {#if profile} 294 184 <span class="w-min max-w-sm min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 295 - >{profile.displayName}</span 185 + >{profile.displayName?.length === 0 ? handle : profile.displayName}</span 296 186 ><span class="shrink-0 text-sm text-nowrap opacity-70">(@{handle})</span> 297 187 {:else} 298 188 {handle} ··· 300 190 </button> 301 191 {/snippet} 302 192 303 - <!-- eslint-disable svelte/no-navigation-without-resolve --> 304 193 {#snippet profilePopout()} 305 - {@const profileDesc = profile?.description?.trim() ?? ''} 306 194 <Dropdown 307 195 class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!" 308 196 style="background: {color}36; border-color: {color}99;" 309 197 bind:isOpen={profileOpen} 310 198 trigger={profileInline} 199 + onMouseEnter={() => (profileOpen = true)} 200 + onMouseLeave={() => (profileOpen = false)} 311 201 > 312 - <div class="flex items-center gap-2"> 313 - <ProfilePicture {client} {did} size={20} /> 314 - 315 - <div class="flex flex-col items-start overflow-hidden overflow-ellipsis"> 316 - <span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis"> 317 - {profile?.displayName ?? handle} 318 - {#if profile?.pronouns} 319 - <span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span> 320 - {/if} 321 - </span> 322 - <button 323 - oncontextmenu={(e) => { 324 - const node = e.target as Node; 325 - const selection = window.getSelection() ?? new Selection(); 326 - const range = document.createRange(); 327 - range.selectNodeContents(node); 328 - selection.removeAllRanges(); 329 - selection.addRange(range); 330 - e.stopPropagation(); 331 - }} 332 - onclick={() => (profilePopoutShowDid = !profilePopoutShowDid)} 333 - class="mb-0.5 text-nowrap opacity-85 select-text hover:underline" 334 - > 335 - {profilePopoutShowDid ? did : `@${handle}`} 336 - </button> 337 - {#if profile?.website} 338 - <a 339 - target="_blank" 340 - rel="noopener noreferrer" 341 - href={profile.website} 342 - class="text-sm text-nowrap opacity-60">{profile.website}</a 343 - > 344 - {/if} 345 - </div> 346 - </div> 347 - 348 - {#if profileDesc.length > 0} 349 - <p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word"> 350 - {#each profileDesc.split(/(\s)/) as line, idx (idx)} 351 - {#if line === '\n'} 352 - <br /> 353 - {:else if isActorIdentifier(line.replace(/^@/, ''))} 354 - <a 355 - target="_blank" 356 - rel="noopener noreferrer" 357 - class="text-(--nucleus-accent2)" 358 - href={`${$settings.socialAppUrl}/profile/${line.replace(/^@/, '')}`}>{line}</a 359 - > 360 - {:else if line.startsWith('https://')} 361 - <a 362 - target="_blank" 363 - rel="noopener noreferrer" 364 - class="text-(--nucleus-accent2)" 365 - href={line}>{line.replace(/https?:\/\//, '')}</a 366 - > 367 - {:else} 368 - {line} 369 - {/if} 370 - {/each} 371 - </p> 372 - {/if} 202 + <ProfileInfo {client} {did} {handle} {profile} /> 373 203 </Dropdown> 374 204 {/snippet} 375 205 ··· 380 210 {:then post} 381 211 {#if post.ok} 382 212 {@const record = post.value.record} 383 - <!-- svelte-ignore a11y_click_events_have_key_events --> 384 - <!-- svelte-ignore a11y_no_static_element_interactions --> 385 - <div 386 - onclick={() => scrollToAndPulse(post.value.uri)} 387 - class="select-none hover:cursor-pointer hover:underline" 388 - > 389 - <span style="color: {color};">@{handle}</span>: 390 - {#if record.embed} 391 - {@render embedBadge(record.embed)} 392 - {/if} 393 - <span title={record.text}>{record.text}</span> 394 - </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} 395 234 {:else} 396 235 {post.error} 397 236 {/if} ··· 414 253 {:then post} 415 254 {#if post.ok} 416 255 {@const record = post.value.record} 417 - <!-- svelte-ignore a11y_no_static_element_interactions --> 418 - <div 419 - id="timeline-post-{post.value.uri}-{quoteDepth}" 420 - oncontextmenu={handleRightClick} 421 - 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=" 422 276 group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all 423 277 {$isPulsing ? 'animate-pulse-highlight' : ''} 424 278 {isOnPostComposer ? 'backdrop-brightness-20' : ''} 425 279 " 426 - style=" 280 + style=" 427 281 background: {color}{isOnPostComposer 428 - ? '36' 429 - : 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)}; 430 284 border-color: {color}{isOnPostComposer ? '99' : '66'}; 431 285 " 432 - > 433 - <div 434 - class=" 435 - mb-3 flex w-fit max-w-full items-center gap-1 rounded-sm pr-1 436 - " 437 - style="background: {color}33;" 438 286 > 439 - {@render profilePopout()} 440 - <span>ยท</span> 441 - <span 442 - title={new Date(record.createdAt).toLocaleString()} 443 - class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" 444 - > 445 - {getRelativeTime(new Date(record.createdAt))} 446 - </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} 447 325 </div> 448 - <p class="leading-normal text-wrap wrap-break-word"> 449 - {record.text} 450 - {#if isOnPostComposer && record.embed} 451 - {@render embedBadge(record.embed)} 452 - {/if} 453 - </p> 454 - {#if !isOnPostComposer && record.embed} 455 - {@const embed = record.embed} 456 - <div class="mt-2"> 457 - {@render postEmbed(embed)} 458 - </div> 459 - {/if} 460 - {#if !isOnPostComposer} 461 - {@const backlinks = postActions.get(`${selectedDid!}:${post.value.uri}`)} 462 - {@render postControls(post.value, backlinks)} 463 - {/if} 464 - </div> 326 + {/if} 465 327 {:else} 466 328 <div class="error-disclaimer"> 467 329 <p class="text-sm font-medium">error: {post.error}</p> ··· 470 332 {/await} 471 333 {/if} 472 334 473 - {#snippet postEmbed(embed: AppBskyEmbeds)} 474 - {#snippet embedMedia( 475 - embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main 476 - )} 477 - <!-- svelte-ignore a11y_no_static_element_interactions --> 478 - <div oncontextmenu={(e) => e.stopPropagation()}> 479 - {#if embed.$type === 'app.bsky.embed.images'} 480 - <!-- todo: improve how images are displayed, and pop out on click --> 481 - {#each embed.images as image (image.image)} 482 - {#if isBlob(image.image)} 483 - <img 484 - class="w-full rounded-sm" 485 - src={img('feed_thumbnail', did, image.image.ref.$link)} 486 - alt={image.alt} 487 - /> 488 - {/if} 489 - {/each} 490 - {:else if embed.$type === 'app.bsky.embed.video'} 491 - {#if isBlob(embed.video)} 492 - {#await didDoc then didDoc} 493 - {#if didDoc.ok} 494 - <!-- svelte-ignore a11y_media_has_caption --> 495 - <video 496 - class="rounded-sm" 497 - src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 498 - controls 499 - ></video> 500 - {/if} 501 - {/await} 502 - {/if} 503 - {/if} 504 - </div> 505 - {/snippet} 506 - {#snippet embedPost(uri: ResourceUri)} 507 - {#if quoteDepth < 2} 508 - {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 509 - <!-- reject recursive quotes --> 510 - {#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} 511 356 <BskyPost 512 357 {client} 513 358 quoteDepth={quoteDepth + 1} ··· 517 362 {onQuote} 518 363 {onReply} 519 364 /> 520 - {:else} 521 - <span>you think you're funny with that recursive quote but i'm onto you</span> 522 365 {/if} 523 366 {:else} 524 - {@render embedBadge(embed)} 367 + <span>you think you're funny with that recursive quote but i'm onto you</span> 525 368 {/if} 526 - {/snippet} 527 - {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} 528 - {@render embedMedia(embed)} 529 - {:else if embed.$type === 'app.bsky.embed.record'} 530 - {@render embedPost(embed.record.uri)} 531 - {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 532 - <div class="space-y-1.5"> 533 - {@render embedPost(embed.record.record.uri)} 534 - {@render embedMedia(embed.media)} 535 - </div> 369 + {:else} 370 + <EmbedBadge embed={{ $type: 'app.bsky.embed.record' } as AppBskyEmbedRecord.Main} /> 536 371 {/if} 537 - <!-- todo: implement external link embeds --> 538 372 {/snippet} 539 373 540 - {#snippet postControls(post: PostWithUri, backlinks?: PostActions)} 541 - {#snippet control( 542 - name: string, 543 - icon: string, 544 - onClick: (e: MouseEvent) => void, 545 - isFull?: boolean, 546 - hasSolid?: boolean 547 - )} 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 + })} 548 394 <button 549 395 class=" 550 - px-2 py-1.5 text-(--nucleus-fg)/90 transition-all 551 - 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! 552 399 " 553 400 onclick={(e) => onClick(e)} 554 - 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)'}" 555 402 title={name} 403 + disabled={canBeDisabled ? user?.did === undefined : false} 556 404 > 557 405 <Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} /> 558 406 </button> 559 407 {/snippet} 560 408 <div class="mt-3 flex w-full items-center justify-between"> 561 409 <div class="flex w-fit items-center rounded-sm" style="background: {color}1f;"> 562 - {#snippet label( 563 - name: string, 564 - icon: string, 565 - onClick: (link: Backlink | null | undefined) => void, 566 - backlink?: Backlink | null, 567 - hasSolid?: boolean 568 - )} 569 - {@render control(name, icon, () => onClick(backlink), backlink ? true : false, hasSolid)} 570 - {/snippet} 571 - {@render label('reply', 'heroicons:chat-bubble-left', () => { 572 - onReply?.(post); 410 + {@render control({ 411 + name: 'reply', 412 + icon: 'heroicons:chat-bubble-left', 413 + hasSolid: true, 414 + onClick: () => onReply?.(post) 573 415 })} 574 - {@render label( 575 - 'repost', 576 - 'heroicons:arrow-path-rounded-square-20-solid', 577 - async (link) => { 578 - if (link === undefined) return; 579 - postActions.set(`${selectedDid!}:${aturi}`, { 580 - ...backlinks!, 581 - repost: await toggleLink(link, 'app.bsky.feed.repost') 582 - }); 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); 583 423 }, 584 - backlinks?.repost 585 - )} 586 - {@render label('quote', 'heroicons:paper-clip-20-solid', () => { 587 - onQuote?.(post); 424 + isFull: myRepost 425 + })} 426 + {@render control({ 427 + name: 'quote', 428 + icon: 'heroicons:paper-clip-20-solid', 429 + onClick: () => onQuote?.(post) 588 430 })} 589 - {@render label( 590 - 'like', 591 - 'heroicons:star', 592 - async (link) => { 593 - if (link === undefined) return; 594 - postActions.set(`${selectedDid!}:${aturi}`, { 595 - ...backlinks!, 596 - like: await toggleLink(link, 'app.bsky.feed.like') 597 - }); 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); 598 438 }, 599 - backlinks?.like, 600 - true 601 - )} 439 + isFull: myLike, 440 + hasSolid: true 441 + })} 602 442 </div> 603 443 <Dropdown 604 444 class="post-dropdown" ··· 610 450 {@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () => 611 451 navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`) 612 452 )} 613 - {@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () => 453 + {@render dropdownItem(undefined, 'copy at uri', () => 614 454 navigator.clipboard.writeText(post.uri) 615 455 )} 616 456 {@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () => 617 457 navigator.clipboard.writeText(post.record.text) 618 458 )} 619 - {#if actionClient} 459 + {#if isLoggedInUser} 620 460 <div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div> 621 461 {@render dropdownItem( 622 462 deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid', ··· 628 468 {/if} 629 469 630 470 {#snippet trigger()} 631 - <div 632 - class=" 633 - w-fit items-center rounded-sm transition-opacity 634 - duration-100 ease-in-out group-hover:opacity-100 635 - {!actionsOpen && !Device.isMobile ? 'opacity-0' : ''} 636 - " 637 - style="background: {color}1f;" 638 - > 639 - {@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) => { 640 475 e.stopPropagation(); 641 476 actionsOpen = !actionsOpen; 642 477 actionsPos = { x: 0, y: 0 }; 643 - })} 644 - </div> 478 + }, 479 + canBeDisabled: false, 480 + isFull: true, 481 + iconColor: 'color-mix(in srgb, var(--nucleus-fg) 70%, transparent)' 482 + })} 645 483 {/snippet} 646 484 </Dropdown> 647 485 </div> 648 486 {/snippet} 649 487 650 488 {#snippet dropdownItem( 651 - icon: string, 489 + icon: string | undefined, 652 490 label: string, 653 491 onClick: () => void, 654 492 autoClose: boolean = true, ··· 665 503 if (autoClose) actionsOpen = false; 666 504 }} 667 505 > 668 - <span class="font-bold">{label}</span> 669 - <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} 670 510 </button> 671 511 {/snippet} 672 - 673 - <style> 674 - @reference "../app.css"; 675 - 676 - :global(.post-dropdown) { 677 - @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60; 678 - } 679 - </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>
+167
src/components/FollowingView.svelte
··· 1 + <script lang="ts"> 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'; 14 + 15 + interface Props { 16 + client: AtpClient | undefined; 17 + followingSort: Sort; 18 + } 19 + 20 + let { client, followingSort = $bindable('active') }: Props = $props(); 21 + 22 + const selectedDid = $derived(client?.user?.did); 23 + const followsMap = $derived(selectedDid ? follows.get(selectedDid) : undefined); 24 + 25 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 + let sortedFollowing = $state<{ did: Did; data: any }[]>([]); 27 + 28 + let isLongCalculation = $state(false); 29 + let calculationTimer: ReturnType<typeof setTimeout> | undefined; 30 + 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()); 34 + 35 + const updateList = async () => { 36 + // Reset timer and loading state at start 37 + if (calculationTimer) clearTimeout(calculationTimer); 38 + isLongCalculation = false; 39 + 40 + if (!followsMap || !selectedDid) { 41 + sortedFollowing = []; 42 + return; 43 + } 44 + 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)); 49 + 50 + const interactionScores = 51 + followingSort === 'conversational' 52 + ? calculateInteractionScores( 53 + selectedDid, 54 + followsMap, 55 + allPosts, 56 + allBacklinks, 57 + replyIndex, 58 + staticNow 59 + ) 60 + : null; 61 + 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 + })); 72 + 73 + const following = userStatsList.filter((u) => u.data !== null); 74 + const sorted = [...following].sort((a, b) => sortFollowedUser(followingSort, a.data!, b.data!)); 75 + 76 + sortedFollowing = sorted; 77 + 78 + // Clear timer and remove loading state immediately after done 79 + if (calculationTimer) clearTimeout(calculationTimer); 80 + isLongCalculation = false; 81 + }; 82 + 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(); 92 + 93 + updateList(); 94 + }); 95 + 96 + let listHeight = $state(0); 97 + let listContainer: HTMLDivElement | undefined = $state(); 98 + 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 + }; 107 + 108 + $effect(() => { 109 + if (listContainer) { 110 + calcHeight(); 111 + const observer = new ResizeObserver(calcHeight); 112 + observer.observe(document.body); 113 + return () => observer.disconnect(); 114 + } 115 + }); 116 + </script> 117 + 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"> 120 + <div> 121 + <h2 class="text-2xl font-bold md:text-3xl">following</h2> 122 + <div class="mt-2 flex gap-2"> 123 + <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div> 124 + <div class="h-1 w-11 rounded-full bg-(--nucleus-accent2)"></div> 125 + </div> 126 + </div> 127 + <div class="flex gap-1 text-sm sm:gap-2"> 128 + {#each ['recent', 'active', 'conversational'] as type (type)} 129 + <button 130 + class="rounded-sm px-2 py-1 transition-colors {followingSort === type 131 + ? 'bg-(--nucleus-accent) text-(--nucleus-bg)' 132 + : 'bg-(--nucleus-accent)/10 hover:bg-(--nucleus-accent)/20'}" 133 + onclick={() => (followingSort = type as Sort)} 134 + > 135 + {type} 136 + </button> 137 + {/each} 138 + </div> 139 + </div> 140 + 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} 145 + <div class="flex justify-center py-8"> 146 + <div 147 + class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" 148 + style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 149 + ></div> 150 + </div> 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> 165 + {/if} 166 + </div> 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>
-30
src/components/NotificationsPopup.svelte
··· 1 - <script lang="ts"> 2 - import Popup from './Popup.svelte'; 3 - 4 - interface Props { 5 - isOpen: boolean; 6 - onClose: () => void; 7 - } 8 - 9 - let { isOpen = $bindable(false), onClose }: Props = $props(); 10 - 11 - const handleClose = () => { 12 - onClose(); 13 - }; 14 - </script> 15 - 16 - <Popup 17 - bind:isOpen 18 - onClose={handleClose} 19 - title="notifications" 20 - width="w-[42vmax] max-w-2xl" 21 - height="60vh" 22 - showHeaderDivider={true} 23 - > 24 - <div class="flex h-full items-center justify-center"> 25 - <div class="text-center"> 26 - <div class="mb-4 text-6xl opacity-50">๐Ÿšง</div> 27 - <h3 class="text-xl font-bold opacity-80">todo</h3> 28 - </div> 29 - </div> 30 - </Popup>
+18
src/components/NotificationsView.svelte
··· 1 + <div class="p-4"> 2 + <div class="mb-6"> 3 + <h2 class="text-3xl font-bold">notifications</h2> 4 + <div class="mt-2 flex gap-2"> 5 + <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div> 6 + <div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div> 7 + </div> 8 + </div> 9 + 10 + <div 11 + class="flex h-64 items-center justify-center rounded-sm border-2 border-dashed border-(--nucleus-fg)/10" 12 + > 13 + <div class="text-center"> 14 + <div class="mb-4 text-6xl opacity-50">๐Ÿšง</div> 15 + <h3 class="text-xl font-bold opacity-80">todo</h3> 16 + </div> 17 + </div> 18 + </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>
+618 -109
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 + import { parseToRichText } from '$lib/richtext'; 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'; 17 + 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 + }; 10 31 11 32 interface Props { 12 33 client: AtpClient; 13 34 onPostSent: (post: PostWithUri) => void; 14 - quoting?: PostWithUri; 15 - replying?: PostWithUri; 35 + _state: State; 16 36 } 17 37 18 - let { 19 - client, 20 - onPostSent, 21 - quoting = $bindable(undefined), 22 - replying = $bindable(undefined) 23 - }: Props = $props(); 38 + let { client, onPostSent, _state = $bindable() }: Props = $props(); 39 + 40 + const isFocused = $derived(_state.focus === 'focused'); 24 41 25 - let color = $derived( 42 + const color = $derived( 26 43 client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)' 27 44 ); 28 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 + 29 89 const post = async (text: string): Promise<Result<PostWithUri, string>> => { 30 90 const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({ 31 91 $type: 'com.atproto.repo.strongRef', 32 92 cid: p.cid!, 33 93 uri: p.uri 34 94 }); 95 + 96 + const rt = await parseToRichText(text); 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 + 35 134 const record: AppBskyFeedPost.Main = { 36 135 $type: 'app.bsky.feed.post', 37 - text, 38 - reply: replying 39 - ? { 40 - root: replying.record.reply?.root ?? strongRef(replying), 41 - parent: strongRef(replying) 42 - } 43 - : undefined, 44 - embed: quoting 45 - ? { 46 - $type: 'app.bsky.embed.record', 47 - record: strongRef(quoting) 48 - } 49 - : undefined, 136 + text: rt.text, 137 + facets: rt.facets, 138 + reply: 139 + _state.focus === 'focused' && _state.replying 140 + ? { 141 + root: _state.replying.record.reply?.root ?? strongRef(_state.replying), 142 + parent: strongRef(_state.replying) 143 + } 144 + : undefined, 145 + embed: 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']), 50 158 createdAt: new Date().toISOString() 51 159 }; 52 160 53 - const res = await client.atcute?.post('com.atproto.repo.createRecord', { 161 + const res = await client.user?.atcute.post('com.atproto.repo.createRecord', { 54 162 input: { 55 163 collection: 'app.bsky.feed.post', 56 164 repo: client.user!.did, ··· 58 166 } 59 167 }); 60 168 61 - if (!res) { 62 - return err('failed to post: not logged in'); 63 - } 169 + if (!res) return err('failed to post: not logged in'); 64 170 65 - if (!res.ok) { 171 + if (!res.ok) 66 172 return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`); 67 - } 68 173 69 174 return ok({ 70 175 uri: res.data.uri, ··· 73 178 }); 74 179 }; 75 180 76 - let postText = $state(''); 77 - let info = $state(''); 78 - let isFocused = $state(false); 181 + let posting = $state(false); 182 + let postError = $state(''); 79 183 let textareaEl: HTMLTextAreaElement | undefined = $state(); 184 + let fileInputEl: HTMLInputElement | undefined = $state(); 185 + let selectingFile = $state(false); 80 186 81 - const unfocus = () => { 82 - isFocused = false; 83 - quoting = undefined; 84 - replying = undefined; 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; 85 322 }; 86 323 87 324 const doPost = () => { 88 - if (postText.length === 0 || postText.length > 300) return; 325 + if (_state.text.length === 0 || _state.text.length > 300) return; 89 326 90 - post(postText).then((res) => { 91 - if (res.ok) { 92 - onPostSent(res.value); 93 - postText = ''; 94 - info = 'posted!'; 95 - unfocus(); 96 - setTimeout(() => (info = ''), 1000 * 0.8); 97 - } else { 98 - // todo: add a way to clear error 99 - info = res.error; 100 - } 101 - }); 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 + }); 102 352 }; 103 353 104 354 $effect(() => { 105 355 document.documentElement.style.setProperty('--acc-color', color); 106 356 if (isFocused && textareaEl) textareaEl.focus(); 107 - if (quoting || replying) isFocused = true; 108 357 }); 109 358 </script> 110 359 111 - {#snippet renderPost(post: PostWithUri)} 360 + {#snippet attachedPost(post: PostWithUri, type: 'quoting' | 'replying')} 361 + {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 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')} 112 375 {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 113 - <BskyPost 114 - {client} 115 - did={parsedUri.repo} 116 - rkey={parsedUri.rkey} 117 - data={post} 118 - isOnPostComposer={true} 119 - /> 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> 120 394 {/snippet} 121 395 122 - {#snippet composer()} 396 + {#snippet highlighter(text: string)} 397 + {#each tokenize(text) as token, idx (idx)} 398 + {@const highlighted = 399 + token.type === 'mention' || 400 + token.type === 'topic' || 401 + token.type === 'link' || 402 + token.type === 'autolink'} 403 + <span class={highlighted ? 'text-(--nucleus-accent2)' : ''}>{token.raw}</span> 404 + {/each} 405 + {#if text.endsWith('\n')} 406 + <br /> 407 + {/if} 408 + {/snippet} 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 + 484 + {#snippet composer(replying?: PostWithUri, quoting?: PostWithUri)} 485 + {@const hasIncompleteUpload = _state.blobsState 486 + .values() 487 + .some((s) => s.state === 'uploading' || s.state === 'error')} 123 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} 124 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} 125 533 <span 126 - class="text-sm font-medium" 127 - 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 128 536 ? '#ef4444' 129 537 : 'var(--nucleus-fg)'} 53%, transparent);" 130 538 > 131 - {postText.length} / 300 539 + {_state.text.length} / 300 132 540 </span> 133 541 <button 134 - onmousedown={(e) => { 135 - e.preventDefault(); 136 - doPost(); 137 - }} 138 - disabled={postText.length === 0 || postText.length > 300} 139 - 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" 140 548 style="background: color-mix(in srgb, {color} 87%, transparent);" 141 549 > 142 550 post 143 551 </button> 144 552 </div> 145 553 {#if replying} 146 - {@render renderPost(replying)} 554 + {@render attachedPost(replying, 'replying')} 147 555 {/if} 148 - <div class="composer space-y-2"> 149 - <textarea 150 - bind:this={textareaEl} 151 - bind:value={postText} 152 - onfocus={() => (isFocused = true)} 153 - onblur={unfocus} 154 - onkeydown={(event) => { 155 - if (event.key === 'Escape') unfocus(); 156 - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); 157 - }} 158 - placeholder="what's on your mind?" 159 - rows="4" 160 - class="field-sizing-content resize-none" 161 - ></textarea> 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 + > 563 + <div class="relative grid"> 564 + <!-- todo: replace this with a proper rich text editor --> 565 + <div 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)" 567 + aria-hidden="true" 568 + > 569 + {@render highlighter(_state.text)} 570 + </div> 571 + 572 + <textarea 573 + bind:this={textareaEl} 574 + bind:value={_state.text} 575 + onfocus={() => (_state.focus = 'focused')} 576 + onblur={() => (!selectingFile ? unfocus() : null)} 577 + onkeydown={(event) => { 578 + if (event.key === 'Escape') unfocus(); 579 + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); 580 + }} 581 + placeholder="what's on your mind?" 582 + rows="4" 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" 584 + ></textarea> 585 + </div> 586 + {#if _state.attachedMedia} 587 + {@render mediaPreview(_state.attachedMedia)} 588 + {/if} 162 589 {#if quoting} 163 - {@render renderPost(quoting)} 590 + {@render attachedPost(quoting, 'quoting')} 164 591 {/if} 165 592 </div> 166 593 {/snippet} ··· 174 601 <!-- svelte-ignore a11y_no_static_element_interactions --> 175 602 <div 176 603 onmousedown={(e) => { 177 - if (isFocused) { 178 - e.preventDefault(); 179 - } 604 + if (isFocused) e.preventDefault(); 180 605 }} 181 606 class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300 182 607 {!isFocused ? 'min-h-13 items-center' : ''} ··· 186 611 : `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`}; 187 612 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 188 613 > 189 - <div class="w-full p-1.5 px-2"> 190 - {#if info.length > 0} 614 + <div class="w-full p-1"> 615 + {#if !client.user} 191 616 <div 192 617 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 193 618 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" 194 619 > 195 - {info} 620 + not logged in 196 621 </div> 197 622 {:else} 198 - <div class="flex flex-col gap-2"> 199 - {#if isFocused} 200 - {@render composer()} 623 + <div class="flex flex-col gap-1"> 624 + {#if _state.focus === 'focused'} 625 + {@render composer(_state.replying, _state.quoting)} 201 626 {:else} 202 - <input 203 - bind:value={postText} 204 - onfocus={() => (isFocused = true)} 205 - type="text" 206 - placeholder="what's on your mind?" 207 - class="flex-1" 208 - /> 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> 209 649 {/if} 210 650 </div> 211 651 {/if} ··· 213 653 </div> 214 654 </div> 215 655 216 - <!-- TODO: this fucking blows --> 217 656 <style> 218 657 @reference "../app.css"; 219 658 220 659 input, 221 660 .composer { 222 - @apply single-line-input bg-(--nucleus-bg)/35; 661 + @apply single-line-input rounded-xs bg-(--nucleus-bg)/35; 223 662 border-color: color-mix(in srgb, var(--acc-color) 30%, transparent); 224 663 } 225 664 226 665 .composer { 227 - @apply p-2; 666 + @apply p-1; 228 667 } 229 668 230 669 textarea { 231 - @apply w-full bg-transparent p-0; 670 + @apply w-full p-0; 232 671 } 233 672 234 673 input { 235 - @apply p-1 px-2; 674 + @apply p-1.5; 236 675 } 237 676 238 677 .composer { 239 678 @apply focus:scale-100; 240 679 } 241 680 242 - input::placeholder, 243 - textarea::placeholder { 681 + input::placeholder { 244 682 color: color-mix(in srgb, var(--acc-color) 45%, var(--nucleus-bg)); 245 683 } 246 684 247 685 textarea:focus { 248 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; 249 758 } 250 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 -23
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; ··· 13 14 } 14 15 15 16 let { client, did, size }: Props = $props(); 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 + }); 16 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 - style="width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});" 33 - alt="avatar for {did}" 34 - src={img('avatar_thumbnail', did, record.avatar.ref.$link)} 35 - /> 36 - {:else} 37 - {@render missingPfp()} 38 - {/if} 39 - {:else} 40 - {@render missingPfp()} 41 - {/if} 42 - {/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>
+79
src/components/RichText.svelte
··· 1 + <script lang="ts"> 2 + import { parseToRichText } from '$lib/richtext'; 3 + import { settings } from '$lib/settings'; 4 + import { router } from '$lib/state.svelte'; 5 + import type { BakedRichtext } from '@atcute/bluesky-richtext-builder'; 6 + import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter'; 7 + 8 + interface Props { 9 + text: string; 10 + facets?: Facet[]; 11 + } 12 + 13 + const { text, facets }: Props = $props(); 14 + 15 + const richtext: Promise<BakedRichtext> = $derived( 16 + facets ? Promise.resolve({ text, facets }) : parseToRichText(text) 17 + ); 18 + 19 + const handleProfileClick = (e: MouseEvent, did: string) => { 20 + e.preventDefault(); 21 + router.navigate(`/profile/${did}`); 22 + }; 23 + </script> 24 + 25 + {#snippet plainText(text: string)} 26 + {#each text.split(/(\s)/) as line, idx (idx)} 27 + {#if line === '\n'} 28 + <br /> 29 + {:else} 30 + {line} 31 + {/if} 32 + {/each} 33 + {/snippet} 34 + 35 + {#snippet segments(segments: RichtextSegment[])} 36 + {#each segments as segment, idx (idx)} 37 + {@const { text, features: _features } = segment} 38 + {@const features = _features ?? []} 39 + {#if features.length > 0} 40 + <!-- eslint-disable svelte/no-navigation-without-resolve --> 41 + {#each features as feature, idx (idx)} 42 + {#if feature.$type === 'app.bsky.richtext.facet#mention'} 43 + <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 47 + > 48 + {:else if feature.$type === 'app.bsky.richtext.facet#link'} 49 + {@const uri = new URL(feature.uri)} 50 + {@const text = `${!uri.protocol.startsWith('http') ? `${uri.protocol}//` : ''}${uri.host}${uri.hash.length === 0 && uri.search.length === 0 && uri.pathname === '/' ? '' : uri.pathname}${uri.search}${uri.hash}`} 51 + <a 52 + class="text-(--nucleus-accent2)" 53 + href={uri.href} 54 + target="_blank" 55 + rel="noopener noreferrer" 56 + >{@render plainText(`${text.substring(0, 40)}${text.length > 40 ? '...' : ''}`)}</a 57 + > 58 + {:else if feature.$type === 'app.bsky.richtext.facet#tag'} 59 + <a 60 + class="text-(--nucleus-accent2)" 61 + href={`${$settings.socialAppUrl}/search?q=${encodeURIComponent('#' + feature.tag)}`} 62 + target="_blank" 63 + rel="noopener noreferrer">{@render plainText(text)}</a 64 + > 65 + {:else} 66 + <span>{@render plainText(text)}</span> 67 + {/if} 68 + {/each} 69 + {:else} 70 + <span>{@render plainText(text)}</span> 71 + {/if} 72 + {/each} 73 + {/snippet} 74 + 75 + {#await richtext} 76 + {@render plainText(text)} 77 + {:then richtext} 78 + {@render segments(segmentize(richtext.text, richtext.facets))} 79 + {/await}
-191
src/components/SettingsPopup.svelte
··· 1 - <script lang="ts"> 2 - import { defaultSettings, needsReload, settings } from '$lib/settings'; 3 - import { handleCache, didDocCache, recordCache } from '$lib/at/client'; 4 - import { get } from 'svelte/store'; 5 - import ColorPicker from 'svelte-awesome-color-picker'; 6 - import Popup from './Popup.svelte'; 7 - import Tabs from './Tabs.svelte'; 8 - 9 - interface Props { 10 - isOpen: boolean; 11 - onClose: () => void; 12 - } 13 - 14 - let { isOpen = $bindable(false), onClose }: Props = $props(); 15 - 16 - type Tab = 'style' | 'moderation' | 'advanced'; 17 - let activeTab = $state<Tab>('advanced'); 18 - 19 - let localSettings = $state(get(settings)); 20 - let hasReloadChanges = $derived(needsReload($settings, localSettings)); 21 - 22 - $effect(() => { 23 - $settings.theme = localSettings.theme; 24 - }); 25 - 26 - const resetSettingsToSaved = () => { 27 - localSettings = $settings; 28 - }; 29 - 30 - const handleClose = () => { 31 - resetSettingsToSaved(); 32 - onClose(); 33 - }; 34 - 35 - const handleSave = () => { 36 - settings.set(localSettings); 37 - window.location.reload(); 38 - }; 39 - 40 - const handleReset = () => { 41 - const confirmed = confirm('reset all settings to defaults?'); 42 - if (!confirmed) return; 43 - settings.reset(); 44 - window.location.reload(); 45 - }; 46 - 47 - const handleClearCache = () => { 48 - handleCache.clear(); 49 - didDocCache.clear(); 50 - recordCache.clear(); 51 - alert('cache cleared!'); 52 - }; 53 - </script> 54 - 55 - {#snippet divider()} 56 - <div class="h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 57 - {/snippet} 58 - 59 - {#snippet settingHeader(name: string, desc: string)} 60 - <h3 class="mb-3 text-lg font-bold">{name}</h3> 61 - <p class="mb-4 text-sm opacity-80">{desc}</p> 62 - {/snippet} 63 - 64 - {#snippet advancedTab()} 65 - <div class="space-y-5"> 66 - <div> 67 - <h3 class="mb-3 text-lg font-bold">api endpoints</h3> 68 - <div class="space-y-4"> 69 - {#snippet _input(name: string, desc: string)} 70 - <div> 71 - <label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 72 - {desc} 73 - </label> 74 - <input 75 - id={name} 76 - type="url" 77 - bind:value={localSettings.endpoints[name]} 78 - placeholder={defaultSettings.endpoints[name]} 79 - class="single-line-input" 80 - /> 81 - </div> 82 - {/snippet} 83 - {@render _input('slingshot', 'slingshot url (for fetching records & resolving identity)')} 84 - {@render _input('spacedust', 'spacedust url (for notifications)')} 85 - {@render _input('constellation', 'constellation url (for backlinks)')} 86 - </div> 87 - </div> 88 - 89 - {@render divider()} 90 - 91 - <div> 92 - <label for="social-app-url" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 93 - social-app url (for when copying links to posts / profiles) 94 - </label> 95 - <input 96 - id="social-app-url" 97 - type="url" 98 - bind:value={localSettings.socialAppUrl} 99 - placeholder={defaultSettings.socialAppUrl} 100 - class="single-line-input" 101 - /> 102 - </div> 103 - 104 - {@render divider()} 105 - 106 - <div> 107 - {@render settingHeader( 108 - 'cache management', 109 - 'clears cached data (records, DID documents, handles, etc.)' 110 - )} 111 - <button onclick={handleClearCache} class="action-button"> clear cache </button> 112 - </div> 113 - 114 - {@render divider()} 115 - 116 - <div> 117 - {@render settingHeader('reset settings', 'resets all settings to their default values')} 118 - <button 119 - onclick={handleReset} 120 - class="action-button border-red-600 text-red-600 hover:bg-red-600/20" 121 - > 122 - reset to defaults 123 - </button> 124 - </div> 125 - </div> 126 - {/snippet} 127 - 128 - {#snippet styleTab()} 129 - <div class="space-y-5"> 130 - <div> 131 - <h3 class="mb-3 text-lg font-bold">colors</h3> 132 - <div class="space-y-4"> 133 - {#snippet color(name: string, desc: string)} 134 - <div> 135 - <label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 136 - {desc} 137 - </label> 138 - <div class="color-picker"> 139 - <ColorPicker 140 - bind:hex={localSettings.theme[name]} 141 - isAlpha={false} 142 - position="responsive" 143 - label={localSettings.theme[name]} 144 - /> 145 - </div> 146 - </div> 147 - {/snippet} 148 - {@render color('fg', 'foreground color')} 149 - {@render color('bg', 'background color')} 150 - {@render color('accent', 'accent color')} 151 - {@render color('accent2', 'secondary accent color')} 152 - </div> 153 - </div> 154 - </div> 155 - {/snippet} 156 - 157 - <Popup 158 - bind:isOpen 159 - onClose={handleClose} 160 - title="settings" 161 - width="w-[42vmax] max-w-2xl" 162 - height="60vh" 163 - showHeaderDivider={true} 164 - > 165 - {#snippet headerActions()} 166 - {#if hasReloadChanges} 167 - <button onclick={handleSave} class="shrink-0 action-button"> save & reload </button> 168 - {/if} 169 - {/snippet} 170 - 171 - {#if activeTab === 'advanced'} 172 - {@render advancedTab()} 173 - {:else if activeTab === 'moderation'} 174 - <div class="flex h-full items-center justify-center"> 175 - <div class="text-center"> 176 - <div class="mb-4 text-6xl opacity-50">๐Ÿšง</div> 177 - <h3 class="text-xl font-bold opacity-80">todo</h3> 178 - </div> 179 - </div> 180 - {:else if activeTab === 'style'} 181 - {@render styleTab()} 182 - {/if} 183 - 184 - {#snippet footer()} 185 - <Tabs 186 - tabs={['style', 'moderation', 'advanced']} 187 - bind:activeTab 188 - onTabChange={(tab) => (activeTab = tab)} 189 - /> 190 - {/snippet} 191 - </Popup>
+184
src/components/SettingsView.svelte
··· 1 + <script lang="ts"> 2 + import { defaultSettings, needsReload, settings } from '$lib/settings'; 3 + import { get } from 'svelte/store'; 4 + import ColorPicker from 'svelte-awesome-color-picker'; 5 + import Tabs from './Tabs.svelte'; 6 + import { portal } from 'svelte-portal'; 7 + import { cache } from '$lib/cache'; 8 + import { router } from '$lib/state.svelte'; 9 + 10 + interface Props { 11 + tab: string; 12 + } 13 + 14 + let { tab }: Props = $props(); 15 + 16 + let localSettings = $state(get(settings)); 17 + let hasReloadChanges = $derived(needsReload($settings, localSettings)); 18 + 19 + $effect(() => { 20 + $settings.theme = localSettings.theme; 21 + }); 22 + 23 + const handleSave = () => { 24 + settings.set(localSettings); 25 + window.location.reload(); 26 + }; 27 + 28 + const handleReset = () => { 29 + const confirmed = confirm('reset all settings to defaults?'); 30 + if (!confirmed) return; 31 + settings.reset(); 32 + window.location.reload(); 33 + }; 34 + 35 + const handleClearCache = () => { 36 + cache.clear(); 37 + alert('cache cleared!'); 38 + }; 39 + 40 + const onTabChange = (tab: string) => router.replace(`/settings/${tab}`); 41 + </script> 42 + 43 + {#snippet advancedTab()} 44 + <div class="space-y-3 p-4"> 45 + <div> 46 + <h3 class="header">api endpoints</h3> 47 + <div class="borders space-y-4"> 48 + {#snippet _input(name: string, desc: string)} 49 + <div> 50 + <label for={name} class="header-desc block"> 51 + {desc} 52 + </label> 53 + <input 54 + id={name} 55 + type="url" 56 + bind:value={localSettings.endpoints[name]} 57 + placeholder={defaultSettings.endpoints[name]} 58 + class="single-line-input" 59 + /> 60 + </div> 61 + {/snippet} 62 + {@render _input('slingshot', 'slingshot url (for fetching records & resolving identity)')} 63 + {@render _input('spacedust', 'spacedust url (for notifications)')} 64 + {@render _input('constellation', 'constellation url (for backlinks)')} 65 + {@render _input('jetstream', 'jetstream url (for real-time updates)')} 66 + </div> 67 + </div> 68 + 69 + <div class="borders"> 70 + <label for="social-app-url" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 71 + social-app url (for when copying links to posts / profiles) 72 + </label> 73 + <input 74 + id="social-app-url" 75 + type="url" 76 + bind:value={localSettings.socialAppUrl} 77 + placeholder={defaultSettings.socialAppUrl} 78 + class="single-line-input" 79 + /> 80 + </div> 81 + 82 + <h3 class="header">cache management</h3> 83 + <div class="borders"> 84 + <p class="header-desc">clears cached data (records, DID documents, handles, etc.)</p> 85 + <button onclick={handleClearCache} class="action-button"> clear cache </button> 86 + </div> 87 + 88 + <h3 class="header">reset settings</h3> 89 + <div class="borders"> 90 + <p class="header-desc">resets all settings to their default values</p> 91 + <button 92 + onclick={handleReset} 93 + class="action-button border-red-600 text-red-600 hover:bg-red-600/20" 94 + > 95 + reset to defaults 96 + </button> 97 + </div> 98 + </div> 99 + {/snippet} 100 + 101 + {#snippet styleTab()} 102 + <div class="space-y-5 p-4"> 103 + <div> 104 + <h3 class="header">colors</h3> 105 + <div class="borders"> 106 + {#snippet color(name: string, desc: string)} 107 + <div> 108 + <label for={name} class="header-desc block"> 109 + {desc} 110 + </label> 111 + <div class="color-picker"> 112 + <ColorPicker 113 + bind:hex={localSettings.theme[name]} 114 + isAlpha={false} 115 + position="responsive" 116 + label={localSettings.theme[name]} 117 + /> 118 + </div> 119 + </div> 120 + {/snippet} 121 + {@render color('fg', 'foreground color')} 122 + {@render color('bg', 'background color')} 123 + {@render color('accent', 'accent color')} 124 + {@render color('accent2', 'secondary accent color')} 125 + </div> 126 + </div> 127 + </div> 128 + {/snippet} 129 + 130 + <div class="flex flex-col"> 131 + <div class="mb-6 flex items-center justify-between p-4 pb-0"> 132 + <div> 133 + <h2 class="text-3xl font-bold">settings</h2> 134 + <div class="mt-2 flex gap-2"> 135 + <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div> 136 + <div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div> 137 + </div> 138 + </div> 139 + {#if hasReloadChanges} 140 + <button onclick={handleSave} class="action-button animate-pulse shadow-lg"> 141 + save & reload 142 + </button> 143 + {/if} 144 + </div> 145 + 146 + <div class="flex-1"> 147 + {#if tab === 'advanced'} 148 + {@render advancedTab()} 149 + {:else if tab === 'moderation'} 150 + <div class="p-4"> 151 + <div class="flex h-64 items-center justify-center"> 152 + <div class="text-center"> 153 + <div class="mb-4 text-6xl opacity-50">๐Ÿšง</div> 154 + <h3 class="text-xl font-bold opacity-80">todo</h3> 155 + </div> 156 + </div> 157 + </div> 158 + {:else if tab === 'style'} 159 + {@render styleTab()} 160 + {/if} 161 + </div> 162 + 163 + <div 164 + use:portal={'#footer-portal'} 165 + class=" 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)] 167 + " 168 + > 169 + <Tabs tabs={['style', 'moderation', 'advanced']} activeTab={tab} {onTabChange} /> 170 + </div> 171 + </div> 172 + 173 + <style> 174 + @reference "../app.css"; 175 + .borders { 176 + @apply rounded-sm border-2 border-dashed border-(--nucleus-fg)/10 p-4; 177 + } 178 + .header-desc { 179 + @apply mb-2 text-sm text-(--nucleus-fg)/80; 180 + } 181 + .header { 182 + @apply mb-2 text-lg font-bold; 183 + } 184 + </style>
+5 -3
src/components/Tabs.svelte
··· 8 8 let { tabs, activeTab = $bindable(), onTabChange }: Props = $props(); 9 9 </script> 10 10 11 - <div class="flex"> 11 + <div class="flex rounded border-x-3 border-b-3 border-(--nucleus-accent)/20"> 12 12 {#each tabs as tab (tab)} 13 13 {@const isActive = activeTab === tab} 14 14 <button 15 15 onclick={() => onTabChange(tab)} 16 - class="flex-1 border-t-3 px-4 py-3 font-semibold transition-colors hover:cursor-pointer {isActive 17 - ? 'border-(--nucleus-accent) bg-(--nucleus-accent)/20 text-(--nucleus-accent)' 16 + class="flex-1 border-t-3 px-4 py-3 17 + font-semibold transition-colors hover:cursor-pointer 18 + {isActive 19 + ? 'rounded-t border-(--nucleus-accent) bg-(--nucleus-accent)/20 text-(--nucleus-accent)' 18 20 : 'border-(--nucleus-accent)/20 bg-transparent text-(--nucleus-fg)/60 hover:bg-(--nucleus-accent)/10'}" 19 21 > 20 22 {tab}
+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 + };
-317
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 } 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 { PersistedLRU } 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 - // import { JetstreamSubscription } from '@atcute/jetstream'; 41 - 42 - const cacheTtl = 1000 * 60 * 60 * 24; 43 - export const handleCache = new PersistedLRU<Handle, AtprotoDid>({ 44 - max: 1000, 45 - ttl: cacheTtl, 46 - prefix: 'handle' 47 - }); 48 - export const didDocCache = new PersistedLRU<ActorIdentifier, MiniDoc>({ 49 - max: 1000, 50 - ttl: cacheTtl, 51 - prefix: 'didDoc' 52 - }); 53 - export const recordCache = new PersistedLRU< 54 - string, 55 - InferOutput<typeof ComAtprotoRepoGetRecord.mainSchema.output.schema> 56 - >({ 57 - max: 5000, 58 - ttl: cacheTtl, 59 - prefix: 'record' 60 - }); 61 - 62 - export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot); 63 - export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust); 64 - export const constellationUrl: URL = new URL(get(settings).endpoints.constellation); 65 - 66 - type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>; 67 - export type NotificationsStream = WebSocket<NotificationsStreamEncoder>; 68 - export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>; 69 - 70 - export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output }; 71 - 72 - export class AtpClient { 73 - public atcute: AtcuteClient | null = null; 74 - public user: { did: Did; handle: Handle } | null = null; 75 - 76 - async login(agent: OAuthUserAgent): Promise<Result<null, string>> { 77 - try { 78 - const rpc = new AtcuteClient({ handler: agent }); 79 - const res = await rpc.get('com.atproto.server.getSession'); 80 - if (!res.ok) throw res.data.error; 81 - this.user = { 82 - did: res.data.did, 83 - handle: res.data.handle 84 - }; 85 - this.atcute = rpc; 86 - } catch (error) { 87 - return err(`failed to login: ${error}`); 88 - } 89 - 90 - return ok(null); 91 - } 92 - 93 - async getRecordUri< 94 - Collection extends Nsid, 95 - TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 96 - TKey extends RecordKeySchema, 97 - Schema extends RecordSchema<TObject, TKey>, 98 - Output extends InferInput<Schema> 99 - >(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> { 100 - const parsedUri = expect(parseResourceUri(uri)); 101 - if (parsedUri.collection !== schema.object.shape.$type.expected) 102 - return err( 103 - `collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}` 104 - ); 105 - return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!); 106 - } 107 - 108 - async getRecord< 109 - Collection extends Nsid, 110 - TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 111 - TKey extends RecordKeySchema, 112 - Schema extends RecordSchema<TObject, TKey>, 113 - Output extends InferInput<Schema> 114 - >( 115 - schema: Schema, 116 - repo: ActorIdentifier, 117 - rkey: RecordKey 118 - ): Promise<Result<RecordOutput<Output>, string>> { 119 - const collection = schema.object.shape.$type.expected; 120 - const cacheKey = `${repo}:${collection}:${rkey}`; 121 - 122 - const cached = recordCache.get(cacheKey); 123 - if (cached) return ok({ uri: cached.uri, cid: cached.cid, record: cached.value as Output }); 124 - const cachedSignal = recordCache.getSignal(cacheKey); 125 - 126 - const result = await Promise.race([ 127 - fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, { 128 - repo, 129 - collection, 130 - rkey 131 - }).then((result): Result<RecordOutput<Output>, string> => { 132 - if (!result.ok) return result; 133 - 134 - const parsed = safeParse(schema, result.value.value); 135 - if (!parsed.ok) return err(parsed.message); 136 - 137 - recordCache.set(cacheKey, result.value); 138 - 139 - return ok({ 140 - uri: result.value.uri, 141 - cid: result.value.cid, 142 - record: parsed.value as Output 143 - }); 144 - }), 145 - cachedSignal.then( 146 - (d): Result<RecordOutput<Output>, string> => 147 - ok({ uri: d.uri, cid: d.cid, record: d.value as Output }) 148 - ) 149 - ]); 150 - 151 - if (!result.ok) return result; 152 - 153 - return ok(result.value); 154 - } 155 - 156 - async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> { 157 - repo = repo ?? this.user?.did; 158 - if (!repo) return err('not authenticated'); 159 - return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record); 160 - } 161 - 162 - async listRecords<Collection extends keyof Records>( 163 - collection: Collection, 164 - repo: ActorIdentifier, 165 - cursor?: string, 166 - limit?: number 167 - ): Promise< 168 - Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string> 169 - > { 170 - if (!this.atcute) return err('not authenticated'); 171 - const res = await this.atcute.get('com.atproto.repo.listRecords', { 172 - params: { 173 - repo, 174 - collection, 175 - cursor, 176 - limit 177 - } 178 - }); 179 - if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 180 - return ok(res.data); 181 - } 182 - 183 - async resolveHandle(identifier: ActorIdentifier): Promise<Result<AtprotoDid, string>> { 184 - if (isDid(identifier)) return ok(identifier as AtprotoDid); 185 - 186 - const cached = handleCache.get(identifier); 187 - if (cached) return ok(cached); 188 - const cachedSignal = handleCache.getSignal(identifier); 189 - 190 - const res = await Promise.race([ 191 - fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, { 192 - handle: identifier 193 - }), 194 - cachedSignal.then((d): Result<{ did: Did }, string> => ok({ did: d })) 195 - ]); 196 - 197 - const mapped = map(res, (data) => data.did as AtprotoDid); 198 - 199 - if (mapped.ok) handleCache.set(identifier, mapped.value); 200 - 201 - return mapped; 202 - } 203 - 204 - async resolveDidDoc(handleOrDid: ActorIdentifier): Promise<Result<MiniDoc, string>> { 205 - const cached = didDocCache.get(handleOrDid); 206 - if (cached) return ok(cached); 207 - const cachedSignal = didDocCache.getSignal(handleOrDid); 208 - 209 - const result = await Promise.race([ 210 - fetchMicrocosm(slingshotUrl, MiniDocQuery, { 211 - identifier: handleOrDid 212 - }), 213 - cachedSignal.then((d): Result<MiniDoc, string> => ok(d)) 214 - ]); 215 - 216 - if (result.ok) didDocCache.set(handleOrDid, result.value); 217 - 218 - return result; 219 - } 220 - 221 - async getBacklinksUri( 222 - uri: ResourceUri, 223 - source: BacklinksSource 224 - ): Promise<Result<Backlinks, string>> { 225 - const parsedResourceUri = expect(parseCanonicalResourceUri(uri)); 226 - return await this.getBacklinks( 227 - parsedResourceUri.repo, 228 - parsedResourceUri.collection, 229 - parsedResourceUri.rkey, 230 - source 231 - ); 232 - } 233 - 234 - async getBacklinks( 235 - repo: ActorIdentifier, 236 - collection: Nsid, 237 - rkey: RecordKey, 238 - source: BacklinksSource 239 - ): Promise<Result<Backlinks, string>> { 240 - const did = await this.resolveHandle(repo); 241 - if (!did.ok) return err(`cant resolve handle: ${did.error}`); 242 - 243 - const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000)); 244 - const query = fetchMicrocosm(constellationUrl, BacklinksQuery, { 245 - subject: `at://${did.value}/${collection}/${rkey}`, 246 - source, 247 - limit: 100 248 - }); 249 - 250 - const results = await Promise.race([query, timeout]); 251 - if (!results) return err('cant fetch backlinks: timeout'); 252 - 253 - return results; 254 - } 255 - 256 - streamNotifications(subjects: Did[], ...sources: BacklinksSource[]): NotificationsStream { 257 - const url = new URL(spacedustUrl); 258 - url.protocol = 'wss:'; 259 - url.pathname = '/subscribe'; 260 - const searchParams = []; 261 - sources.every((source) => searchParams.push(['wantedSources', source])); 262 - subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject])); 263 - subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`])); 264 - searchParams.push(['instant', 'true']); 265 - url.search = `?${new URLSearchParams(searchParams)}`; 266 - // console.log(`streaming notifications: ${url}`); 267 - const encoder = WebSocket.getDefaultEncoder<undefined, Notification>(); 268 - const ws = new WebSocket<typeof encoder>(url.toString(), { 269 - encoder 270 - }); 271 - return ws; 272 - } 273 - 274 - // streamJetstream(subjects: Did[], ...collections: Nsid[]) { 275 - // return new JetstreamSubscription({ 276 - // url: 'wss://jetstream2.fr.hose.cam', 277 - // wantedCollections: collections, 278 - // wantedDids: subjects 279 - // }); 280 - // } 281 - } 282 - 283 - const fetchMicrocosm = async < 284 - Schema extends XRPCQueryMetadata, 285 - Input extends Schema['params'] extends ObjectSchema ? InferOutput<Schema['params']> : undefined, 286 - Output extends InferXRPCBodyOutput<Schema['output']> 287 - >( 288 - api: URL, 289 - schema: Schema, 290 - params: Input, 291 - init?: RequestInit 292 - ): Promise<Result<Output, string>> => { 293 - if (!schema.output || schema.output.type === 'blob') return err('schema must be blob'); 294 - api.pathname = `/xrpc/${schema.nsid}`; 295 - api.search = params ? `?${new URLSearchParams(params)}` : ''; 296 - try { 297 - const body = await fetchJson(api, init); 298 - if (!body.ok) return err(body.error); 299 - const parsed = safeParse(schema.output.schema, body.value); 300 - if (!parsed.ok) return err(parsed.message); 301 - return ok(parsed.value as Output); 302 - } catch (error) { 303 - return err(`FetchError: ${error}`); 304 - } 305 - }; 306 - 307 - const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => { 308 - try { 309 - const response = await fetch(url, init); 310 - const body = await response.json(); 311 - if (response.status === 400) return err(`${body.error}: ${body.message}`); 312 - if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`); 313 - return ok(body); 314 - } catch (error) { 315 - return err(`FetchError: ${error}`); 316 - } 317 - };
+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 -50
src/lib/at/fetch.ts
··· 4 4 type Cid, 5 5 type ResourceUri 6 6 } from '@atcute/lexicons'; 7 - import { recordCache, 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 - repo: AtprotoDid, 24 22 cursor?: string, 25 - limit?: number 26 - ): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => { 27 - const recordsList = await client.listRecords('app.bsky.feed.post', repo, 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); 28 27 if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`); 29 28 cursor = recordsList.value.cursor; 30 29 const records = recordsList.value.records; 31 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 + } 41 + 32 42 try { 33 43 const allBacklinks = await Promise.all( 34 44 records.map(async (r): Promise<PostWithBacklinks> => { 35 - recordCache.set(r.uri, r); 36 - const replies = await client.getBacklinksUri(r.uri, replySource); 37 - 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; 38 48 return { 39 49 uri: r.uri, 40 50 cid: r.cid, 41 51 record: r.value as AppBskyFeedPost.Main, 42 - replies: replies.value 52 + replies 43 53 }; 44 54 }) 45 55 ); ··· 49 59 } 50 60 }; 51 61 62 + export type HydrateOptions = { 63 + downwards: 'sameAuthor' | 'none'; 64 + }; 65 + 52 66 export const hydratePosts = async ( 53 67 client: AtpClient, 54 - repo: AtprotoDid, 55 - data: PostsWithReplyBacklinks 68 + repo: Did, 69 + data: PostWithBacklinks[], 70 + cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined, 71 + options?: Partial<HydrateOptions> 56 72 ): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => { 57 73 let posts: Map<ResourceUri, PostWithUri> = new Map(); 58 74 try { 59 75 const allPosts = await Promise.all( 60 76 data.map(async (post) => { 61 77 const result: PostWithUri[] = [post]; 62 - const replies = await Promise.all( 63 - post.replies.records.map(async (r) => { 64 - const reply = await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey); 65 - if (!reply.ok) throw `cant fetch reply: ${reply.error}`; 66 - return reply.value; 67 - }) 68 - ); 69 - 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 + } 70 90 return result; 71 91 }) 72 92 ); ··· 78 98 const fetchUpwardsChain = async (post: PostWithUri) => { 79 99 let parent = post.record.reply?.parent; 80 100 while (parent) { 101 + const parentUri = parent.uri as CanonicalResourceUri; 81 102 // if we already have this parent, then we already fetched this chain / are fetching it 82 - if (posts.has(parent.uri as CanonicalResourceUri)) return; 83 - 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 + )); 84 112 if (p.ok) { 85 113 posts.set(p.value.uri, p.value); 86 114 parent = p.value.record.reply?.parent; ··· 92 120 }; 93 121 await Promise.all(posts.values().map(fetchUpwardsChain)); 94 122 95 - try { 96 - const fetchDownwardsChain = async (post: PostWithUri) => { 97 - const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri)); 98 - 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; 99 128 100 - // get chains that are the same author until we exhaust them 101 - const backlinks = await client.getBacklinksUri(post.uri, replySource); 102 - 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; 103 132 104 - const promises = []; 105 - for (const reply of backlinks.value.records) { 106 - if (reply.did !== postRepo) continue; 107 - // if we already have this reply, then we already fetched this chain / are fetching it 108 - if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue; 109 - const record = await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey); 110 - if (!record.ok) break; // TODO: this doesnt handle deleted posts in between 111 - posts.set(record.value.uri, record.value); 112 - promises.push(fetchDownwardsChain(record.value)); 113 - } 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 + } 114 145 115 - await Promise.all(promises); 116 - }; 117 - await Promise.all(posts.values().map(fetchDownwardsChain)); 118 - } catch (error) { 119 - 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 + } 120 152 } 121 153 122 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;
+200 -68
src/lib/cache.ts
··· 1 - import { Cache, type CacheOptions } from '@wora/cache-persist'; 2 - import { LRUCache } from 'lru-cache'; 1 + import { createCache } from 'async-cache-dedupe'; 2 + 3 + const DB_NAME = 'nucleus-cache'; 4 + const STORE_NAME = 'keyvalue'; 5 + const DB_VERSION = 1; 6 + 7 + type WriteOp = 8 + | { 9 + type: 'put'; 10 + key: string; 11 + value: { value: unknown; expires: number }; 12 + resolve: () => void; 13 + reject: (err: unknown) => void; 14 + } 15 + | { type: 'delete'; key: string; resolve: () => void; reject: (err: unknown) => void }; 16 + type ReadOp = { 17 + key: string; 18 + resolve: (val: unknown) => void; 19 + reject: (err: unknown) => void; 20 + }; 21 + 22 + class IDBStorage { 23 + private dbPromise: Promise<IDBDatabase> | null = null; 24 + 25 + private getBatch: ReadOp[] = []; 26 + private writeBatch: WriteOp[] = []; 27 + 28 + private getFlushScheduled = false; 29 + private writeFlushScheduled = false; 30 + 31 + constructor() { 32 + if (typeof indexedDB === 'undefined') return; 3 33 4 - export interface PersistedLRUOptions { 5 - prefix?: string; 6 - max: number; 7 - ttl?: number; 8 - persistOptions?: CacheOptions; 9 - } 34 + this.dbPromise = new Promise((resolve, reject) => { 35 + const request = indexedDB.open(DB_NAME, DB_VERSION); 10 36 11 - // eslint-disable-next-line @typescript-eslint/no-empty-object-type 12 - export class PersistedLRU<K extends string, V extends {}> { 13 - private memory: LRUCache<K, V>; 14 - private storage: Cache; 15 - private signals: Map<K, ((data: V) => void)[]>; 37 + request.onerror = () => { 38 + console.error('IDB open error:', request.error); 39 + reject(request.error); 40 + }; 16 41 17 - private prefix = ''; 42 + request.onsuccess = () => resolve(request.result); 18 43 19 - constructor(opts: PersistedLRUOptions) { 20 - this.memory = new LRUCache<K, V>({ 21 - max: opts.max, 22 - ttl: opts.ttl 44 + request.onupgradeneeded = (event) => { 45 + const db = (event.target as IDBOpenDBRequest).result; 46 + if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME); 47 + }; 23 48 }); 24 - this.storage = new Cache(opts.persistOptions); 25 - this.prefix = opts.prefix ? `${opts.prefix}%` : ''; 26 - this.signals = new Map(); 49 + } 27 50 28 - this.init(); 51 + async get(key: string): Promise<unknown> { 52 + // checking in-flight writes 53 + for (let i = this.writeBatch.length - 1; i >= 0; i--) { 54 + const op = this.writeBatch[i]; 55 + if (op.key === key) { 56 + if (op.type === 'delete') return undefined; 57 + if (op.type === 'put') { 58 + // if expired we dont want it 59 + if (op.value.expires < Date.now()) return undefined; 60 + return op.value.value; 61 + } 62 + } 63 + } 64 + 65 + if (!this.dbPromise) return undefined; 66 + 67 + return new Promise((resolve, reject) => { 68 + this.getBatch.push({ key, resolve, reject }); 69 + this.scheduleGetFlush(); 70 + }); 29 71 } 30 72 31 - async init(): Promise<void> { 32 - await this.storage.restore(); 73 + private scheduleGetFlush() { 74 + if (this.getFlushScheduled) return; 75 + this.getFlushScheduled = true; 76 + queueMicrotask(() => this.flushGetBatch()); 77 + } 78 + 79 + private async flushGetBatch() { 80 + this.getFlushScheduled = false; 81 + const batch = this.getBatch; 82 + this.getBatch = []; 83 + 84 + if (batch.length === 0) return; 85 + 86 + try { 87 + const db = await this.dbPromise; 88 + if (!db) throw new Error('DB not available'); 89 + 90 + const transaction = db.transaction(STORE_NAME, 'readonly'); 91 + const store = transaction.objectStore(STORE_NAME); 33 92 34 - const state = this.storage.getState(); 35 - for (const [key, val] of Object.entries(state)) { 36 - try { 37 - // console.log('restoring', key); 38 - const k = this.unprefix(key) as unknown as K; 39 - const v = val as V; 40 - this.memory.set(k, v); 41 - } catch (err) { 42 - console.warn('skipping invalid persisted entry', key, err); 43 - } 93 + batch.forEach(({ key, resolve }) => { 94 + try { 95 + const request = store.get(key); 96 + request.onsuccess = () => { 97 + const result = request.result; 98 + if (!result) { 99 + resolve(undefined); 100 + return; 101 + } 102 + if (result.expires < Date.now()) { 103 + // Fire-and-forget removal for expired items 104 + this.remove(key).catch(() => {}); 105 + resolve(undefined); 106 + return; 107 + } 108 + resolve(result.value); 109 + }; 110 + request.onerror = () => resolve(undefined); 111 + } catch { 112 + resolve(undefined); 113 + } 114 + }); 115 + } catch (error) { 116 + batch.forEach(({ reject }) => reject(error)); 44 117 } 45 118 } 46 119 47 - get(key: K): V | undefined { 48 - return this.memory.get(key); 120 + async set(key: string, value: unknown, ttl: number): Promise<void> { 121 + if (!this.dbPromise) return; 122 + 123 + const expires = Date.now() + ttl * 1000; 124 + const storageValue = { value, expires }; 125 + 126 + return new Promise((resolve, reject) => { 127 + this.writeBatch.push({ type: 'put', key, value: storageValue, resolve, reject }); 128 + this.scheduleWriteFlush(); 129 + }); 49 130 } 50 - getSignal(key: K): Promise<V> { 51 - return new Promise<V>((resolve) => { 52 - if (!this.signals.has(key)) { 53 - this.signals.set(key, [resolve]); 54 - return; 55 - } 56 - const signals = this.signals.get(key)!; 57 - signals.push(resolve); 58 - this.signals.set(key, signals); 131 + 132 + async remove(key: string): Promise<void> { 133 + if (!this.dbPromise) return; 134 + 135 + return new Promise((resolve, reject) => { 136 + this.writeBatch.push({ type: 'delete', key, resolve, reject }); 137 + this.scheduleWriteFlush(); 59 138 }); 60 139 } 61 - set(key: K, value: V): void { 62 - this.memory.set(key, value); 63 - this.storage.set(this.prefixed(key), value); 64 - const signals = this.signals.get(key); 65 - let signal = signals?.pop(); 66 - while (signal) { 67 - signal(value); 68 - signal = signals?.pop(); 140 + 141 + private scheduleWriteFlush() { 142 + if (this.writeFlushScheduled) return; 143 + this.writeFlushScheduled = true; 144 + queueMicrotask(() => this.flushWriteBatch()); 145 + } 146 + 147 + private async flushWriteBatch() { 148 + this.writeFlushScheduled = false; 149 + const batch = this.writeBatch; 150 + this.writeBatch = []; 151 + 152 + if (batch.length === 0) return; 153 + 154 + try { 155 + const db = await this.dbPromise; 156 + if (!db) throw new Error('DB not available'); 157 + 158 + const transaction = db.transaction(STORE_NAME, 'readwrite'); 159 + const store = transaction.objectStore(STORE_NAME); 160 + 161 + batch.forEach((op) => { 162 + try { 163 + let request: IDBRequest; 164 + if (op.type === 'put') request = store.put(op.value, op.key); 165 + else request = store.delete(op.key); 166 + 167 + request.onsuccess = () => op.resolve(); 168 + request.onerror = () => op.reject(request.error); 169 + } catch (err) { 170 + op.reject(err); 171 + } 172 + }); 173 + } catch (error) { 174 + batch.forEach(({ reject }) => reject(error)); 69 175 } 70 - this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly) 71 176 } 72 - has(key: K): boolean { 73 - return this.memory.has(key); 177 + 178 + async clear(): Promise<void> { 179 + if (!this.dbPromise) return; 180 + try { 181 + const db = await this.dbPromise; 182 + return new Promise<void>((resolve, reject) => { 183 + const transaction = db.transaction(STORE_NAME, 'readwrite'); 184 + const store = transaction.objectStore(STORE_NAME); 185 + const request = store.clear(); 186 + 187 + request.onerror = () => reject(request.error); 188 + request.onsuccess = () => resolve(); 189 + }); 190 + } catch (e) { 191 + console.error('IDB clear error', e); 192 + } 74 193 } 75 - delete(key: K): void { 76 - this.memory.delete(key); 77 - this.storage.delete(this.prefixed(key)); 78 - this.storage.flush(); 194 + 195 + async exists(key: string): Promise<boolean> { 196 + return (await this.get(key)) !== undefined; 79 197 } 80 - clear(): void { 81 - this.memory.clear(); 82 - this.storage.purge(); 83 - this.storage.flush(); 198 + 199 + async invalidate(key: string): Promise<void> { 200 + return this.remove(key); 84 201 } 85 202 86 - private prefixed(key: K): string { 87 - return this.prefix + key; 203 + // noops 204 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 205 + async getTTL(key: string): Promise<void> { 206 + return; 88 207 } 89 - private unprefix(prefixed: string): string { 90 - return prefixed.slice(this.prefix.length); 208 + async refresh(): Promise<void> { 209 + return; 91 210 } 92 211 } 212 + 213 + export const ttl = 60 * 60 * 3; // 3 hours 214 + 215 + export const cache = createCache({ 216 + storage: { 217 + type: 'custom', 218 + options: { 219 + storage: new IDBStorage() 220 + } 221 + }, 222 + ttl, 223 + onError: (err) => console.error(err) 224 + });
+17
src/lib/date.ts
··· 1 + export const getRelativeTime = (date: Date, now: Date = new Date()) => { 2 + const diff = now.getTime() - date.getTime(); 3 + const seconds = Math.floor(diff / 1000); 4 + const minutes = Math.floor(seconds / 60); 5 + const hours = Math.floor(minutes / 60); 6 + const days = Math.floor(hours / 24); 7 + const months = Math.floor(days / 30); 8 + const years = Math.floor(months / 12); 9 + 10 + if (years > 0) return `${years}y`; 11 + if (months > 0) return `${months}mo`; 12 + if (days > 0) return `${days}d`; 13 + if (hours > 0) return `${hours}h`; 14 + if (minutes > 0) return `${minutes}m`; 15 + if (seconds > 0) return `${seconds}s`; 16 + return 'now'; 17 + };
+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;
+72
src/lib/richtext/index.ts
··· 1 + import RichtextBuilder, { type BakedRichtext } from '@atcute/bluesky-richtext-builder'; 2 + import { tokenize, type Token } from '$lib/richtext/parser'; 3 + import type { Did, GenericUri, Handle } from '@atcute/lexicons'; 4 + import { resolveHandle } from '$lib/at/client.svelte'; 5 + 6 + export const parseToRichText = (text: string): ReturnType<typeof processTokens> => 7 + processTokens(tokenize(text)); 8 + 9 + const processTokens = async (tokens: Token[]): Promise<BakedRichtext> => { 10 + const rt = new RichtextBuilder(); 11 + 12 + for (const token of tokens) { 13 + switch (token.type) { 14 + case 'text': 15 + rt.addText(token.content); 16 + break; 17 + case 'mention': { 18 + let did: Did | undefined = token.did as Did | undefined; 19 + if (!did) { 20 + const handle = token.handle as Handle; 21 + const result = await resolveHandle(handle); 22 + if (result.ok) did = result.value; 23 + } 24 + if (did) rt.addMention(token.raw, did); 25 + else rt.addText(token.raw); 26 + break; 27 + } 28 + case 'topic': 29 + rt.addTag(token.name); 30 + break; 31 + case 'autolink': 32 + rt.addLink(token.url, token.url as GenericUri); 33 + break; 34 + case 'link': { 35 + // flatten children to text 36 + const text = flattenToText(token.children); 37 + rt.addLink(text, token.url as GenericUri); 38 + break; 39 + } 40 + case 'escape': 41 + rt.addText(token.escaped); 42 + break; 43 + // formatting tokens (strong, emphasis, etc.) don't map to facets 44 + // so just extract their text content 45 + case 'strong': 46 + case 'emphasis': 47 + case 'underline': 48 + case 'delete': 49 + rt.addText(flattenToText(token.children)); 50 + break; 51 + case 'code': 52 + rt.addText(token.content); 53 + break; 54 + case 'emote': 55 + // handle emotes as needed 56 + rt.addText(token.raw); 57 + break; 58 + } 59 + } 60 + 61 + return rt.build(); 62 + }; 63 + 64 + const flattenToText = (tokens: Token[]): string => { 65 + return tokens 66 + .map((t) => { 67 + if ('content' in t) return t.content; 68 + if ('children' in t) return flattenToText(t.children); 69 + return t.raw; 70 + }) 71 + .join(''); 72 + };
+349
src/lib/richtext/parser.ts
··· 1 + // taken and modified from: https://github.com/mary-ext/atcute/blob/trunk/packages/bluesky/richtext-parser/lib/index.ts 2 + 3 + const ESCAPE_RE = /^\\([^0-9A-Za-z\s])/; 4 + 5 + const MENTION_RE = /^[@๏ผ ]([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))($|\s|\p{P})/u; 6 + 7 + const DID_RE = /^(did:([a-z0-9]+):([A-Za-z0-9.\-_%:]+))($|\s|\p{P})/u; 8 + 9 + const TOPIC_RE = 10 + /^(?:#(?!\ufe0f|\u20e3)|๏ผƒ)([\p{N}]*[\p{L}\p{M}\p{Pc}][\p{L}\p{M}\p{Pc}\p{N}]*)($|\s|\p{P})/u; 11 + 12 + const EMOTE_RE = /^:([\w-]+):/; 13 + 14 + const AUTOLINK_RE = /^https?:\/\/[\S]+/; 15 + const AUTOLINK_BACKPEDAL_RE = /(?:(?<!\(.*)\))?[.,;]*$/; 16 + 17 + const LINK_RE = 18 + /^\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*<?((?:\([^)]*\)|[^\s\\]|\\.)*?)>?(?:\s+['"]([^]*?)['"])?\s*\)/; 19 + const UNESCAPE_URL_RE = /\\([^0-9A-Za-z\s])/g; 20 + 21 + const EMPHASIS_RE = 22 + /^\b_((?:__|\\[^]|[^\\_])+?)_\b|^\*(?=\S)((?:\*\*|\\[^]|\s+(?:\\[^]|[^\s*\\]|\*\*)|[^\s*\\])+?)\*(?!\*)/; 23 + 24 + const STRONG_RE = /^\*\*((?:\\[^]|[^\\])+?)\*\*(?!\*)/; 25 + 26 + const UNDERLINE_RE = /^__((?:\\[^]|~(?!~)|[^~\\]|\s(?!~~))+?)__(?!_)/; 27 + 28 + const DELETE_RE = /^~~((?:\\[^]|~(?!~)|[^~\\]|\s(?!~~))+?)~~/; 29 + 30 + const CODE_RE = /^(`+)([^]*?[^`])\1(?!`)/; 31 + const CODE_ESCAPE_BACKTICKS_RE = /^ (?= *`)|(` *) $/g; 32 + 33 + const TEXT_RE = 34 + /^[^]+?(?:(?=$|[~*_`:\\[]|https?:\/\/)|(?<=\s|[(){}/\\[\]\-|:;'".,=+])(?=[@๏ผ #๏ผƒ]|did:[a-z0-9]+:))/; 35 + 36 + export interface EscapeToken { 37 + type: 'escape'; 38 + raw: string; 39 + escaped: string; 40 + } 41 + 42 + export interface MentionToken { 43 + type: 'mention'; 44 + raw: string; 45 + handle?: string; 46 + did?: string; 47 + } 48 + 49 + export interface TopicToken { 50 + type: 'topic'; 51 + raw: string; 52 + name: string; 53 + } 54 + 55 + export interface EmoteToken { 56 + type: 'emote'; 57 + raw: string; 58 + name: string; 59 + } 60 + 61 + export interface AutolinkToken { 62 + type: 'autolink'; 63 + raw: string; 64 + url: string; 65 + } 66 + 67 + export interface LinkToken { 68 + type: 'link'; 69 + raw: string; 70 + url: string; 71 + children: Token[]; 72 + } 73 + 74 + export interface UnderlineToken { 75 + type: 'underline'; 76 + raw: string; 77 + children: Token[]; 78 + } 79 + 80 + export interface StrongToken { 81 + type: 'strong'; 82 + raw: string; 83 + children: Token[]; 84 + } 85 + 86 + export interface EmphasisToken { 87 + type: 'emphasis'; 88 + raw: string; 89 + children: Token[]; 90 + } 91 + 92 + export interface DeleteToken { 93 + type: 'delete'; 94 + raw: string; 95 + children: Token[]; 96 + } 97 + 98 + export interface CodeToken { 99 + type: 'code'; 100 + raw: string; 101 + content: string; 102 + } 103 + 104 + export interface TextToken { 105 + type: 'text'; 106 + raw: string; 107 + content: string; 108 + } 109 + 110 + export type Token = 111 + | EscapeToken 112 + | MentionToken 113 + | TopicToken 114 + | EmoteToken 115 + | AutolinkToken 116 + | LinkToken 117 + | StrongToken 118 + | EmphasisToken 119 + | UnderlineToken 120 + | DeleteToken 121 + | CodeToken 122 + | TextToken; 123 + 124 + const tokenizeEscape = (src: string): EscapeToken | undefined => { 125 + const match = ESCAPE_RE.exec(src); 126 + if (match) { 127 + return { 128 + type: 'escape', 129 + raw: match[0], 130 + escaped: match[1] 131 + }; 132 + } 133 + }; 134 + 135 + const tokenizeMention = (src: string): MentionToken | undefined => { 136 + const match = MENTION_RE.exec(src); 137 + if (match && match[2] !== '@') { 138 + const suffix = match[2].length; 139 + 140 + return { 141 + type: 'mention', 142 + raw: suffix > 0 ? match[0].slice(0, -suffix) : match[0], 143 + handle: match[1] 144 + }; 145 + } 146 + 147 + const didMatch = DID_RE.exec(src); 148 + if (didMatch) { 149 + const suffix = didMatch[4].length; 150 + 151 + return { 152 + type: 'mention', 153 + raw: suffix > 0 ? didMatch[0].slice(0, -suffix) : didMatch[0], 154 + did: didMatch[1] 155 + }; 156 + } 157 + }; 158 + 159 + const tokenizeTopic = (src: string): TopicToken | undefined => { 160 + const match = TOPIC_RE.exec(src); 161 + if (match && match[2] !== '#') { 162 + const suffix = match[2].length; 163 + 164 + return { 165 + type: 'topic', 166 + raw: suffix > 0 ? match[0].slice(0, -suffix) : match[0], 167 + name: match[1] 168 + }; 169 + } 170 + }; 171 + 172 + const tokenizeEmote = (src: string): EmoteToken | undefined => { 173 + const match = EMOTE_RE.exec(src); 174 + if (match) { 175 + return { 176 + type: 'emote', 177 + raw: match[0], 178 + name: match[1] 179 + }; 180 + } 181 + }; 182 + 183 + const tokenizeAutolink = (src: string): AutolinkToken | undefined => { 184 + const match = AUTOLINK_RE.exec(src); 185 + if (match) { 186 + const url = match[0].replace(AUTOLINK_BACKPEDAL_RE, ''); 187 + 188 + return { 189 + type: 'autolink', 190 + raw: url, 191 + url: url 192 + }; 193 + } 194 + }; 195 + 196 + const tokenizeLink = (src: string): LinkToken | undefined => { 197 + const match = LINK_RE.exec(src); 198 + if (match) { 199 + return { 200 + type: 'link', 201 + raw: match[0], 202 + url: match[2].replace(UNESCAPE_URL_RE, '$1'), 203 + children: tokenize(match[1]) 204 + }; 205 + } 206 + }; 207 + 208 + const _tokenizeEmphasis = (src: string): EmphasisToken | undefined => { 209 + const match = EMPHASIS_RE.exec(src); 210 + if (match) { 211 + return { 212 + type: 'emphasis', 213 + raw: match[0], 214 + children: tokenize(match[2] || match[1]) 215 + }; 216 + } 217 + }; 218 + 219 + const _tokenizeStrong = (src: string): StrongToken | undefined => { 220 + const match = STRONG_RE.exec(src); 221 + if (match) { 222 + return { 223 + type: 'strong', 224 + raw: match[0], 225 + children: tokenize(match[1]) 226 + }; 227 + } 228 + }; 229 + 230 + const _tokenizeUnderline = (src: string): UnderlineToken | undefined => { 231 + const match = UNDERLINE_RE.exec(src); 232 + if (match) { 233 + return { 234 + type: 'underline', 235 + raw: match[0], 236 + children: tokenize(match[1]) 237 + }; 238 + } 239 + }; 240 + 241 + const tokenizeEmStrongU = ( 242 + src: string 243 + ): EmphasisToken | StrongToken | UnderlineToken | undefined => { 244 + let token: EmphasisToken | StrongToken | UnderlineToken | undefined; 245 + 246 + { 247 + const match = _tokenizeEmphasis(src); 248 + if (match && (!token || match.raw.length > token.raw.length)) { 249 + token = match; 250 + } 251 + } 252 + 253 + { 254 + const match = _tokenizeStrong(src); 255 + if (match && (!token || match.raw.length > token.raw.length)) { 256 + token = match; 257 + } 258 + } 259 + 260 + { 261 + const match = _tokenizeUnderline(src); 262 + if (match && (!token || match.raw.length > token.raw.length)) { 263 + token = match; 264 + } 265 + } 266 + 267 + return token; 268 + }; 269 + 270 + const tokenizeDelete = (src: string): DeleteToken | undefined => { 271 + const match = DELETE_RE.exec(src); 272 + if (match) { 273 + return { 274 + type: 'delete', 275 + raw: match[0], 276 + children: tokenize(match[1]) 277 + }; 278 + } 279 + }; 280 + 281 + const tokenizeCode = (src: string): CodeToken | undefined => { 282 + const match = CODE_RE.exec(src); 283 + if (match) { 284 + return { 285 + type: 'code', 286 + raw: match[0], 287 + content: match[2].replace(CODE_ESCAPE_BACKTICKS_RE, '$1') 288 + }; 289 + } 290 + }; 291 + 292 + const tokenizeText = (src: string): TextToken | undefined => { 293 + const match = TEXT_RE.exec(src); 294 + if (match) { 295 + return { 296 + type: 'text', 297 + raw: match[0], 298 + content: match[0] 299 + }; 300 + } 301 + }; 302 + 303 + export const tokenize = (src: string): Token[] => { 304 + const tokens: Token[] = []; 305 + 306 + let last: Token | undefined; 307 + let token: Token | undefined; 308 + 309 + while (src) { 310 + last = token; 311 + 312 + if ( 313 + (token = 314 + tokenizeEscape(src) || 315 + tokenizeMention(src) || 316 + tokenizeAutolink(src) || 317 + tokenizeTopic(src) || 318 + tokenizeEmote(src) || 319 + tokenizeLink(src) || 320 + tokenizeEmStrongU(src) || 321 + tokenizeDelete(src) || 322 + tokenizeCode(src)) 323 + ) { 324 + src = src.slice(token.raw.length); 325 + tokens.push(token); 326 + continue; 327 + } 328 + 329 + if ((token = tokenizeText(src))) { 330 + src = src.slice(token.raw.length); 331 + 332 + if (last && last.type === 'text') { 333 + last.raw += token.raw; 334 + last.content += token.content; 335 + token = last; 336 + } else { 337 + tokens.push(token); 338 + } 339 + 340 + continue; 341 + } 342 + 343 + if (src) { 344 + throw new Error(`infinite loop encountered`); 345 + } 346 + } 347 + 348 + return tokens; 349 + };
+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 + }
+24 -8
src/lib/settings.ts
··· 5 5 slingshot: string; 6 6 spacedust: string; 7 7 constellation: string; 8 + jetstream: string; 8 9 }; 9 10 export type Settings = { 10 11 endpoints: ApiEndpoints; ··· 16 17 endpoints: { 17 18 slingshot: 'https://slingshot.microcosm.blue', 18 19 spacedust: 'https://spacedust.microcosm.blue', 19 - constellation: 'https://constellation.microcosm.blue' 20 + constellation: 'https://constellation.microcosm.blue', 21 + jetstream: 'wss://jetstream2.fr.hose.cam' 20 22 }, 21 23 theme: defaultTheme, 22 24 socialAppUrl: 'https://bsky.app' 23 25 }; 24 26 25 27 const createSettingsStore = () => { 26 - const stored = localStorage.getItem('settings'); 28 + // Prevent SSR crash if localStorage is missing 29 + const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('settings') : null; 27 30 28 31 const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings; 29 - initial.endpoints = initial.endpoints ?? defaultSettings.endpoints; 30 - initial.theme = initial.theme ?? defaultSettings.theme; 32 + initial.endpoints = { ...defaultSettings.endpoints, ...initial.endpoints }; 33 + initial.theme = { ...defaultSettings.theme, ...initial.theme }; 31 34 initial.socialAppUrl = initial.socialAppUrl ?? defaultSettings.socialAppUrl; 32 35 33 36 const { subscribe, set, update } = writable<Settings>(initial as Settings); 34 37 35 38 subscribe((settings) => { 39 + if (typeof document === 'undefined') return; 36 40 const theme = settings.theme; 37 41 document.documentElement.style.setProperty('--nucleus-bg', theme.bg); 38 42 document.documentElement.style.setProperty('--nucleus-fg', theme.fg); 39 43 document.documentElement.style.setProperty('--nucleus-accent', theme.accent); 40 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); 41 53 }); 42 54 43 55 return { 44 56 subscribe, 45 57 set: (value: Settings) => { 46 - localStorage.setItem('settings', JSON.stringify(value)); 58 + if (typeof localStorage !== 'undefined') 59 + localStorage.setItem('settings', JSON.stringify(value)); 47 60 set(value); 48 61 }, 49 62 update: (fn: (value: Settings) => Settings) => { 50 63 update((value) => { 51 64 const newValue = fn(value); 52 - localStorage.setItem('settings', JSON.stringify(newValue)); 65 + if (typeof localStorage !== 'undefined') 66 + localStorage.setItem('settings', JSON.stringify(newValue)); 53 67 return newValue; 54 68 }); 55 69 }, 56 70 reset: () => { 57 - localStorage.setItem('settings', JSON.stringify(defaultSettings)); 71 + if (typeof localStorage !== 'undefined') 72 + localStorage.setItem('settings', JSON.stringify(defaultSettings)); 58 73 set(defaultSettings); 59 74 } 60 75 }; ··· 66 81 return ( 67 82 current.endpoints.slingshot !== other.endpoints.slingshot || 68 83 current.endpoints.spacedust !== other.endpoints.spacedust || 69 - current.endpoints.constellation !== other.endpoints.constellation 84 + current.endpoints.constellation !== other.endpoints.constellation || 85 + current.endpoints.jetstream !== other.endpoints.jetstream 70 86 ); 71 87 };
+655 -17
src/lib/state.svelte.ts
··· 1 1 import { writable } from 'svelte/store'; 2 - import { AtpClient, type NotificationsStream } from './at/client'; 3 - import { SvelteMap } from 'svelte/reactivity'; 4 - import type { Did, ResourceUri } from '@atcute/lexicons'; 5 - import type { Backlink } from './at/constellation'; 6 - import type { PostWithUri } from './at/fetch'; 7 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 8 - // import type { JetstreamSubscription } from '@atcute/jetstream'; 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'; 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'; 9 35 10 36 export const notificationStream = writable<NotificationsStream | null>(null); 11 - // export const jetstream = writable<JetstreamSubscription | null>(null); 37 + export const jetstream = writable<JetstreamSubscription | null>(null); 12 38 13 - export type PostActions = { 14 - like: Backlink | null; 15 - repost: Backlink | null; 16 - // reply: Backlink | null; 17 - // 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 + } 18 74 }; 19 - 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 + }; 20 240 21 241 export const pulsingPostId = writable<string | null>(null); 22 242 23 243 export const viewClient = new AtpClient(); 24 - export const clients = new SvelteMap<AtprotoDid, AtpClient>(); 244 + export const clients = new SvelteMap<Did, AtpClient>(); 25 245 26 - export const posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 27 - export const cursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 246 + export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>(); 247 + 248 + export const addFollows = ( 249 + did: Did, 250 + followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]> 251 + ) => { 252 + let map = follows.get(did)!; 253 + if (!map) { 254 + map = new SvelteMap(followMap); 255 + follows.set(did, map); 256 + return; 257 + } 258 + for (const [uri, record] of followMap) map.set(uri, record); 259 + }; 260 + 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 + } 270 + addFollows( 271 + account.did, 272 + res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main]) 273 + ); 274 + return res.value.records.values().map((follow) => follow.value as AppBskyGraphFollow.Main); 275 + }; 276 + 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); 282 + if (!res.ok) return; 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))); 293 + }; 294 + 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); 303 + 304 + // mark as fetched 305 + let flags = blockFlags.get(subject); 306 + if (!flags) { 307 + flags = new SvelteSet(); 308 + blockFlags.set(subject, flags); 309 + } 310 + flags.add(blocker); 311 + 312 + return res.value.total > 0; 313 + }; 314 + 315 + export const fetchBlocks = async (account: Account) => { 316 + const client = clients.get(account.did)!; 317 + const res = await client.listRecordsUntil(account.did, 'app.bsky.graph.block'); 318 + if (!res.ok) return; 319 + for (const block of res.value.records) { 320 + const record = block.value as AppBskyGraphBlock.Main; 321 + const parsedUri = expect(parseCanonicalResourceUri(block.uri)); 322 + addBacklinks(`at://${record.subject}`, blockSource, [ 323 + { 324 + did: parsedUri.repo, 325 + collection: parsedUri.collection, 326 + rkey: parsedUri.rkey 327 + } 328 + ]); 329 + } 330 + }; 331 + 332 + export const createBlock = async (client: AtpClient, targetDid: Did) => { 333 + const userDid = client.user?.did; 334 + if (!userDid) return; 335 + 336 + const rkey = tidNow(); 337 + const targetUri = `at://${targetDid}` as ResourceUri; 338 + 339 + addBacklinks(targetUri, blockSource, [ 340 + { 341 + did: userDid, 342 + collection: 'app.bsky.graph.block', 343 + rkey 344 + } 345 + ]); 346 + 347 + const record: AppBskyGraphBlock.Main = { 348 + $type: 'app.bsky.graph.block', 349 + subject: targetDid, 350 + // eslint-disable-next-line svelte/prefer-svelte-reactivity 351 + createdAt: new Date().toISOString() 352 + }; 353 + 354 + await client.user?.atcute.post('com.atproto.repo.createRecord', { 355 + input: { 356 + repo: userDid, 357 + collection: 'app.bsky.graph.block', 358 + rkey, 359 + record 360 + } 361 + }); 362 + }; 363 + 364 + export const deleteBlock = async (client: AtpClient, targetDid: Did) => { 365 + const userDid = client.user?.did; 366 + if (!userDid) return; 367 + 368 + const targetUri = `at://${targetDid}` as ResourceUri; 369 + const links = findBacklinksBy(targetUri, blockSource, userDid); 370 + 371 + removeBacklinks(targetUri, blockSource, links); 372 + 373 + await Promise.allSettled( 374 + links.map((link) => 375 + client.user?.atcute.post('com.atproto.repo.deleteRecord', { 376 + input: { 377 + repo: userDid, 378 + collection: 'app.bsky.graph.block', 379 + rkey: link.rkey 380 + } 381 + }) 382 + ) 383 + ); 384 + }; 385 + 386 + export const isBlockedByUser = (targetDid: Did, userDid: Did): boolean => { 387 + return isBlockedBy(targetDid, userDid); 388 + }; 389 + 390 + export const isUserBlockedBy = (userDid: Did, targetDid: Did): boolean => { 391 + return isBlockedBy(userDid, targetDid); 392 + }; 393 + 394 + export const hasBlockRelationship = (did1: Did, did2: Did): boolean => { 395 + return isBlockedBy(did1, did2) || isBlockedBy(did2, did1); 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 + } 450 + } 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 }); 462 + }; 463 + 464 + export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 465 + export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 466 + 467 + const traversePostChain = (post: PostWithUri) => { 468 + const result = [post.uri]; 469 + const parentUri = post.record.reply?.parent.uri; 470 + if (parentUri) { 471 + const parentPost = allPosts.get(extractDidFromUri(parentUri)!)?.get(parentUri); 472 + if (parentPost) result.push(...traversePostChain(parentPost)); 473 + } 474 + return result; 475 + }; 476 + export const addTimeline = (did: Did, uris: Iterable<ResourceUri>) => { 477 + let timeline = timelines.get(did); 478 + if (!timeline) { 479 + timeline = new SvelteSet(); 480 + timelines.set(did, timeline); 481 + } 482 + for (const uri of uris) { 483 + const post = allPosts.get(did)?.get(uri); 484 + // we need to traverse the post chain to add all posts in the chain to the timeline 485 + // because the parent posts might not be in the timeline yet 486 + const chain = post ? traversePostChain(post) : [uri]; 487 + for (const uri of chain) timeline.add(uri); 488 + } 489 + }; 490 + 491 + export const fetchTimeline = async ( 492 + client: AtpClient, 493 + subject: Did, 494 + limit: number = 6, 495 + withBacklinks: boolean = true, 496 + hydrateOptions?: Partial<HydrateOptions> 497 + ) => { 498 + const cursor = postCursors.get(subject); 499 + if (cursor && cursor.end) return; 500 + 501 + const accPosts = await fetchPosts(subject, client, cursor?.value, limit, withBacklinks); 502 + if (!accPosts.ok) throw `cant fetch posts ${subject}: ${accPosts.error}`; 503 + 504 + // if the cursor is undefined, we've reached the end of the timeline 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))); 529 + } 530 + 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 + }; 547 + 548 + export const fetchInitial = async (account: Account) => { 549 + const client = clients.get(account.did)!; 550 + await Promise.all([ 551 + fetchBlocks(account), 552 + fetchForInteractions(client, account.did), 553 + fetchFollows(account).then((follows) => 554 + Promise.all(follows.map((follow) => fetchForInteractions(client, follow.subject)) ?? []) 555 + ) 556 + ]); 557 + }; 558 + 559 + export const handleJetstreamEvent = async (event: JetstreamEvent) => { 560 + if (event.kind !== 'commit') return; 561 + 562 + const { did, commit } = event; 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 + }; 594 + 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; 609 + 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 + } 634 + 635 + // console.log(hydrated); 636 + addPosts(hydrated.value.values()); 637 + addTimeline(did, hydrated.value.keys()); 638 + }; 639 + 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 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); 655 + } 656 + }; 657 + 658 + export const currentTime = new SvelteDate(); 659 + 660 + if (typeof window !== 'undefined') 661 + setInterval(() => { 662 + currentTime.setTime(Date.now()); 663 + }, 1000); 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
+29 -20
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 = { ··· 20 24 branchParentPost?: ThreadPost; 21 25 }; 22 26 23 - export const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => { 27 + export const buildThreads = ( 28 + account: Did, 29 + timeline: Set<ResourceUri>, 30 + posts: Map<Did, Map<ResourceUri, PostWithUri>> 31 + ): Thread[] => { 24 32 const threadMap = new Map<ResourceUri, ThreadPost[]>(); 25 33 26 34 // group posts by root uri into "thread" chains 27 - for (const [account, timeline] of timelines) { 28 - for (const [uri, data] of timeline) { 29 - const parsedUri = expect(parseCanonicalResourceUri(uri)); 30 - const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 31 - 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; 32 39 33 - const post: ThreadPost = { 34 - data, 35 - account, 36 - did: parsedUri.repo, 37 - rkey: parsedUri.rkey, 38 - parentUri, 39 - depth: 0, 40 - newestTime: new Date(data.record.createdAt).getTime() 41 - }; 40 + const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 41 + const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 42 + 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 + }; 42 53 43 - if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); 54 + if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); 44 55 45 - threadMap.get(rootUri)!.push(post); 46 - } 56 + threadMap.get(rootUri)!.push(post); 47 57 } 48 58 49 59 const threads: Thread[] = []; ··· 146 156 147 157 threads.sort((a, b) => b.newestTime - a.newestTime); 148 158 149 - // console.log(threads); 150 - 151 159 return threads; 152 160 }; 153 161 ··· 163 171 164 172 export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) => 165 173 threads.filter((thread) => { 174 + if (thread.posts.length === 0) return false; 166 175 if (!opts.viewOwnPosts) return hasNonOwnPost(thread.posts, accounts); 167 176 return true; 168 177 });
-489
src/routes/+page.svelte
··· 1 - <script lang="ts"> 2 - import BskyPost from '$components/BskyPost.svelte'; 3 - import PostComposer from '$components/PostComposer.svelte'; 4 - import AccountSelector from '$components/AccountSelector.svelte'; 5 - import SettingsPopup from '$components/SettingsPopup.svelte'; 6 - import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client'; 7 - import { accounts, type Account } from '$lib/accounts'; 8 - import { type Did, parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons'; 9 - import { onMount } from 'svelte'; 10 - import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch'; 11 - import { expect } from '$lib/result'; 12 - import { AppBskyFeedPost } from '@atcute/bluesky'; 13 - import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 14 - import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 15 - import { clients, cursors, notificationStream, posts, viewClient } from '$lib/state.svelte'; 16 - import { get } from 'svelte/store'; 17 - import Icon from '@iconify/svelte'; 18 - import { sessions } from '$lib/at/oauth'; 19 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 20 - import type { PageProps } from './+page'; 21 - import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 22 - import NotificationsPopup from '$components/NotificationsPopup.svelte'; 23 - 24 - const { data: loadData }: PageProps = $props(); 25 - 26 - let errors = $state(loadData.client.ok ? [] : [loadData.client.error]); 27 - let errorsOpen = $state(false); 28 - 29 - let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null); 30 - $effect(() => { 31 - if (selectedDid) { 32 - localStorage.setItem('selectedDid', selectedDid); 33 - } else { 34 - localStorage.removeItem('selectedDid'); 35 - } 36 - }); 37 - 38 - const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null); 39 - 40 - const loginAccount = async (account: Account) => { 41 - if (clients.has(account.did)) return; 42 - const client = new AtpClient(); 43 - const result = await client.login(await sessions.get(account.did)); 44 - if (!result.ok) { 45 - errors.push(`failed to login into @${account.handle ?? account.did}: ${result.error}`); 46 - return; 47 - } 48 - clients.set(account.did, client); 49 - }; 50 - 51 - const handleAccountSelected = async (did: AtprotoDid) => { 52 - selectedDid = did; 53 - const account = $accounts.find((acc) => acc.did === did); 54 - if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute)) 55 - await loginAccount(account); 56 - }; 57 - 58 - const handleLogout = async (did: AtprotoDid) => { 59 - await sessions.remove(did); 60 - const newAccounts = $accounts.filter((acc) => acc.did !== did); 61 - $accounts = newAccounts; 62 - clients.delete(did); 63 - posts.delete(did); 64 - cursors.delete(did); 65 - handleAccountSelected(newAccounts[0]?.did); 66 - }; 67 - 68 - let isSettingsOpen = $state(false); 69 - let isNotificationsOpen = $state(false); 70 - let reverseChronological = $state(true); 71 - let viewOwnPosts = $state(true); 72 - 73 - const threads = $derived(filterThreads(buildThreads(posts), $accounts, { viewOwnPosts })); 74 - 75 - let quoting = $state<PostWithUri | undefined>(undefined); 76 - let replying = $state<PostWithUri | undefined>(undefined); 77 - 78 - const expandedThreads = new SvelteSet<ResourceUri>(); 79 - 80 - const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => { 81 - if (!posts.has(did)) { 82 - posts.set(did, new SvelteMap(accTimeline)); 83 - return; 84 - } 85 - const map = posts.get(did)!; 86 - for (const [uri, record] of accTimeline) map.set(uri, record); 87 - }; 88 - 89 - const fetchTimeline = async (account: Account) => { 90 - const client = clients.get(account.did); 91 - if (!client) return; 92 - 93 - const cursor = cursors.get(account.did); 94 - if (cursor && cursor.end) return; 95 - 96 - const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6); 97 - if (!accPosts.ok) throw `cant fetch posts @${account.handle}: ${accPosts.error}`; 98 - 99 - // if the cursor is undefined, we've reached the end of the timeline 100 - if (!accPosts.value.cursor) { 101 - cursors.set(account.did, { ...cursor, end: true }); 102 - return; 103 - } 104 - 105 - cursors.set(account.did, { value: accPosts.value.cursor, end: false }); 106 - const hydrated = await hydratePosts(client, account.did, accPosts.value.posts); 107 - if (!hydrated.ok) throw `cant hydrate posts @${account.handle}: ${hydrated.error}`; 108 - 109 - addPosts(account.did, hydrated.value); 110 - }; 111 - 112 - const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline)); 113 - 114 - const handleNotification = async (event: NotificationsStreamEvent) => { 115 - if (event.type === 'message') { 116 - // console.log(event.data); 117 - const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject)); 118 - const subjectPost = await viewClient.getRecord( 119 - AppBskyFeedPost.mainSchema, 120 - parsedSubjectUri.repo, 121 - parsedSubjectUri.rkey 122 - ); 123 - if (!subjectPost.ok) return; 124 - 125 - const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 126 - const hydrated = await hydratePosts(viewClient, parsedSubjectUri.repo as AtprotoDid, [ 127 - { 128 - record: subjectPost.value.record, 129 - uri: event.data.link.subject, 130 - cid: subjectPost.value.cid, 131 - replies: { 132 - cursor: null, 133 - total: 1, 134 - records: [ 135 - { 136 - did: parsedSourceUri.repo, 137 - collection: parsedSourceUri.collection, 138 - rkey: parsedSourceUri.rkey 139 - } 140 - ] 141 - } 142 - } 143 - ]); 144 - 145 - if (!hydrated.ok) { 146 - errors.push(`cant hydrate posts @${parsedSubjectUri.repo}: ${hydrated.error}`); 147 - return; 148 - } 149 - 150 - // console.log(hydrated); 151 - addPosts(parsedSubjectUri.repo, hydrated.value); 152 - } 153 - }; 154 - 155 - // const handleJetstream = async (subscription: JetstreamSubscription) => { 156 - // for await (const event of subscription) { 157 - // if (event.kind !== 'commit') continue; 158 - // const commit = event.commit; 159 - // if (commit.operation === 'delete') { 160 - // continue; 161 - // } 162 - // const record = commit.record as AppBskyFeedPost.Main; 163 - // addPosts( 164 - // event.did, 165 - // new Map([[`at://${event.did}/${commit.collection}/${commit.rkey}` as ResourceUri, record]]) 166 - // ); 167 - // } 168 - // }; 169 - 170 - const loaderState = new LoaderState(); 171 - let scrollContainer = $state<HTMLDivElement>(); 172 - 173 - let loading = $state(false); 174 - let loadError = $state(''); 175 - let showScrollToTop = $state(false); 176 - 177 - const handleScroll = () => { 178 - showScrollToTop = window.scrollY > 300; 179 - }; 180 - 181 - const scrollToTop = () => { 182 - window.scrollTo({ top: 0, behavior: 'smooth' }); 183 - }; 184 - 185 - const loadMore = async () => { 186 - if (loading || $accounts.length === 0) return; 187 - 188 - loading = true; 189 - loaderState.status = 'LOADING'; 190 - 191 - try { 192 - await fetchTimelines($accounts); 193 - loaderState.loaded(); 194 - } catch (error) { 195 - loadError = `${error}`; 196 - loaderState.error(); 197 - loading = false; 198 - return; 199 - } 200 - 201 - loading = false; 202 - if (cursors.values().every((cursor) => cursor.end)) loaderState.complete(); 203 - }; 204 - 205 - onMount(() => { 206 - window.addEventListener('scroll', handleScroll); 207 - 208 - accounts.subscribe((newAccounts) => { 209 - get(notificationStream)?.stop(); 210 - // jetstream.set(null); 211 - if (newAccounts.length === 0) return; 212 - notificationStream.set( 213 - viewClient.streamNotifications( 214 - newAccounts.map((account) => account.did), 215 - 'app.bsky.feed.post:reply.parent.uri' 216 - ) 217 - ); 218 - // jetstream.set( 219 - // viewClient.streamJetstream( 220 - // newAccounts.map((account) => account.did), 221 - // 'app.bsky.feed.post' 222 - // ) 223 - // ); 224 - }); 225 - notificationStream.subscribe((stream) => { 226 - if (!stream) return; 227 - stream.listen(handleNotification); 228 - }); 229 - // jetstream.subscribe((stream) => { 230 - // if (!stream) return; 231 - // handleJetstream(stream); 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 - loadMore(); 244 - }); 245 - } else { 246 - selectedDid = null; 247 - } 248 - 249 - return () => { 250 - window.removeEventListener('scroll', handleScroll); 251 - }; 252 - }); 253 - </script> 254 - 255 - {#snippet appButton(onClick: () => void, icon: string, ariaLabel: string, iconHover?: string)} 256 - <button 257 - onclick={onClick} 258 - class="group rounded-sm bg-(--nucleus-accent)/15 p-2 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg" 259 - aria-label={ariaLabel} 260 - > 261 - <Icon class="group-hover:hidden" {icon} width={28} /> 262 - <Icon class="hidden group-hover:block" icon={iconHover ?? icon} width={28} /> 263 - </button> 264 - {/snippet} 265 - 266 - <div class="mx-auto max-w-2xl"> 267 - <!-- thread list (page scrolls as a whole) --> 268 - <div 269 - id="app-thread-list" 270 - class="mb-4 min-h-screen p-2 [scrollbar-color:var(--nucleus-accent)_transparent]" 271 - bind:this={scrollContainer} 272 - > 273 - {#if $accounts.length > 0} 274 - {@render renderThreads()} 275 - {:else} 276 - <div class="flex justify-center py-4"> 277 - <p class="text-xl opacity-80"> 278 - <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 279 - </p> 280 - </div> 281 - {/if} 282 - </div> 283 - 284 - <!-- header --> 285 - <div class="sticky bottom-0 z-10"> 286 - {#if errors.length > 0} 287 - <div class="relative m-3 mb-1 error-disclaimer"> 288 - <div class="flex items-center gap-2 text-red-500"> 289 - <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> 290 - there are ({errors.length}) errors 291 - <div class="grow"></div> 292 - <button onclick={() => (errorsOpen = !errorsOpen)} class="action-button p-1 px-1.5" 293 - >{errorsOpen ? 'hide details' : 'see details'}</button 294 - > 295 - </div> 296 - {#if errorsOpen} 297 - <div 298 - 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" 299 - > 300 - {#each errors as error, idx (idx)} 301 - <p>โ€ข {error}</p> 302 - {/each} 303 - </div> 304 - {/if} 305 - </div> 306 - {/if} 307 - 308 - <div 309 - class="rounded-t-sm px-0.5 pt-0.5" 310 - style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));" 311 - > 312 - <div 313 - class="rounded-t-sm" 314 - style=" 315 - background: linear-gradient(to right, color-mix(in srgb, var(--nucleus-accent) 18%, var(--nucleus-bg)), color-mix(in srgb, var(--nucleus-accent2) 13%, var(--nucleus-bg))); 316 - " 317 - > 318 - <!-- composer and error disclaimer (above thread list, not scrollable) --> 319 - <div class="flex gap-2 px-2 pt-2 pb-1"> 320 - <AccountSelector 321 - client={viewClient} 322 - accounts={$accounts} 323 - bind:selectedDid 324 - onAccountSelected={handleAccountSelected} 325 - onLogout={handleLogout} 326 - /> 327 - 328 - {#if selectedClient} 329 - <div class="flex-1"> 330 - <PostComposer 331 - client={selectedClient} 332 - onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)} 333 - bind:quoting 334 - bind:replying 335 - /> 336 - </div> 337 - {:else} 338 - <div 339 - 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" 340 - > 341 - <p class="text-sm opacity-80">select or add an account to post</p> 342 - </div> 343 - {/if} 344 - 345 - {#if showScrollToTop} 346 - {@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top')} 347 - {/if} 348 - </div> 349 - 350 - <div 351 - class="mt-1 h-px w-full opacity-50" 352 - style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));" 353 - ></div> 354 - 355 - <div class="flex items-center gap-1.5 px-2 py-1"> 356 - <div class="mb-2"> 357 - <h1 class="text-3xl font-bold tracking-tight">nucleus</h1> 358 - <div class="mt-1 flex gap-2"> 359 - <div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div> 360 - <div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div> 361 - </div> 362 - </div> 363 - <div class="grow"></div> 364 - {@render appButton( 365 - () => (isNotificationsOpen = true), 366 - 'heroicons:bell', 367 - 'notifications', 368 - 'heroicons:bell-solid' 369 - )} 370 - {@render appButton( 371 - () => (isSettingsOpen = true), 372 - 'heroicons:cog-6-tooth', 373 - 'settings', 374 - 'heroicons:cog-6-tooth-solid' 375 - )} 376 - </div> 377 - 378 - <!-- <hr 379 - class="h-[4px] w-full rounded-full border-0" 380 - style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));" 381 - /> --> 382 - </div> 383 - </div> 384 - </div> 385 - </div> 386 - 387 - <SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} /> 388 - <NotificationsPopup 389 - bind:isOpen={isNotificationsOpen} 390 - onClose={() => (isNotificationsOpen = false)} 391 - /> 392 - 393 - {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 394 - <span 395 - class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 396 - > 397 - <span class="text-sm text-nowrap opacity-60">{reverse ? 'โ†ฑ' : 'โ†ณ'}</span> 398 - <BskyPost mini client={selectedClient ?? viewClient} {...post} /> 399 - </span> 400 - {/snippet} 401 - 402 - {#snippet threadsView()} 403 - {#each threads as thread (thread.rootUri)} 404 - <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> 405 - {#if thread.branchParentPost} 406 - {@render replyPost(thread.branchParentPost)} 407 - {/if} 408 - {#each thread.posts as post, idx (post.data.uri)} 409 - {@const mini = 410 - !expandedThreads.has(thread.rootUri) && 411 - thread.posts.length > 4 && 412 - idx > 0 && 413 - idx < thread.posts.length - 2} 414 - {#if !mini} 415 - <div class="mb-1.5"> 416 - <BskyPost 417 - client={selectedClient ?? viewClient} 418 - onQuote={(post) => (quoting = post)} 419 - onReply={(post) => (replying = post)} 420 - {...post} 421 - /> 422 - </div> 423 - {:else if mini} 424 - {#if idx === 1} 425 - {@render replyPost(post, !reverseChronological)} 426 - <button 427 - 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)" 428 - onclick={() => expandedThreads.add(thread.rootUri)} 429 - > 430 - <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 431 - <Icon 432 - class="shrink-0" 433 - icon={reverseChronological 434 - ? 'heroicons:bars-arrow-up-solid' 435 - : 'heroicons:bars-arrow-down-solid'} 436 - width={32} 437 - /><span class="shrink-0 pb-1">view full chain</span> 438 - <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 439 - </button> 440 - {:else if idx === thread.posts.length - 3} 441 - {@render replyPost(post)} 442 - {/if} 443 - {/if} 444 - {/each} 445 - </div> 446 - <div 447 - class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 448 - ></div> 449 - {/each} 450 - {/snippet} 451 - 452 - {#snippet renderThreads()} 453 - <InfiniteLoader 454 - {loaderState} 455 - triggerLoad={loadMore} 456 - loopDetectionTimeout={0} 457 - intersectionOptions={{ root: scrollContainer }} 458 - > 459 - {@render threadsView()} 460 - {#snippet noData()} 461 - <div class="flex justify-center py-4"> 462 - <p class="text-xl opacity-80"> 463 - all posts seen! <span class="text-2xl">:o</span> 464 - </p> 465 - </div> 466 - {/snippet} 467 - {#snippet loading()} 468 - <div class="flex justify-center"> 469 - <div 470 - class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" 471 - style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 472 - ></div> 473 - </div> 474 - {/snippet} 475 - {#snippet error()} 476 - <div class="flex flex-col gap-4 py-4"> 477 - <p class="text-xl opacity-80"> 478 - <span class="text-4xl">x_x</span> <br /> 479 - {loadError} 480 - </p> 481 - <div> 482 - <button class="flex action-button items-center gap-2" onclick={loadMore}> 483 - <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again 484 - </button> 485 - </div> 486 - </div> 487 - {/snippet} 488 - </InfiniteLoader> 489 - {/snippet}
-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/*',