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 + }
+98 -55
deno.lock
··· 1 { 2 "version": "5", 3 "specifiers": { 4 - "npm:@atcute/atproto@^3.1.9": "3.1.9", 5 "npm:@atcute/bluesky-richtext-builder@^2.0.4": "2.0.4", 6 "npm:@atcute/bluesky-richtext-segmenter@^2.0.4": "2.0.4", 7 "npm:@atcute/bluesky@^3.2.14": "3.2.14", 8 - "npm:@atcute/client@^4.1.1": "4.1.1", 9 "npm:@atcute/identity-resolver@^1.2.1": "1.2.1_@atcute+identity@1.1.3", 10 "npm:@atcute/identity@^1.1.3": "1.1.3", 11 "npm:@atcute/jetstream@^1.1.2": "1.1.2", 12 - "npm:@atcute/lexicons@^1.2.5": "1.2.5", 13 "npm:@atcute/oauth-browser-client@^2.0.3": "2.0.3_@atcute+identity@1.1.3", 14 - "npm:@atcute/tid@^1.0.3": "1.0.3", 15 "npm:@eslint/compat@2": "2.0.0_eslint@9.39.2", 16 "npm:@eslint/js@^9.39.2": "9.39.2", 17 "npm:@floating-ui/dom@^1.7.4": "1.7.4", ··· 22 "npm:@sveltejs/vite-plugin-svelte@^6.2.1": "6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3", 23 "npm:@tailwindcss/forms@~0.5.11": "0.5.11_tailwindcss@4.1.18", 24 "npm:@tailwindcss/vite@^4.1.18": "4.1.18_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3", 25 - "npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.17": "3.0.17_svelte@5.46.1__acorn@8.15.0", 26 "npm:@types/node@^25.0.3": "25.0.3", 27 "npm:@wora/cache-persist@^2.2.1": "2.2.1", 28 "npm:async-cache-dedupe@^3.4.0": "3.4.0", 29 "npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.39.2", 30 "npm:eslint-plugin-svelte@^3.13.1": "3.13.1_eslint@9.39.2_svelte@5.46.1__acorn@8.15.0_postcss@8.5.6", 31 "npm:eslint@^9.39.2": "9.39.2", 32 - "npm:globals@^16.5.0": "16.5.0", 33 "npm:hash-wasm@^4.12.0": "4.12.0", 34 "npm:lru-cache@^11.2.4": "11.2.4", 35 "npm:prettier-plugin-svelte@^3.4.1": "3.4.1_prettier@3.7.4_svelte@5.46.1__acorn@8.15.0", 36 "npm:prettier-plugin-tailwindcss@~0.7.2": "0.7.2_prettier@3.7.4_prettier-plugin-svelte@3.4.1__prettier@3.7.4__svelte@5.46.1___acorn@8.15.0_svelte@5.46.1__acorn@8.15.0", 37 "npm:prettier@^3.7.4": "3.7.4", ··· 42 "npm:svelte-portal@^2.2.1": "2.2.1", 43 "npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0", 44 "npm:tailwindcss@^4.1.18": "4.1.18", 45 - "npm:typescript-eslint@^8.50.1": "8.50.1_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.50.1__eslint@9.39.2__typescript@5.9.3", 46 "npm:typescript@^5.9.3": "5.9.3", 47 "npm:vite@^7.3.0": "7.3.0_@types+node@25.0.3_picomatch@4.0.3" 48 }, 49 "npm": { 50 - "@atcute/atproto@3.1.9": { 51 - "integrity": "sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==", 52 "dependencies": [ 53 "@atcute/lexicons" 54 ] ··· 74 "@atcute/lexicons" 75 ] 76 }, 77 - "@atcute/client@4.1.1": { 78 - "integrity": "sha512-FROCbTTCeL5u4tO/n72jDEKyKqjdlXMB56Ehve3W/gnnLGCYWvN42sS7tvL1Mgu6sbO3yZwsXKDrmM2No4XpjA==", 79 "dependencies": [ 80 "@atcute/identity", 81 "@atcute/lexicons" ··· 109 "yocto-queue@1.2.2" 110 ] 111 }, 112 - "@atcute/lexicons@1.2.5": { 113 - "integrity": "sha512-9yO9WdgxW8jZ7SbzUycH710z+JmsQ9W9n5S6i6eghYju32kkluFmgBeS47r8e8p2+Dv4DemS7o/3SUGsX9FR5Q==", 114 "dependencies": [ 115 "@standard-schema/spec", 116 "esm-env" 117 ] ··· 133 "nanoid@5.1.6" 134 ] 135 }, 136 - "@atcute/tid@1.0.3": { 137 - "integrity": "sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w==" 138 }, 139 "@atcute/uint8array@1.0.6": { 140 "integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==" ··· 143 "integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==", 144 "dependencies": [ 145 "@badrap/valita" 146 ] 147 }, 148 "@badrap/valita@0.4.6": { ··· 278 "os": ["win32"], 279 "cpu": ["x64"] 280 }, 281 - "@eslint-community/eslint-utils@4.9.0_eslint@9.39.2": { 282 - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", 283 "dependencies": [ 284 "eslint", 285 "eslint-visitor-keys@3.4.3" ··· 717 "vite" 718 ] 719 }, 720 - "@tutorlatin/svelte-tiny-virtual-list@3.0.17_svelte@5.46.1__acorn@8.15.0": { 721 - "integrity": "sha512-OvFRITfbWdsFk7VR2FKVJiBMPlgbyc81hqbFORXdEcBXcT91XRdLXfhSbS8o14ntUBMFPWVv19fti+Ez50q45g==", 722 "dependencies": [ 723 "svelte" 724 ] ··· 732 "@types/json-schema@7.0.15": { 733 "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" 734 }, 735 "@types/node@25.0.3": { 736 "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", 737 "dependencies": [ 738 - "undici-types" 739 ] 740 }, 741 - "@typescript-eslint/eslint-plugin@8.50.1_@typescript-eslint+parser@8.50.1__eslint@9.39.2__typescript@5.9.3_eslint@9.39.2_typescript@5.9.3": { 742 - "integrity": "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==", 743 "dependencies": [ 744 "@eslint-community/regexpp", 745 "@typescript-eslint/parser", ··· 754 "typescript" 755 ] 756 }, 757 - "@typescript-eslint/parser@8.50.1_eslint@9.39.2_typescript@5.9.3": { 758 - "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", 759 "dependencies": [ 760 "@typescript-eslint/scope-manager", 761 "@typescript-eslint/types", ··· 766 "typescript" 767 ] 768 }, 769 - "@typescript-eslint/project-service@8.50.1_typescript@5.9.3": { 770 - "integrity": "sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==", 771 "dependencies": [ 772 "@typescript-eslint/tsconfig-utils", 773 "@typescript-eslint/types", ··· 775 "typescript" 776 ] 777 }, 778 - "@typescript-eslint/scope-manager@8.50.1": { 779 - "integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==", 780 "dependencies": [ 781 "@typescript-eslint/types", 782 "@typescript-eslint/visitor-keys" 783 ] 784 }, 785 - "@typescript-eslint/tsconfig-utils@8.50.1_typescript@5.9.3": { 786 - "integrity": "sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==", 787 "dependencies": [ 788 "typescript" 789 ] 790 }, 791 - "@typescript-eslint/type-utils@8.50.1_eslint@9.39.2_typescript@5.9.3": { 792 - "integrity": "sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==", 793 "dependencies": [ 794 "@typescript-eslint/types", 795 "@typescript-eslint/typescript-estree", ··· 800 "typescript" 801 ] 802 }, 803 - "@typescript-eslint/types@8.50.1": { 804 - "integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==" 805 }, 806 - "@typescript-eslint/typescript-estree@8.50.1_typescript@5.9.3": { 807 - "integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==", 808 "dependencies": [ 809 "@typescript-eslint/project-service", 810 "@typescript-eslint/tsconfig-utils", ··· 818 "typescript" 819 ] 820 }, 821 - "@typescript-eslint/utils@8.50.1_eslint@9.39.2_typescript@5.9.3": { 822 - "integrity": "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==", 823 "dependencies": [ 824 "@eslint-community/eslint-utils", 825 "@typescript-eslint/scope-manager", ··· 829 "typescript" 830 ] 831 }, 832 - "@typescript-eslint/visitor-keys@8.50.1": { 833 - "integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==", 834 "dependencies": [ 835 "@typescript-eslint/types", 836 "eslint-visitor-keys@4.2.1" ··· 1100 "eslint-visitor-keys@4.2.1" 1101 ] 1102 }, 1103 - "esquery@1.6.0": { 1104 - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", 1105 "dependencies": [ 1106 "estraverse" 1107 ] ··· 1184 }, 1185 "globals@16.5.0": { 1186 "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==" 1187 }, 1188 "graceful-fs@4.2.11": { 1189 "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" ··· 1410 "natural-compare@1.4.0": { 1411 "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" 1412 }, 1413 "obliterator@2.0.5": { 1414 "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==" 1415 }, ··· 1453 }, 1454 "path-key@3.1.1": { 1455 "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" 1456 }, 1457 "picocolors@1.1.1": { 1458 "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" ··· 1701 "totalist@3.0.1": { 1702 "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" 1703 }, 1704 - "ts-api-utils@2.1.0_typescript@5.9.3": { 1705 - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", 1706 "dependencies": [ 1707 "typescript" 1708 ] ··· 1719 "type-fest@4.41.0": { 1720 "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==" 1721 }, 1722 - "typescript-eslint@8.50.1_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.50.1__eslint@9.39.2__typescript@5.9.3": { 1723 - "integrity": "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==", 1724 "dependencies": [ 1725 "@typescript-eslint/eslint-plugin", 1726 "@typescript-eslint/parser", ··· 1734 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1735 "bin": true 1736 }, 1737 "undici-types@7.16.0": { 1738 "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" 1739 }, 1740 "uri-js@4.4.1": { 1741 "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", 1742 "dependencies": [ ··· 1749 "vite@7.3.0_@types+node@25.0.3_picomatch@4.0.3": { 1750 "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", 1751 "dependencies": [ 1752 - "@types/node", 1753 "esbuild", 1754 "fdir", 1755 "picomatch", ··· 1761 "fsevents" 1762 ], 1763 "optionalPeers": [ 1764 - "@types/node" 1765 ], 1766 "bin": true 1767 }, ··· 1800 "workspace": { 1801 "packageJson": { 1802 "dependencies": [ 1803 - "npm:@atcute/atproto@^3.1.9", 1804 "npm:@atcute/bluesky-richtext-builder@^2.0.4", 1805 "npm:@atcute/bluesky-richtext-segmenter@^2.0.4", 1806 "npm:@atcute/bluesky@^3.2.14", 1807 - "npm:@atcute/client@^4.1.1", 1808 "npm:@atcute/identity-resolver@^1.2.1", 1809 "npm:@atcute/identity@^1.1.3", 1810 "npm:@atcute/jetstream@^1.1.2", 1811 - "npm:@atcute/lexicons@^1.2.5", 1812 "npm:@atcute/oauth-browser-client@^2.0.3", 1813 - "npm:@atcute/tid@^1.0.3", 1814 "npm:@eslint/compat@2", 1815 "npm:@eslint/js@^9.39.2", 1816 "npm:@floating-ui/dom@^1.7.4", ··· 1821 "npm:@sveltejs/vite-plugin-svelte@^6.2.1", 1822 "npm:@tailwindcss/forms@~0.5.11", 1823 "npm:@tailwindcss/vite@^4.1.18", 1824 - "npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.17", 1825 "npm:@types/node@^25.0.3", 1826 "npm:@wora/cache-persist@^2.2.1", 1827 "npm:async-cache-dedupe@^3.4.0", 1828 "npm:eslint-config-prettier@^10.1.8", 1829 "npm:eslint-plugin-svelte@^3.13.1", 1830 "npm:eslint@^9.39.2", 1831 - "npm:globals@^16.5.0", 1832 "npm:hash-wasm@^4.12.0", 1833 "npm:lru-cache@^11.2.4", 1834 "npm:prettier-plugin-svelte@^3.4.1", 1835 "npm:prettier-plugin-tailwindcss@~0.7.2", 1836 "npm:prettier@^3.7.4", ··· 1841 "npm:svelte-portal@^2.2.1", 1842 "npm:svelte@^5.46.1", 1843 "npm:tailwindcss@^4.1.18", 1844 - "npm:typescript-eslint@^8.50.1", 1845 "npm:typescript@^5.9.3", 1846 "npm:vite@^7.3.0" 1847 ]
··· 1 { 2 "version": "5", 3 "specifiers": { 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", 7 "npm:@atcute/bluesky@^3.2.14": "3.2.14", 8 + "npm:@atcute/client@^4.2.0": "4.2.0", 9 "npm:@atcute/identity-resolver@^1.2.1": "1.2.1_@atcute+identity@1.1.3", 10 "npm:@atcute/identity@^1.1.3": "1.1.3", 11 "npm:@atcute/jetstream@^1.1.2": "1.1.2", 12 + "npm:@atcute/lexicons@^1.2.6": "1.2.6", 13 "npm:@atcute/oauth-browser-client@^2.0.3": "2.0.3_@atcute+identity@1.1.3", 14 + "npm:@atcute/tid@^1.1.1": "1.1.1", 15 "npm:@eslint/compat@2": "2.0.0_eslint@9.39.2", 16 "npm:@eslint/js@^9.39.2": "9.39.2", 17 "npm:@floating-ui/dom@^1.7.4": "1.7.4", ··· 22 "npm:@sveltejs/vite-plugin-svelte@^6.2.1": "6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3", 23 "npm:@tailwindcss/forms@~0.5.11": "0.5.11_tailwindcss@4.1.18", 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", 26 "npm:@types/node@^25.0.3": "25.0.3", 27 "npm:@wora/cache-persist@^2.2.1": "2.2.1", 28 "npm:async-cache-dedupe@^3.4.0": "3.4.0", 29 "npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.39.2", 30 "npm:eslint-plugin-svelte@^3.13.1": "3.13.1_eslint@9.39.2_svelte@5.46.1__acorn@8.15.0_postcss@8.5.6", 31 "npm:eslint@^9.39.2": "9.39.2", 32 + "npm:globals@17": "17.0.0", 33 "npm:hash-wasm@^4.12.0": "4.12.0", 34 "npm:lru-cache@^11.2.4": "11.2.4", 35 + "npm:photoswipe@^5.4.4": "5.4.4", 36 "npm:prettier-plugin-svelte@^3.4.1": "3.4.1_prettier@3.7.4_svelte@5.46.1__acorn@8.15.0", 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", 38 "npm:prettier@^3.7.4": "3.7.4", ··· 43 "npm:svelte-portal@^2.2.1": "2.2.1", 44 "npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0", 45 "npm:tailwindcss@^4.1.18": "4.1.18", 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", 47 "npm:typescript@^5.9.3": "5.9.3", 48 "npm:vite@^7.3.0": "7.3.0_@types+node@25.0.3_picomatch@4.0.3" 49 }, 50 "npm": { 51 + "@atcute/atproto@3.1.10": { 52 + "integrity": "sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==", 53 "dependencies": [ 54 "@atcute/lexicons" 55 ] ··· 75 "@atcute/lexicons" 76 ] 77 }, 78 + "@atcute/client@4.2.0": { 79 + "integrity": "sha512-vYixpXevM+dkzN4HGTfmxCJBOXNSRAMki1bfi1atFdmZGo9n1zStyClHqn0SRs8I5nOQoVG1XBkYAGSFJWqy2Q==", 80 "dependencies": [ 81 "@atcute/identity", 82 "@atcute/lexicons" ··· 110 "yocto-queue@1.2.2" 111 ] 112 }, 113 + "@atcute/lexicons@1.2.6": { 114 + "integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==", 115 "dependencies": [ 116 + "@atcute/uint8array", 117 + "@atcute/util-text", 118 "@standard-schema/spec", 119 "esm-env" 120 ] ··· 136 "nanoid@5.1.6" 137 ] 138 }, 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 152 }, 153 "@atcute/uint8array@1.0.6": { 154 "integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==" ··· 157 "integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==", 158 "dependencies": [ 159 "@badrap/valita" 160 + ] 161 + }, 162 + "@atcute/util-text@0.0.1": { 163 + "integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==", 164 + "dependencies": [ 165 + "unicode-segmenter" 166 ] 167 }, 168 "@badrap/valita@0.4.6": { ··· 298 "os": ["win32"], 299 "cpu": ["x64"] 300 }, 301 + "@eslint-community/eslint-utils@4.9.1_eslint@9.39.2": { 302 + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", 303 "dependencies": [ 304 "eslint", 305 "eslint-visitor-keys@3.4.3" ··· 737 "vite" 738 ] 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 ] ··· 752 "@types/json-schema@7.0.15": { 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 + ] 760 + }, 761 "@types/node@25.0.3": { 762 "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", 763 "dependencies": [ 764 + "undici-types@7.16.0" 765 ] 766 }, 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==", 769 "dependencies": [ 770 "@eslint-community/regexpp", 771 "@typescript-eslint/parser", ··· 780 "typescript" 781 ] 782 }, 783 + "@typescript-eslint/parser@8.51.0_eslint@9.39.2_typescript@5.9.3": { 784 + "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", 785 "dependencies": [ 786 "@typescript-eslint/scope-manager", 787 "@typescript-eslint/types", ··· 792 "typescript" 793 ] 794 }, 795 + "@typescript-eslint/project-service@8.51.0_typescript@5.9.3": { 796 + "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==", 797 "dependencies": [ 798 "@typescript-eslint/tsconfig-utils", 799 "@typescript-eslint/types", ··· 801 "typescript" 802 ] 803 }, 804 + "@typescript-eslint/scope-manager@8.51.0": { 805 + "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", 806 "dependencies": [ 807 "@typescript-eslint/types", 808 "@typescript-eslint/visitor-keys" 809 ] 810 }, 811 + "@typescript-eslint/tsconfig-utils@8.51.0_typescript@5.9.3": { 812 + "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==", 813 "dependencies": [ 814 "typescript" 815 ] 816 }, 817 + "@typescript-eslint/type-utils@8.51.0_eslint@9.39.2_typescript@5.9.3": { 818 + "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==", 819 "dependencies": [ 820 "@typescript-eslint/types", 821 "@typescript-eslint/typescript-estree", ··· 826 "typescript" 827 ] 828 }, 829 + "@typescript-eslint/types@8.51.0": { 830 + "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==" 831 }, 832 + "@typescript-eslint/typescript-estree@8.51.0_typescript@5.9.3": { 833 + "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", 834 "dependencies": [ 835 "@typescript-eslint/project-service", 836 "@typescript-eslint/tsconfig-utils", ··· 844 "typescript" 845 ] 846 }, 847 + "@typescript-eslint/utils@8.51.0_eslint@9.39.2_typescript@5.9.3": { 848 + "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==", 849 "dependencies": [ 850 "@eslint-community/eslint-utils", 851 "@typescript-eslint/scope-manager", ··· 855 "typescript" 856 ] 857 }, 858 + "@typescript-eslint/visitor-keys@8.51.0": { 859 + "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", 860 "dependencies": [ 861 "@typescript-eslint/types", 862 "eslint-visitor-keys@4.2.1" ··· 1126 "eslint-visitor-keys@4.2.1" 1127 ] 1128 }, 1129 + "esquery@1.7.0": { 1130 + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", 1131 "dependencies": [ 1132 "estraverse" 1133 ] ··· 1210 }, 1211 "globals@16.5.0": { 1212 "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==" 1213 + }, 1214 + "globals@17.0.0": { 1215 + "integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==" 1216 }, 1217 "graceful-fs@4.2.11": { 1218 "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" ··· 1439 "natural-compare@1.4.0": { 1440 "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" 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 }, ··· 1486 }, 1487 "path-key@3.1.1": { 1488 "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" 1489 + }, 1490 + "photoswipe@5.4.4": { 1491 + "integrity": "sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==" 1492 }, 1493 "picocolors@1.1.1": { 1494 "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" ··· 1737 "totalist@3.0.1": { 1738 "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" 1739 }, 1740 + "ts-api-utils@2.4.0_typescript@5.9.3": { 1741 + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", 1742 "dependencies": [ 1743 "typescript" 1744 ] ··· 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==", 1760 "dependencies": [ 1761 "@typescript-eslint/eslint-plugin", 1762 "@typescript-eslint/parser", ··· 1770 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1771 "bin": true 1772 }, 1773 + "undici-types@6.21.0": { 1774 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" 1775 + }, 1776 "undici-types@7.16.0": { 1777 "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" 1778 }, 1779 + "unicode-segmenter@0.14.5": { 1780 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" 1781 + }, 1782 "uri-js@4.4.1": { 1783 "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", 1784 "dependencies": [ ··· 1791 "vite@7.3.0_@types+node@25.0.3_picomatch@4.0.3": { 1792 "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", 1793 "dependencies": [ 1794 + "@types/node@25.0.3", 1795 "esbuild", 1796 "fdir", 1797 "picomatch", ··· 1803 "fsevents" 1804 ], 1805 "optionalPeers": [ 1806 + "@types/node@25.0.3" 1807 ], 1808 "bin": true 1809 }, ··· 1842 "workspace": { 1843 "packageJson": { 1844 "dependencies": [ 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", 1848 "npm:@atcute/bluesky@^3.2.14", 1849 + "npm:@atcute/client@^4.2.0", 1850 "npm:@atcute/identity-resolver@^1.2.1", 1851 "npm:@atcute/identity@^1.1.3", 1852 "npm:@atcute/jetstream@^1.1.2", 1853 + "npm:@atcute/lexicons@^1.2.6", 1854 "npm:@atcute/oauth-browser-client@^2.0.3", 1855 + "npm:@atcute/tid@^1.1.1", 1856 "npm:@eslint/compat@2", 1857 "npm:@eslint/js@^9.39.2", 1858 "npm:@floating-ui/dom@^1.7.4", ··· 1863 "npm:@sveltejs/vite-plugin-svelte@^6.2.1", 1864 "npm:@tailwindcss/forms@~0.5.11", 1865 "npm:@tailwindcss/vite@^4.1.18", 1866 + "npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.18", 1867 "npm:@types/node@^25.0.3", 1868 "npm:@wora/cache-persist@^2.2.1", 1869 "npm:async-cache-dedupe@^3.4.0", 1870 "npm:eslint-config-prettier@^10.1.8", 1871 "npm:eslint-plugin-svelte@^3.13.1", 1872 "npm:eslint@^9.39.2", 1873 + "npm:globals@17", 1874 "npm:hash-wasm@^4.12.0", 1875 "npm:lru-cache@^11.2.4", 1876 + "npm:photoswipe@^5.4.4", 1877 "npm:prettier-plugin-svelte@^3.4.1", 1878 "npm:prettier-plugin-tailwindcss@~0.7.2", 1879 "npm:prettier@^3.7.4", ··· 1884 "npm:svelte-portal@^2.2.1", 1885 "npm:svelte@^5.46.1", 1886 "npm:tailwindcss@^4.1.18", 1887 + "npm:typescript-eslint@^8.51.0", 1888 "npm:typescript@^5.9.3", 1889 "npm:vite@^7.3.0" 1890 ]
+2 -2
nix/modules.nix
··· 14 ]; 15 }; 16 17 - outputHash = "sha256-oZKRCeIxjkv8Iujo82FaumsEnpt8pSqoYDbibmPgmZA="; 18 outputHashAlgo = "sha256"; 19 outputHashMode = "recursive"; 20 21 - nativeBuildInputs = [deno]; 22 23 dontConfigure = true; 24 dontCheck = true;
··· 14 ]; 15 }; 16 17 + outputHash = "sha256-1AkU6eV0uIUZohotHhd8E5eAwc4E4wwg2SjHVUdX8LE="; 18 outputHashAlgo = "sha256"; 19 outputHashMode = "recursive"; 20 21 + nativeBuildInputs = [ deno ]; 22 23 dontConfigure = true; 24 dontCheck = true;
+8 -7
package.json
··· 14 "lint": "prettier --check . && eslint ." 15 }, 16 "dependencies": { 17 - "@atcute/atproto": "^3.1.9", 18 "@atcute/bluesky": "^3.2.14", 19 "@atcute/bluesky-richtext-builder": "^2.0.4", 20 "@atcute/bluesky-richtext-segmenter": "^2.0.4", 21 - "@atcute/client": "^4.1.1", 22 "@atcute/identity": "^1.1.3", 23 "@atcute/identity-resolver": "^1.2.1", 24 "@atcute/jetstream": "^1.1.2", 25 - "@atcute/lexicons": "^1.2.5", 26 "@atcute/oauth-browser-client": "^2.0.3", 27 - "@atcute/tid": "^1.0.3", 28 "@floating-ui/dom": "^1.7.4", 29 "@soffinal/websocket": "^0.2.1", 30 - "@tutorlatin/svelte-tiny-virtual-list": "^3.0.17", 31 "@wora/cache-persist": "^2.2.1", 32 "async-cache-dedupe": "^3.4.0", 33 "hash-wasm": "^4.12.0", 34 "lru-cache": "^11.2.4", 35 "svelte-device-info": "^1.0.6", 36 "svelte-infinite": "^0.5.1", 37 "svelte-portal": "^2.2.1" ··· 49 "eslint": "^9.39.2", 50 "eslint-config-prettier": "^10.1.8", 51 "eslint-plugin-svelte": "^3.13.1", 52 - "globals": "^16.5.0", 53 "prettier": "^3.7.4", 54 "prettier-plugin-svelte": "^3.4.1", 55 "prettier-plugin-tailwindcss": "^0.7.2", ··· 58 "svelte-check": "^4.3.5", 59 "tailwindcss": "^4.1.18", 60 "typescript": "^5.9.3", 61 - "typescript-eslint": "^8.50.1", 62 "vite": "^7.3.0" 63 } 64 }
··· 14 "lint": "prettier --check . && eslint ." 15 }, 16 "dependencies": { 17 + "@atcute/atproto": "^3.1.10", 18 "@atcute/bluesky": "^3.2.14", 19 "@atcute/bluesky-richtext-builder": "^2.0.4", 20 "@atcute/bluesky-richtext-segmenter": "^2.0.4", 21 + "@atcute/client": "^4.2.0", 22 "@atcute/identity": "^1.1.3", 23 "@atcute/identity-resolver": "^1.2.1", 24 "@atcute/jetstream": "^1.1.2", 25 + "@atcute/lexicons": "^1.2.6", 26 "@atcute/oauth-browser-client": "^2.0.3", 27 + "@atcute/tid": "^1.1.1", 28 "@floating-ui/dom": "^1.7.4", 29 "@soffinal/websocket": "^0.2.1", 30 + "@tutorlatin/svelte-tiny-virtual-list": "^3.0.18", 31 "@wora/cache-persist": "^2.2.1", 32 "async-cache-dedupe": "^3.4.0", 33 "hash-wasm": "^4.12.0", 34 "lru-cache": "^11.2.4", 35 + "photoswipe": "^5.4.4", 36 "svelte-device-info": "^1.0.6", 37 "svelte-infinite": "^0.5.1", 38 "svelte-portal": "^2.2.1" ··· 50 "eslint": "^9.39.2", 51 "eslint-config-prettier": "^10.1.8", 52 "eslint-plugin-svelte": "^3.13.1", 53 + "globals": "^17.0.0", 54 "prettier": "^3.7.4", 55 "prettier-plugin-svelte": "^3.4.1", 56 "prettier-plugin-tailwindcss": "^0.7.2", ··· 59 "svelte-check": "^4.3.5", 60 "tailwindcss": "^4.1.18", 61 "typescript": "^5.9.3", 62 + "typescript-eslint": "^8.51.0", 63 "vite": "^7.3.0" 64 } 65 }
+4
src/app.css
··· 125 .animate-slide-in-left { 126 animation: slide-in-from-left 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; 127 }
··· 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 + }
+1 -1
src/components/AccountSelector.svelte
··· 1 <script lang="ts"> 2 import { generateColorForDid, loggingIn, type Account } from '$lib/accounts'; 3 - import { AtpClient, resolveHandle } from '$lib/at/client'; 4 import type { Handle } from '@atcute/lexicons'; 5 import ProfilePicture from './ProfilePicture.svelte'; 6 import PfpPlaceholder from './PfpPlaceholder.svelte';
··· 1 <script lang="ts"> 2 import { generateColorForDid, loggingIn, type Account } from '$lib/accounts'; 3 + import { AtpClient, resolveHandle } from '$lib/at/client.svelte'; 4 import type { Handle } from '@atcute/lexicons'; 5 import ProfilePicture from './ProfilePicture.svelte'; 6 import PfpPlaceholder from './PfpPlaceholder.svelte';
+36
src/components/BlockedUserIndicator.svelte
···
··· 1 + <script lang="ts"> 2 + import type { Did } from '@atcute/lexicons'; 3 + import ProfilePicture from './ProfilePicture.svelte'; 4 + import type { AtpClient } from '$lib/at/client.svelte'; 5 + import { generateColorForDid } from '$lib/accounts'; 6 + 7 + interface Props { 8 + client: AtpClient; 9 + did: Did; 10 + reason: 'blocked' | 'blocks-you'; 11 + size?: 'small' | 'normal' | 'large'; 12 + } 13 + 14 + let { client, did, reason, size = 'normal' }: Props = $props(); 15 + 16 + const color = $derived(generateColorForDid(did)); 17 + const text = $derived(reason === 'blocked' ? 'user blocked' : 'user blocks you'); 18 + const pfpSize = $derived(size === 'small' ? 8 : size === 'large' ? 16 : 10); 19 + </script> 20 + 21 + <div 22 + class="flex items-center gap-2 rounded-sm border-2 p-2 {size === 'small' ? 'text-sm' : ''}" 23 + style="background: {color}11; border-color: {color}44;" 24 + > 25 + <div class="blocked-pfp"> 26 + <ProfilePicture {client} {did} size={pfpSize} /> 27 + </div> 28 + <span class="opacity-80">{text}</span> 29 + </div> 30 + 31 + <style> 32 + .blocked-pfp { 33 + filter: blur(8px) grayscale(100%); 34 + opacity: 0.4; 35 + } 36 + </style>
+231 -268
src/components/BskyPost.svelte
··· 1 <script lang="ts"> 2 - import { resolveDidDoc, type AtpClient } from '$lib/at/client'; 3 - import { 4 - AppBskyActorProfile, 5 - AppBskyEmbedExternal, 6 - AppBskyEmbedImages, 7 - AppBskyEmbedVideo, 8 - AppBskyFeedPost 9 - } from '@atcute/bluesky'; 10 import { 11 parseCanonicalResourceUri, 12 - type ActorIdentifier, 13 - type CanonicalResourceUri, 14 type Did, 15 type RecordKey, 16 type ResourceUri 17 } from '@atcute/lexicons'; 18 import { expect, ok } from '$lib/result'; 19 - import { generateColorForDid } from '$lib/accounts'; 20 import ProfilePicture from './ProfilePicture.svelte'; 21 - import { isBlob } from '@atcute/lexicons/interfaces'; 22 - import { blob, img } from '$lib/cdn'; 23 import BskyPost from './BskyPost.svelte'; 24 import Icon from '@iconify/svelte'; 25 import { 26 - clients, 27 allPosts, 28 pulsingPostId, 29 currentTime, 30 - findBacklinksBy, 31 deletePostBacklink, 32 - createPostBacklink 33 } from '$lib/state.svelte'; 34 import type { PostWithUri } from '$lib/at/fetch'; 35 - import { onMount } from 'svelte'; 36 - import { type AtprotoDid } from '@atcute/lexicons/syntax'; 37 import { derived } from 'svelte/store'; 38 - import Device from 'svelte-device-info'; 39 import Dropdown from './Dropdown.svelte'; 40 - import { type AppBskyEmbeds } from '$lib/at/types'; 41 import { settings } from '$lib/settings'; 42 import RichText from './RichText.svelte'; 43 import { getRelativeTime } from '$lib/date'; 44 - import { likeSource, repostSource } from '$lib'; 45 46 interface Props { 47 client: AtpClient; ··· 55 isOnPostComposer?: boolean; 56 onQuote?: (quote: PostWithUri) => void; 57 onReply?: (reply: PostWithUri) => void; 58 } 59 60 const { ··· 66 mini, 67 onQuote, 68 onReply, 69 - isOnPostComposer = false /* replyBacklinks */ 70 }: Props = $props(); 71 72 - const selectedDid = $derived(client.user?.did ?? null); 73 - const actionClient = $derived(clients.get(did as AtprotoDid)); 74 75 - const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`; 76 - const color = generateColorForDid(did); 77 78 - let handle: ActorIdentifier = $state(did); 79 - const didDoc = resolveDidDoc(did).then((res) => { 80 - if (res.ok) handle = res.value.handle; 81 - return res; 82 }); 83 const post = data 84 ? Promise.resolve(ok(data)) 85 : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey); 86 - let profile: AppBskyActorProfile.Main | null = $state(null); 87 onMount(async () => { 88 const p = await client.getProfile(did); 89 if (!p.ok) return; 90 profile = p.value; 91 - // console.log(profile.description); 92 }); 93 94 - const postId = `timeline-post-${aturi}-${quoteDepth}`; 95 const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); 96 97 const scrollToAndPulse = (targetUri: ResourceUri) => { 98 const targetId = `timeline-post-${targetUri}-0`; 99 - // console.log(`Scrolling to ${targetId}`); 100 const element = document.getElementById(targetId); 101 if (!element) return; 102 ··· 108 generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo) 109 ); 110 pulsingPostId.set(targetId); 111 - // Clear pulse after animation 112 setTimeout(() => pulsingPostId.set(null), 1200); 113 }, 400); 114 }; 115 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 let actionsOpen = $state(false); 134 let actionsPos = $state({ x: 0, y: 0 }); 135 ··· 152 return; 153 } 154 155 - actionClient?.atcute 156 - ?.post('com.atproto.repo.deleteRecord', { 157 input: { 158 collection: 'app.bsky.feed.post', 159 repo: did, ··· 169 }; 170 171 let profileOpen = $state(false); 172 - let profilePopoutShowDid = $state(false); 173 </script> 174 175 - {#snippet embedBadge(embed: AppBskyEmbeds)} 176 - <span 177 - class="rounded-full px-2.5 py-0.5 text-xs font-medium" 178 - style=" 179 - background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent); 180 - color: {mini ? 'var(--nucleus-fg)' : color}; 181 - " 182 - > 183 - {getEmbedText(embed.$type!)} 184 - </span> 185 - {/snippet} 186 - 187 {#snippet profileInline()} 188 <button 189 class=" 190 - flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-200' : ''} 191 rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10 192 " 193 style="color: {color};" 194 - onclick={() => (profileOpen = !profileOpen)} 195 > 196 <ProfilePicture {client} {did} size={8} /> 197 198 {#if profile} 199 <span class="w-min max-w-sm min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 200 - >{profile.displayName}</span 201 ><span class="shrink-0 text-sm text-nowrap opacity-70">(@{handle})</span> 202 {:else} 203 {handle} ··· 205 </button> 206 {/snippet} 207 208 - <!-- eslint-disable svelte/no-navigation-without-resolve --> 209 {#snippet profilePopout()} 210 - {@const profileDesc = profile?.description?.trim() ?? ''} 211 <Dropdown 212 class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!" 213 style="background: {color}36; border-color: {color}99;" 214 bind:isOpen={profileOpen} 215 trigger={profileInline} 216 > 217 - <div class="flex items-center gap-2"> 218 - <ProfilePicture {client} {did} size={20} /> 219 - 220 - <div class="flex flex-col items-start overflow-hidden overflow-ellipsis"> 221 - <span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis"> 222 - {profile?.displayName ?? handle} 223 - {#if profile?.pronouns} 224 - <span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span> 225 - {/if} 226 - </span> 227 - <button 228 - oncontextmenu={(e) => { 229 - const node = e.target as Node; 230 - const selection = window.getSelection() ?? new Selection(); 231 - const range = document.createRange(); 232 - range.selectNodeContents(node); 233 - selection.removeAllRanges(); 234 - selection.addRange(range); 235 - e.stopPropagation(); 236 - }} 237 - onclick={() => (profilePopoutShowDid = !profilePopoutShowDid)} 238 - class="mb-0.5 text-nowrap opacity-85 select-text hover:underline" 239 - > 240 - {profilePopoutShowDid ? did : `@${handle}`} 241 - </button> 242 - {#if profile?.website} 243 - <a 244 - target="_blank" 245 - rel="noopener noreferrer" 246 - href={profile.website} 247 - class="text-sm text-nowrap opacity-60">{profile.website}</a 248 - > 249 - {/if} 250 - </div> 251 - </div> 252 - 253 - {#if profileDesc.length > 0} 254 - <p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word"> 255 - <RichText text={profileDesc} /> 256 - </p> 257 - {/if} 258 </Dropdown> 259 {/snippet} 260 ··· 265 {:then post} 266 {#if post.ok} 267 {@const record = post.value.record} 268 - <!-- svelte-ignore a11y_click_events_have_key_events --> 269 - <!-- svelte-ignore a11y_no_static_element_interactions --> 270 - <div 271 - onclick={() => scrollToAndPulse(post.value.uri)} 272 - class="select-none hover:cursor-pointer hover:underline" 273 - > 274 - <span style="color: {color};">@{handle}</span>: 275 - {#if record.embed} 276 - {@render embedBadge(record.embed)} 277 - {/if} 278 - <span title={record.text}>{record.text}</span> 279 - </div> 280 {:else} 281 {post.error} 282 {/if} ··· 299 {:then post} 300 {#if post.ok} 301 {@const record = post.value.record} 302 - <!-- svelte-ignore a11y_no_static_element_interactions --> 303 - <div 304 - id="timeline-post-{post.value.uri}-{quoteDepth}" 305 - oncontextmenu={handleRightClick} 306 - class=" 307 group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all 308 {$isPulsing ? 'animate-pulse-highlight' : ''} 309 {isOnPostComposer ? 'backdrop-brightness-20' : ''} 310 " 311 - style=" 312 background: {color}{isOnPostComposer 313 - ? '36' 314 - : Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)}; 315 border-color: {color}{isOnPostComposer ? '99' : '66'}; 316 " 317 - > 318 - <div 319 - class=" 320 - mb-3 flex w-fit max-w-full items-center gap-1 rounded-sm pr-1 321 - " 322 - style="background: {color}33;" 323 > 324 - {@render profilePopout()} 325 - <span>ยท</span> 326 - <span 327 - title={new Date(record.createdAt).toLocaleString()} 328 - class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" 329 - > 330 - {getRelativeTime(new Date(record.createdAt), currentTime)} 331 - </span> 332 </div> 333 - <p class="leading-normal text-wrap wrap-break-word"> 334 - <RichText text={record.text} facets={record.facets ?? []} /> 335 - {#if isOnPostComposer && record.embed} 336 - {@render embedBadge(record.embed)} 337 - {/if} 338 - </p> 339 - {#if !isOnPostComposer && record.embed} 340 - {@const embed = record.embed} 341 - <div class="mt-2"> 342 - {@render postEmbed(embed)} 343 - </div> 344 - {/if} 345 - {#if !isOnPostComposer} 346 - {@render postControls(post.value)} 347 - {/if} 348 - </div> 349 {:else} 350 <div class="error-disclaimer"> 351 <p class="text-sm font-medium">error: {post.error}</p> ··· 354 {/await} 355 {/if} 356 357 - {#snippet postEmbed(embed: AppBskyEmbeds)} 358 - {#snippet embedMedia( 359 - embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main 360 - )} 361 - <!-- svelte-ignore a11y_no_static_element_interactions --> 362 - <div oncontextmenu={(e) => e.stopPropagation()}> 363 - {#if embed.$type === 'app.bsky.embed.images'} 364 - <!-- todo: improve how images are displayed, and pop out on click --> 365 - {#each embed.images as image (image.image)} 366 - {#if isBlob(image.image)} 367 - <img 368 - class="w-full rounded-sm" 369 - src={img('feed_thumbnail', did, image.image.ref.$link)} 370 - alt={image.alt} 371 - /> 372 - {/if} 373 - {/each} 374 - {:else if embed.$type === 'app.bsky.embed.video'} 375 - {#if isBlob(embed.video)} 376 - {#await didDoc then didDoc} 377 - {#if didDoc.ok} 378 - <!-- svelte-ignore a11y_media_has_caption --> 379 - <video 380 - class="rounded-sm" 381 - src={blob(didDoc.value.pds, did, embed.video.ref.$link)} 382 - controls 383 - ></video> 384 - {/if} 385 - {/await} 386 - {/if} 387 - {/if} 388 - </div> 389 - {/snippet} 390 - {#snippet embedPost(uri: ResourceUri)} 391 - {#if quoteDepth < 2} 392 - {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 393 - <!-- reject recursive quotes --> 394 - {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 395 <BskyPost 396 {client} 397 quoteDepth={quoteDepth + 1} ··· 401 {onQuote} 402 {onReply} 403 /> 404 - {:else} 405 - <span>you think you're funny with that recursive quote but i'm onto you</span> 406 {/if} 407 {:else} 408 - {@render embedBadge(embed)} 409 {/if} 410 - {/snippet} 411 - {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} 412 - {@render embedMedia(embed)} 413 - {:else if embed.$type === 'app.bsky.embed.record'} 414 - {@render embedPost(embed.record.uri)} 415 - {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 416 - <div class="space-y-1.5"> 417 - {@render embedPost(embed.record.record.uri)} 418 - {@render embedMedia(embed.media)} 419 - </div> 420 {/if} 421 - <!-- todo: implement external link embeds --> 422 {/snippet} 423 424 {#snippet postControls(post: PostWithUri)} 425 - {@const myRepost = findBacklinksBy(post.uri, repostSource, selectedDid!).length > 0} 426 - {@const myLike = findBacklinksBy(post.uri, likeSource, selectedDid!).length > 0} 427 - {#snippet control( 428 - name: string, 429 - icon: string, 430 - onClick: (e: MouseEvent) => void, 431 - isFull?: boolean, 432 - hasSolid?: boolean 433 - )} 434 <button 435 class=" 436 - px-2 py-1.5 text-(--nucleus-fg)/90 transition-all 437 - duration-100 hover:[backdrop-filter:brightness(120%)] 438 " 439 onclick={(e) => onClick(e)} 440 - style="color: {isFull ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 441 title={name} 442 > 443 <Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} /> 444 </button> 445 {/snippet} 446 <div class="mt-3 flex w-full items-center justify-between"> 447 <div class="flex w-fit items-center rounded-sm" style="background: {color}1f;"> 448 - {@render control('reply', 'heroicons:chat-bubble-left', () => onReply?.(post), false, true)} 449 - {@render control( 450 - 'repost', 451 - 'heroicons:arrow-path-rounded-square-20-solid', 452 - () => { 453 - if (!selectedDid) return; 454 if (myRepost) deletePostBacklink(client, post, repostSource); 455 else createPostBacklink(client, post, repostSource); 456 }, 457 - myRepost 458 - )} 459 - {@render control('quote', 'heroicons:paper-clip-20-solid', () => onQuote?.(post), false)} 460 - {@render control( 461 - 'like', 462 - 'heroicons:star', 463 - () => { 464 - if (!selectedDid) return; 465 if (myLike) deletePostBacklink(client, post, likeSource); 466 else createPostBacklink(client, post, likeSource); 467 }, 468 - myLike, 469 - true 470 - )} 471 </div> 472 <Dropdown 473 class="post-dropdown" ··· 479 {@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () => 480 navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`) 481 )} 482 - {@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () => 483 navigator.clipboard.writeText(post.uri) 484 )} 485 {@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () => 486 navigator.clipboard.writeText(post.record.text) 487 )} 488 - {#if actionClient} 489 <div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div> 490 {@render dropdownItem( 491 deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid', ··· 497 {/if} 498 499 {#snippet trigger()} 500 - <div 501 - class=" 502 - w-fit items-center rounded-sm transition-opacity 503 - duration-100 ease-in-out group-hover:opacity-100 504 - {!actionsOpen && !Device.isMobile ? 'opacity-0' : ''} 505 - " 506 - style="background: {color}1f;" 507 - > 508 - {@render control('actions', 'heroicons:ellipsis-horizontal-16-solid', (e) => { 509 e.stopPropagation(); 510 actionsOpen = !actionsOpen; 511 actionsPos = { x: 0, y: 0 }; 512 - })} 513 - </div> 514 {/snippet} 515 </Dropdown> 516 </div> 517 {/snippet} 518 519 {#snippet dropdownItem( 520 - icon: string, 521 label: string, 522 onClick: () => void, 523 autoClose: boolean = true, ··· 534 if (autoClose) actionsOpen = false; 535 }} 536 > 537 - <span class="font-bold">{label}</span> 538 - <Icon class="h-6 w-6" {icon} /> 539 </button> 540 {/snippet} 541 - 542 - <style> 543 - @reference "../app.css"; 544 - 545 - :global(.post-dropdown) { 546 - @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60; 547 - } 548 - </style>
··· 1 <script lang="ts"> 2 + import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte'; 3 + import { AppBskyActorProfile, AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky'; 4 import { 5 parseCanonicalResourceUri, 6 type Did, 7 + type Handle, 8 type RecordKey, 9 type ResourceUri 10 } from '@atcute/lexicons'; 11 import { expect, ok } from '$lib/result'; 12 + import { accounts, generateColorForDid } from '$lib/accounts'; 13 import ProfilePicture from './ProfilePicture.svelte'; 14 import BskyPost from './BskyPost.svelte'; 15 import Icon from '@iconify/svelte'; 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 import type { PostWithUri } from '$lib/at/fetch'; 30 + import { onMount, type Snippet } from 'svelte'; 31 import { derived } from 'svelte/store'; 32 import Dropdown from './Dropdown.svelte'; 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'; 40 41 interface Props { 42 client: AtpClient; ··· 50 isOnPostComposer?: boolean; 51 onQuote?: (quote: PostWithUri) => void; 52 onReply?: (reply: PostWithUri) => void; 53 + cornerFragment?: Snippet; 54 + isBlocked?: boolean; 55 } 56 57 const { ··· 63 mini, 64 onQuote, 65 onReply, 66 + isOnPostComposer = false /* replyBacklinks */, 67 + cornerFragment, 68 + isBlocked = false 69 }: Props = $props(); 70 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)); 76 77 + let expandBlocked = $state(false); 78 + const blockRel = $derived( 79 + user && !isOnPostComposer 80 + ? getBlockRelationship(user.did, did) 81 + : { userBlocked: false, blockedByTarget: false } 82 + ); 83 + const showAsBlocked = $derived( 84 + (isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandBlocked 85 + ); 86 87 + let handle: Handle = $state(handles.get(did) ?? 'handle.invalid'); 88 + onMount(() => { 89 + resolveDidDoc(did).then((res) => { 90 + if (res.ok) { 91 + handle = res.value.handle; 92 + handles.set(did, handle); 93 + } 94 + return res; 95 + }); 96 }); 97 const post = data 98 ? Promise.resolve(ok(data)) 99 : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey); 100 + let profile: AppBskyActorProfile.Main | null = $state(profiles.get(did) ?? null); 101 onMount(async () => { 102 const p = await client.getProfile(did); 103 if (!p.ok) return; 104 profile = p.value; 105 + profiles.set(did, profile); 106 }); 107 108 + const postId = $derived( 109 + `timeline-post-${did.replace(/[^a-zA-Z0-9]/g, '_')}-${rkey}-${quoteDepth}` 110 + ); 111 const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); 112 113 const scrollToAndPulse = (targetUri: ResourceUri) => { 114 const targetId = `timeline-post-${targetUri}-0`; 115 const element = document.getElementById(targetId); 116 if (!element) return; 117 ··· 123 generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo) 124 ); 125 pulsingPostId.set(targetId); 126 setTimeout(() => pulsingPostId.set(null), 1200); 127 }, 400); 128 }; 129 130 let actionsOpen = $state(false); 131 let actionsPos = $state({ x: 0, y: 0 }); 132 ··· 149 return; 150 } 151 152 + clients 153 + .get(did) 154 + ?.user?.atcute.post('com.atproto.repo.deleteRecord', { 155 input: { 156 collection: 'app.bsky.feed.post', 157 repo: did, ··· 167 }; 168 169 let profileOpen = $state(false); 170 </script> 171 172 {#snippet profileInline()} 173 <button 174 class=" 175 + flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-125' : ''} 176 rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10 177 " 178 style="color: {color};" 179 + onclick={() => ((profileOpen = false), router.navigate(`/profile/${did}`))} 180 > 181 <ProfilePicture {client} {did} size={8} /> 182 183 {#if profile} 184 <span class="w-min max-w-sm min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 185 + >{profile.displayName?.length === 0 ? handle : profile.displayName}</span 186 ><span class="shrink-0 text-sm text-nowrap opacity-70">(@{handle})</span> 187 {:else} 188 {handle} ··· 190 </button> 191 {/snippet} 192 193 {#snippet profilePopout()} 194 <Dropdown 195 class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!" 196 style="background: {color}36; border-color: {color}99;" 197 bind:isOpen={profileOpen} 198 trigger={profileInline} 199 + onMouseEnter={() => (profileOpen = true)} 200 + onMouseLeave={() => (profileOpen = false)} 201 > 202 + <ProfileInfo {client} {did} {handle} {profile} /> 203 </Dropdown> 204 {/snippet} 205 ··· 210 {:then post} 211 {#if post.ok} 212 {@const record = post.value.record} 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} 234 {:else} 235 {post.error} 236 {/if} ··· 253 {:then post} 254 {#if post.ok} 255 {@const record = post.value.record} 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=" 276 group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all 277 {$isPulsing ? 'animate-pulse-highlight' : ''} 278 {isOnPostComposer ? 'backdrop-brightness-20' : ''} 279 " 280 + style=" 281 background: {color}{isOnPostComposer 282 + ? '36' 283 + : Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)}; 284 border-color: {color}{isOnPostComposer ? '99' : '66'}; 285 " 286 > 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} 325 </div> 326 + {/if} 327 {:else} 328 <div class="error-disclaimer"> 329 <p class="text-sm font-medium">error: {post.error}</p> ··· 332 {/await} 333 {/if} 334 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} 356 <BskyPost 357 {client} 358 quoteDepth={quoteDepth + 1} ··· 362 {onQuote} 363 {onReply} 364 /> 365 {/if} 366 {:else} 367 + <span>you think you're funny with that recursive quote but i'm onto you</span> 368 {/if} 369 + {:else} 370 + <EmbedBadge embed={{ $type: 'app.bsky.embed.record' } as AppBskyEmbedRecord.Main} /> 371 {/if} 372 {/snippet} 373 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 + })} 394 <button 395 class=" 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! 399 " 400 onclick={(e) => onClick(e)} 401 + style="color: {isFull ? iconColor : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 402 title={name} 403 + disabled={canBeDisabled ? user?.did === undefined : false} 404 > 405 <Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} /> 406 </button> 407 {/snippet} 408 <div class="mt-3 flex w-full items-center justify-between"> 409 <div class="flex w-fit items-center rounded-sm" style="background: {color}1f;"> 410 + {@render control({ 411 + name: 'reply', 412 + icon: 'heroicons:chat-bubble-left', 413 + hasSolid: true, 414 + onClick: () => onReply?.(post) 415 + })} 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); 423 }, 424 + isFull: myRepost 425 + })} 426 + {@render control({ 427 + name: 'quote', 428 + icon: 'heroicons:paper-clip-20-solid', 429 + onClick: () => onQuote?.(post) 430 + })} 431 + {@render control({ 432 + name: 'like', 433 + icon: 'heroicons:star', 434 + onClick: () => { 435 + if (!user?.did) return; 436 if (myLike) deletePostBacklink(client, post, likeSource); 437 else createPostBacklink(client, post, likeSource); 438 }, 439 + isFull: myLike, 440 + hasSolid: true 441 + })} 442 </div> 443 <Dropdown 444 class="post-dropdown" ··· 450 {@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () => 451 navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`) 452 )} 453 + {@render dropdownItem(undefined, 'copy at uri', () => 454 navigator.clipboard.writeText(post.uri) 455 )} 456 {@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () => 457 navigator.clipboard.writeText(post.record.text) 458 )} 459 + {#if isLoggedInUser} 460 <div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div> 461 {@render dropdownItem( 462 deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid', ··· 468 {/if} 469 470 {#snippet trigger()} 471 + {@render control({ 472 + name: 'actions', 473 + icon: 'heroicons:ellipsis-horizontal-16-solid', 474 + onClick: (e: MouseEvent) => { 475 e.stopPropagation(); 476 actionsOpen = !actionsOpen; 477 actionsPos = { x: 0, y: 0 }; 478 + }, 479 + canBeDisabled: false, 480 + isFull: true, 481 + iconColor: 'color-mix(in srgb, var(--nucleus-fg) 70%, transparent)' 482 + })} 483 {/snippet} 484 </Dropdown> 485 </div> 486 {/snippet} 487 488 {#snippet dropdownItem( 489 + icon: string | undefined, 490 label: string, 491 onClick: () => void, 492 autoClose: boolean = true, ··· 503 if (autoClose) actionsOpen = false; 504 }} 505 > 506 + <span class="font-semibold opacity-85">{label}</span> 507 + {#if icon} 508 + <Icon class="h-6 w-6" {icon} /> 509 + {/if} 510 </button> 511 {/snippet}
+71 -5
src/components/Dropdown.svelte
··· 19 children?: import('svelte').Snippet; 20 placement?: Placement; 21 offsetDistance?: number; 22 position?: { x: number; y: number }; 23 } 24 25 let { ··· 28 children, 29 placement = 'bottom-start', 30 offsetDistance = 2, 31 position = $bindable(), 32 ...restProps 33 }: Props = $props(); 34 ··· 36 let contentRef: HTMLElement | undefined = $state(); 37 let cleanup: (() => void) | null = null; 38 39 const updatePosition = async () => { 40 const { x, y } = await computePosition(triggerRef!, contentRef!, { 41 placement, ··· 55 let rect = element.getBoundingClientRect(); 56 let x = event.clientX; 57 let y = event.clientY; 58 return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; 59 }; 60 ··· 70 71 const handleScroll = handleClose; 72 73 $effect(() => { 74 if (isOpen) { 75 cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition); ··· 79 } 80 }); 81 82 - onMount(() => { 83 - return () => { 84 - if (cleanup) cleanup(); 85 - }; 86 }); 87 </script> 88 89 <svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} /> 90 91 - <div role="button" tabindex="0" bind:this={triggerRef}> 92 {@render trigger?.()} 93 </div> 94 ··· 100 style={restProps.style} 101 role="menu" 102 tabindex="-1" 103 > 104 {@render children?.()} 105 </div>
··· 19 children?: import('svelte').Snippet; 20 placement?: Placement; 21 offsetDistance?: number; 22 + openDelay?: number; 23 position?: { x: number; y: number }; 24 + onMouseEnter?: () => void; 25 + onMouseLeave?: () => void; 26 } 27 28 let { ··· 31 children, 32 placement = 'bottom-start', 33 offsetDistance = 2, 34 + openDelay = 400, 35 position = $bindable(), 36 + onMouseEnter, 37 + onMouseLeave, 38 ...restProps 39 }: Props = $props(); 40 ··· 42 let contentRef: HTMLElement | undefined = $state(); 43 let cleanup: (() => void) | null = null; 44 45 + let isTriggerHovered = false; 46 + let isContentHovered = false; 47 + let closeTimer: ReturnType<typeof setTimeout>; 48 + let openTimer: ReturnType<typeof setTimeout>; 49 + 50 const updatePosition = async () => { 51 const { x, y } = await computePosition(triggerRef!, contentRef!, { 52 placement, ··· 66 let rect = element.getBoundingClientRect(); 67 let x = event.clientX; 68 let y = event.clientY; 69 + 70 return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; 71 }; 72 ··· 82 83 const handleScroll = handleClose; 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 + 131 $effect(() => { 132 if (isOpen) { 133 cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition); ··· 137 } 138 }); 139 140 + onMount(() => () => { 141 + if (cleanup) cleanup(); 142 + clearTimeout(closeTimer); 143 + clearTimeout(openTimer); // Cleanup open timer on unmount 144 }); 145 </script> 146 147 <svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} /> 148 149 + <div 150 + role="button" 151 + tabindex="0" 152 + bind:this={triggerRef} 153 + onmouseenter={handleTriggerEnter} 154 + onmouseleave={handleTriggerLeave} 155 + > 156 {@render trigger?.()} 157 </div> 158 ··· 164 style={restProps.style} 165 role="menu" 166 tabindex="-1" 167 + onmouseenter={handleContentEnter} 168 + onmouseleave={handleContentLeave} 169 > 170 {@render children?.()} 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>
+78 -89
src/components/FollowingItem.svelte
··· 1 - <script lang="ts" module> 2 - // Cache for synchronous access during component recycling 3 - const profileCache = new SvelteMap<string, { displayName?: string; handle: string }>(); 4 - </script> 5 - 6 <script lang="ts"> 7 import ProfilePicture from './ProfilePicture.svelte'; 8 import { getRelativeTime } from '$lib/date'; 9 import { generateColorForDid } from '$lib/accounts'; 10 import type { Did } from '@atcute/lexicons'; 11 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 12 import type { calculateFollowedUserStats, Sort } from '$lib/following'; 13 - import type { AtpClient } from '$lib/at/client'; 14 - import { SvelteMap } from 'svelte/reactivity'; 15 - import { clients, getClient } from '$lib/state.svelte'; 16 17 interface Props { 18 style: string; ··· 25 26 let { style, did, stats, client, sort, currentTime }: Props = $props(); 27 28 - // svelte-ignore state_referenced_locally 29 - const cached = profileCache.get(did); 30 - let displayName = $state<string | undefined>(cached?.displayName); 31 - let handle = $state<string>(cached?.handle ?? 'handle.invalid'); 32 - 33 - const loadProfile = async (targetDid: Did) => { 34 - if (profileCache.has(targetDid)) { 35 - const c = profileCache.get(targetDid)!; 36 - displayName = c.displayName; 37 - handle = c.handle; 38 - } else { 39 - const existingClient = clients.get(targetDid as AtprotoDid); 40 - if (existingClient?.user?.handle) { 41 - handle = existingClient.user.handle; 42 - } else { 43 - handle = 'handle.invalid'; 44 - displayName = undefined; 45 - } 46 - } 47 - 48 - try { 49 - // Optimization: Check clients map first to avoid async overhead if possible 50 - // but we need to ensure we have the profile data, not just client existence. 51 - const userClient = await getClient(targetDid as AtprotoDid); 52 - 53 - // Check if the component has been recycled for a different user while we were awaiting 54 - if (did !== targetDid) return; 55 56 - let newHandle = handle; 57 - let newDisplayName = displayName; 58 59 - if (userClient.user?.handle) { 60 - newHandle = userClient.user.handle; 61 - handle = newHandle; 62 - } else { 63 - newHandle = targetDid; 64 - handle = newHandle; 65 - } 66 67 - const profileRes = await userClient.getProfile(); 68 69 if (did !== targetDid) return; 70 71 - if (profileRes.ok) { 72 - newDisplayName = profileRes.value.displayName; 73 - displayName = newDisplayName; 74 - } 75 - 76 - // Update cache 77 - profileCache.set(targetDid, { 78 - handle: newHandle, 79 - displayName: newDisplayName 80 - }); 81 } catch (e) { 82 if (did !== targetDid) return; 83 console.error(`failed to load profile for ${targetDid}`, e); 84 - handle = 'error'; 85 } 86 }; 87 88 - // Re-run whenever `did` changes 89 $effect(() => { 90 loadProfile(did); 91 }); ··· 93 const lastPostAt = $derived(stats?.lastPostAt ?? new Date(0)); 94 const relTime = $derived(getRelativeTime(lastPostAt, currentTime)); 95 const color = $derived(generateColorForDid(did)); 96 </script> 97 98 <div {style} class="box-border w-full pb-2"> 99 - <div 100 - class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 101 - style={`--post-color: ${color};`} 102 - > 103 - <ProfilePicture {client} {did} size={10} /> 104 - <div class="min-w-0 flex-1 space-y-1"> 105 - <div 106 - class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)" 107 - style={`--post-color: ${color};`} 108 - > 109 - <span class="truncate">{displayName || handle}</span> 110 - <span class="truncate text-sm opacity-60">@{handle}</span> 111 - </div> 112 - <div class="flex gap-2 text-xs opacity-70"> 113 - <span 114 - class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 115 - ? 'text-(--nucleus-accent)' 116 - : ''} 117 - > 118 - posted {relTime} 119 - {relTime !== 'now' ? 'ago' : ''} 120 - </span> 121 - {#if stats?.recentPostCount && stats.recentPostCount > 0} 122 - <span class="text-(--nucleus-accent2)"> 123 - {stats.recentPostCount} posts / 6h 124 - </span> 125 {/if} 126 - {#if sort === 'conversational' && stats?.conversationalScore && stats.conversationalScore > 0} 127 - <span class="ml-auto font-bold text-(--nucleus-accent)"> 128 - โ˜… {stats.conversationalScore.toFixed(1)} 129 </span> 130 - {/if} 131 </div> 132 </div> 133 - </div> 134 </div>
··· 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; ··· 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 }); ··· 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>
+18 -16
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'; 5 import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 6 import { 7 calculateFollowedUserStats, ··· 10 type Sort 11 } from '$lib/following'; 12 import FollowingItem from './FollowingItem.svelte'; 13 14 interface Props { 15 - selectedDid: Did; 16 - selectedClient: AtpClient; 17 } 18 19 - const { selectedDid, selectedClient }: Props = $props(); 20 21 - let followingSort: Sort = $state('active' as Sort); 22 - const followsMap = $derived(follows.get(selectedDid)); 23 24 // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 let sortedFollowing = $state<{ did: Did; data: any }[]>([]); ··· 27 let isLongCalculation = $state(false); 28 let calculationTimer: ReturnType<typeof setTimeout> | undefined; 29 30 - // Optimization: Use a static timestamp for calculation to avoid re-sorting every second. 31 - // Only update this when the sort mode changes. 32 let staticNow = $state(Date.now()); 33 34 const updateList = async () => { ··· 36 if (calculationTimer) clearTimeout(calculationTimer); 37 isLongCalculation = false; 38 39 - if (!followsMap) { 40 sortedFollowing = []; 41 return; 42 } 43 44 // schedule spinner to appear only if calculation takes > 200ms 45 - calculationTimer = setTimeout(() => { 46 - isLongCalculation = true; 47 - }, 200); 48 // yield to main thread to allow UI to show spinner/update 49 await new Promise((resolve) => setTimeout(resolve, 0)); 50 ··· 60 ) 61 : null; 62 63 - const userStatsList = Array.from(followsMap.values()).map((f) => ({ 64 did: f.subject, 65 data: calculateFollowedUserStats( 66 followingSort, ··· 81 isLongCalculation = false; 82 }; 83 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; 90 // Update time when sort changes 91 staticNow = Date.now(); 92 ··· 139 </div> 140 141 <div class="min-h-0 flex-1" bind:this={listContainer}> 142 - {#if sortedFollowing.length === 0 || isLongCalculation} 143 <div class="flex justify-center py-8"> 144 <div 145 class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" ··· 154 {style} 155 did={user.did} 156 stats={user.data!} 157 - client={selectedClient} 158 sort={followingSort} 159 {currentTime} 160 />
··· 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, ··· 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 }[]>([]); ··· 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 () => { ··· 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 ··· 59 ) 60 : null; 61 62 + const userStatsList = followsMap.values().map((f) => ({ 63 did: f.subject, 64 data: calculateFollowedUserStats( 65 followingSort, ··· 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 ··· 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" ··· 156 {style} 157 did={user.did} 158 stats={user.data!} 159 + {client} 160 sort={followingSort} 161 {currentTime} 162 />
+5
src/components/NotLoggedIn.svelte
···
··· 1 + <div class="flex justify-center py-4"> 2 + <p class="text-xl opacity-80"> 3 + <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 4 + </p> 5 + </div>
+199
src/components/PhotoSwipeGallery.svelte
···
··· 1 + <script context="module" lang="ts"> 2 + export interface GalleryItem { 3 + src: string; 4 + thumbnail?: { 5 + src: string; 6 + width?: number; 7 + height?: number; 8 + }; 9 + width?: number; 10 + height?: number; 11 + alt?: string; 12 + } 13 + export type GalleryData = Array<GalleryItem>; 14 + </script> 15 + 16 + <script lang="ts"> 17 + import 'photoswipe/photoswipe.css'; 18 + import PhotoSwipeLightbox from 'photoswipe/lightbox'; 19 + import PhotoSwipe, { type ElementProvider, type PreparedPhotoSwipeOptions } from 'photoswipe'; 20 + import { onMount } from 'svelte'; 21 + import { writable } from 'svelte/store'; 22 + 23 + export let images: GalleryData; 24 + let element: HTMLDivElement; 25 + let imageElements: { [key: number]: HTMLImageElement } = {}; 26 + 27 + const options = writable<Partial<PreparedPhotoSwipeOptions> | undefined>(undefined); 28 + $: { 29 + if (!element) break $; 30 + const opts: Partial<PreparedPhotoSwipeOptions> = { 31 + pswpModule: PhotoSwipe, 32 + children: element.childNodes as ElementProvider, 33 + gallery: element, 34 + hideAnimationDuration: 0, 35 + showAnimationDuration: 0, 36 + zoomAnimationDuration: 200, 37 + zoomSVG: 38 + '<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" d="M6.25 8.75v-1h-1a.75.75 0 0 1 0-1.5h1v-1a.75.75 0 0 1 1.5 0v1h1a.75.75 0 0 1 0 1.5h-1v1a.75.75 0 0 1-1.5 0"/><path fill="currentColor" fill-rule="evenodd" d="M7 12c1.11 0 2.136-.362 2.965-.974l2.755 2.754a.75.75 0 1 0 1.06-1.06l-2.754-2.755A5 5 0 1 0 7 12m0-1.5a3.5 3.5 0 1 0 0-7a3.5 3.5 0 0 0 0 7" clip-rule="evenodd"/></svg>', 39 + closeSVG: 40 + '<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94z"/></svg>', 41 + arrowPrevSVG: 42 + '<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M9.78 4.22a.75.75 0 0 1 0 1.06L7.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L5.47 8.53a.75.75 0 0 1 0-1.06l3.25-3.25a.75.75 0 0 1 1.06 0" clip-rule="evenodd"/></svg>', 43 + arrowNextSVG: 44 + '<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8L6.22 5.28a.75.75 0 0 1 0-1.06" clip-rule="evenodd"/></svg>' 45 + }; 46 + $options = opts; 47 + } 48 + 49 + onMount(() => { 50 + let lightbox: PhotoSwipeLightbox | undefined; 51 + const unsub = options.subscribe((opts) => { 52 + lightbox?.destroy?.(); 53 + if (opts === undefined) return; 54 + lightbox = new PhotoSwipeLightbox(opts); 55 + lightbox.init(); 56 + }); 57 + return () => { 58 + unsub(); 59 + lightbox?.destroy?.(); 60 + }; 61 + }); 62 + </script> 63 + 64 + <div class="gallery styling-twitter" data-total={images.length} bind:this={element}> 65 + {#each images as img, i (img.src)} 66 + {@const thumb = img.thumbnail ?? img} 67 + {@const isHidden = i > 3} 68 + {@const isOverlay = i === 3 && images.length > 4} 69 + 70 + <!-- eslint-disable svelte/no-navigation-without-resolve --> 71 + <a 72 + href={img.src} 73 + data-pswp-width={img.width ?? imageElements[i]?.width} 74 + data-pswp-height={img.height ?? imageElements[i]?.height} 75 + target="_blank" 76 + class:hidden-in-grid={isHidden} 77 + class:overlay-container={isOverlay} 78 + > 79 + <img bind:this={imageElements[i]} src={thumb.src} title={img.alt ?? ''} alt={img.alt ?? ''} /> 80 + 81 + {#if isOverlay} 82 + <div class="more-overlay"> 83 + +{images.length - 4} 84 + </div> 85 + {/if} 86 + </a> 87 + {/each} 88 + </div> 89 + 90 + <style> 91 + :global(.gallery--icon) { 92 + --drop-color: color-mix(in srgb, var(--color-gray-900) 70%, transparent); 93 + color: var(--nucleus-fg); 94 + filter: drop-shadow(2px 2px 1px var(--drop-color)) drop-shadow(-2px -2px 1px var(--drop-color)) 95 + drop-shadow(-2px 2px 1px var(--drop-color)) drop-shadow(2px -2px 1px var(--drop-color)); 96 + } 97 + 98 + /* --- Default Grid (for 2+ images) --- */ 99 + .gallery.styling-twitter { 100 + display: grid; 101 + gap: 2px; 102 + border-radius: 4px; 103 + overflow: hidden; 104 + } 105 + 106 + .gallery.styling-twitter > a { 107 + width: 100%; 108 + height: 100%; 109 + display: block; 110 + position: relative; 111 + overflow: hidden; 112 + } 113 + 114 + .gallery.styling-twitter > a > img { 115 + @apply transition-opacity duration-200 hover:opacity-80; 116 + width: 100%; 117 + height: 100%; 118 + object-fit: cover; /* Standard tile crop */ 119 + } 120 + 121 + /* --- SINGLE IMAGE OVERRIDES --- */ 122 + /* This configuration allows the image to determine the width/height 123 + naturally based on aspect ratio, up to a max-height limit. 124 + */ 125 + .gallery.styling-twitter[data-total='1'] { 126 + display: block; /* Remove grid constraints */ 127 + height: auto; 128 + aspect-ratio: auto; /* Remove 16:9 ratio */ 129 + border-radius: 0; 130 + } 131 + 132 + .gallery.styling-twitter[data-total='1'] > a { 133 + /* fit-content is key: the container shrinks to fit the image width */ 134 + width: fit-content; 135 + height: auto; 136 + display: block; 137 + border-radius: 4px; 138 + overflow: hidden; 139 + max-width: 100%; /* Prevent overflowing the parent */ 140 + } 141 + 142 + .gallery.styling-twitter[data-total='1'] > a > img { 143 + /* Let dimensions flow naturally */ 144 + width: auto; 145 + height: auto; 146 + 147 + /* Constraints: */ 148 + max-width: 100%; /* Never wider than container */ 149 + max-height: 60vh; /* Never taller than 60% of viewport (adjust if needed) */ 150 + 151 + object-fit: contain; /* Never crop the single image */ 152 + } 153 + 154 + /* --- Grid Layouts (2+ Images) --- */ 155 + /* These retain the standard grid look */ 156 + 157 + /* 2 Images: Split vertically */ 158 + .gallery.styling-twitter[data-total='2'] { 159 + grid-template-columns: 1fr 1fr; 160 + grid-template-rows: 1fr; 161 + aspect-ratio: 16/9; 162 + } 163 + 164 + /* 3 Images: 1 Big (left), 2 Small (stacked right) */ 165 + .gallery.styling-twitter[data-total='3'] { 166 + grid-template-columns: 1fr 1fr; 167 + grid-template-rows: 1fr 1fr; 168 + aspect-ratio: 16/9; 169 + } 170 + .gallery.styling-twitter[data-total='3'] > a:first-child { 171 + grid-row: span 2; 172 + } 173 + 174 + /* 4+ Images: 2x2 Grid */ 175 + .gallery.styling-twitter[data-total='4'], 176 + .gallery.styling-twitter[data-total^='5'], 177 + .gallery.styling-twitter:not([data-total='1']):not([data-total='2']):not([data-total='3']) { 178 + grid-template-columns: 1fr 1fr; 179 + grid-template-rows: 1fr 1fr; 180 + aspect-ratio: 16/9; 181 + } 182 + 183 + .gallery.styling-twitter .hidden-in-grid { 184 + display: none; 185 + } 186 + 187 + .more-overlay { 188 + position: absolute; 189 + inset: 0; 190 + background-color: rgba(0, 0, 0, 0.5); 191 + color: white; 192 + display: flex; 193 + align-items: center; 194 + justify-content: center; 195 + font-size: 2rem; 196 + font-weight: bold; 197 + pointer-events: none; 198 + } 199 + </style>
+562 -79
src/components/PostComposer.svelte
··· 1 <script lang="ts"> 2 - import type { AtpClient } from '$lib/at/client'; 3 import { ok, err, type Result, expect } from '$lib/result'; 4 - import type { AppBskyFeedPost } from '@atcute/bluesky'; 5 import { generateColorForDid } from '$lib/accounts'; 6 import type { PostWithUri } from '$lib/at/fetch'; 7 import BskyPost from './BskyPost.svelte'; 8 - import { parseCanonicalResourceUri } from '@atcute/lexicons'; 9 import type { ComAtprotoRepoStrongRef } from '@atcute/atproto'; 10 import { parseToRichText } from '$lib/richtext'; 11 import { tokenize } from '$lib/richtext/parser'; 12 13 - export type State = 14 - | { type: 'null' } 15 - | { type: 'focused'; quoting?: PostWithUri; replying?: PostWithUri }; 16 17 interface Props { 18 client: AtpClient; ··· 20 _state: State; 21 } 22 23 - let { client, onPostSent, _state = $bindable({ type: 'null' }) }: Props = $props(); 24 25 - const isFocused = $derived(_state.type === 'focused'); 26 27 const color = $derived( 28 client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)' 29 ); 30 31 const post = async (text: string): Promise<Result<PostWithUri, string>> => { 32 const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({ 33 $type: 'com.atproto.repo.strongRef', ··· 35 uri: p.uri 36 }); 37 38 - // Parse rich text (mentions, links, tags) 39 const rt = await parseToRichText(text); 40 41 const record: AppBskyFeedPost.Main = { 42 $type: 'app.bsky.feed.post', 43 text: rt.text, 44 facets: rt.facets, 45 reply: 46 - _state.type === 'focused' && _state.replying 47 ? { 48 root: _state.replying.record.reply?.root ?? strongRef(_state.replying), 49 parent: strongRef(_state.replying) 50 } 51 : undefined, 52 embed: 53 - _state.type === 'focused' && _state.quoting 54 - ? { 55 - $type: 'app.bsky.embed.record', 56 - record: strongRef(_state.quoting) 57 - } 58 - : undefined, 59 createdAt: new Date().toISOString() 60 }; 61 62 - const res = await client.atcute?.post('com.atproto.repo.createRecord', { 63 input: { 64 collection: 'app.bsky.feed.post', 65 repo: client.user!.did, ··· 67 } 68 }); 69 70 - if (!res) { 71 - return err('failed to post: not logged in'); 72 - } 73 74 - if (!res.ok) { 75 return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`); 76 - } 77 78 return ok({ 79 uri: res.data.uri, ··· 82 }); 83 }; 84 85 - let postText = $state(''); 86 - let info = $state(''); 87 let textareaEl: HTMLTextAreaElement | undefined = $state(); 88 89 - const unfocus = () => { 90 - _state.type = 'null'; 91 }; 92 93 const doPost = () => { 94 - if (postText.length === 0 || postText.length > 300) return; 95 96 - post(postText).then((res) => { 97 - if (res.ok) { 98 - onPostSent(res.value); 99 - postText = ''; 100 - info = 'posted!'; 101 - unfocus(); 102 - setTimeout(() => (info = ''), 1000 * 0.8); 103 - } else { 104 - // todo: add a way to clear error 105 - info = res.error; 106 - } 107 - }); 108 }; 109 110 $effect(() => { ··· 113 }); 114 </script> 115 116 - {#snippet renderPost(post: PostWithUri)} 117 {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 118 - <BskyPost 119 - {client} 120 - did={parsedUri.repo} 121 - rkey={parsedUri.rkey} 122 - data={post} 123 - isOnPostComposer={true} 124 - /> 125 {/snippet} 126 127 {#snippet highlighter(text: string)} ··· 138 {/if} 139 {/snippet} 140 141 {#snippet composer(replying?: PostWithUri, quoting?: PostWithUri)} 142 <div class="flex items-center gap-2"> 143 <div class="grow"></div> 144 <span 145 - class="text-sm font-medium" 146 - style="color: color-mix(in srgb, {postText.length > 300 147 ? '#ef4444' 148 : 'var(--nucleus-fg)'} 53%, transparent);" 149 > 150 - {postText.length} / 300 151 </span> 152 <button 153 - onmousedown={(e) => { 154 - e.preventDefault(); 155 - doPost(); 156 - }} 157 - disabled={postText.length === 0 || postText.length > 300} 158 - class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100" 159 style="background: color-mix(in srgb, {color} 87%, transparent);" 160 > 161 post 162 </button> 163 </div> 164 {#if replying} 165 - {@render renderPost(replying)} 166 {/if} 167 - <div class="composer space-y-2"> 168 <div class="relative grid"> 169 <!-- todo: replace this with a proper rich text editor --> 170 <div 171 class="pointer-events-none col-start-1 row-start-1 min-h-[5lh] w-full bg-transparent text-wrap break-all whitespace-pre-wrap text-(--nucleus-fg)" 172 aria-hidden="true" 173 > 174 - {@render highlighter(postText)} 175 </div> 176 177 <textarea 178 bind:this={textareaEl} 179 - bind:value={postText} 180 - onfocus={() => (_state.type = 'focused')} 181 - onblur={unfocus} 182 onkeydown={(event) => { 183 if (event.key === 'Escape') unfocus(); 184 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); ··· 188 class="col-start-1 row-start-1 field-sizing-content min-h-[5lh] w-full resize-none overflow-hidden bg-transparent text-wrap break-all whitespace-pre-wrap text-transparent caret-(--nucleus-fg) placeholder:text-(--nucleus-fg)/45" 189 ></textarea> 190 </div> 191 - 192 {#if quoting} 193 - {@render renderPost(quoting)} 194 {/if} 195 </div> 196 {/snippet} ··· 214 : `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`}; 215 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 216 > 217 - <div class="w-full p-1.5 px-2"> 218 - {#if info.length > 0} 219 <div 220 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 221 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" 222 > 223 - {info} 224 </div> 225 {:else} 226 - <div class="flex flex-col gap-2"> 227 - {#if _state.type === 'focused'} 228 {@render composer(_state.replying, _state.quoting)} 229 {:else} 230 - <input 231 - bind:value={postText} 232 - onfocus={() => (_state = { type: 'focused' })} 233 - type="text" 234 - placeholder="what's on your mind?" 235 - class="flex-1" 236 - /> 237 {/if} 238 </div> 239 {/if} ··· 246 247 input, 248 .composer { 249 - @apply single-line-input bg-(--nucleus-bg)/35; 250 border-color: color-mix(in srgb, var(--acc-color) 30%, transparent); 251 } 252 253 .composer { 254 - @apply p-2; 255 } 256 257 textarea { ··· 259 } 260 261 input { 262 - @apply p-1 px-2; 263 } 264 265 .composer { ··· 272 273 textarea:focus { 274 @apply border-none! [box-shadow:none]! outline-none!; 275 } 276 </style>
··· 1 <script lang="ts"> 2 + import type { AtpClient } from '$lib/at/client.svelte'; 3 import { ok, err, type Result, expect } from '$lib/result'; 4 + import type { AppBskyEmbedRecordWithMedia, AppBskyFeedPost } from '@atcute/bluesky'; 5 import { generateColorForDid } from '$lib/accounts'; 6 import type { PostWithUri } from '$lib/at/fetch'; 7 import BskyPost from './BskyPost.svelte'; 8 + import { parseCanonicalResourceUri, type Blob as AtpBlob } from '@atcute/lexicons'; 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 + }; 31 32 interface Props { 33 client: AtpClient; ··· 35 _state: State; 36 } 37 38 + let { client, onPostSent, _state = $bindable() }: Props = $props(); 39 40 + const isFocused = $derived(_state.focus === 'focused'); 41 42 const color = $derived( 43 client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)' 44 ); 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 + 89 const post = async (text: string): Promise<Result<PostWithUri, string>> => { 90 const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({ 91 $type: 'com.atproto.repo.strongRef', ··· 93 uri: p.uri 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 + 134 const record: AppBskyFeedPost.Main = { 135 $type: 'app.bsky.feed.post', 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']), 158 createdAt: new Date().toISOString() 159 }; 160 161 + const res = await client.user?.atcute.post('com.atproto.repo.createRecord', { 162 input: { 163 collection: 'app.bsky.feed.post', 164 repo: client.user!.did, ··· 166 } 167 }); 168 169 + if (!res) return err('failed to post: not logged in'); 170 171 + if (!res.ok) 172 return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`); 173 174 return ok({ 175 uri: res.data.uri, ··· 178 }); 179 }; 180 181 + let posting = $state(false); 182 + let postError = $state(''); 183 let textareaEl: HTMLTextAreaElement | undefined = $state(); 184 + let fileInputEl: HTMLInputElement | undefined = $state(); 185 + let selectingFile = $state(false); 186 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; 322 }; 323 324 const doPost = () => { 325 + if (_state.text.length === 0 || _state.text.length > 300) return; 326 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 + }); 352 }; 353 354 $effect(() => { ··· 357 }); 358 </script> 359 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')} 375 + {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 376 + {@const color = generateColorForDid(parsedUri.repo)} 377 + {@const id = handles.get(parsedUri.repo) ?? parsedUri.repo} 378 + <div 379 + class="flex shrink-0 items-center gap-1.5 rounded-sm border py-0.5 pr-0.5 pl-1 text-xs font-bold transition-all" 380 + style=" 381 + background: color-mix(in srgb, {color} 10%, transparent); 382 + border-color: {color}; 383 + color: {color}; 384 + " 385 + title={type === 'replying' ? `replying to ${id}` : `quoting ${id}`} 386 + > 387 + <span class="truncate text-sm font-normal opacity-90"> 388 + {type === 'replying' ? 'replying to' : 'quoting'} 389 + </span> 390 + <div class="shrink-0"> 391 + <ProfilePicture {client} did={parsedUri.repo} size={5} /> 392 + </div> 393 + </div> 394 {/snippet} 395 396 {#snippet highlighter(text: string)} ··· 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')} 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} 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} 533 <span 534 + class="text-sm font-medium text-nowrap" 535 + style="color: color-mix(in srgb, {_state.text.length > 300 536 ? '#ef4444' 537 : 'var(--nucleus-fg)'} 53%, transparent);" 538 > 539 + {_state.text.length} / 300 540 </span> 541 <button 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" 548 style="background: color-mix(in srgb, {color} 87%, transparent);" 549 > 550 post 551 </button> 552 </div> 553 {#if replying} 554 + {@render attachedPost(replying, 'replying')} 555 {/if} 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(); ··· 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} 589 {#if quoting} 590 + {@render attachedPost(quoting, 'quoting')} 591 {/if} 592 </div> 593 {/snippet} ··· 611 : `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`}; 612 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 613 > 614 + <div class="w-full p-1"> 615 + {#if !client.user} 616 <div 617 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 618 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" 619 > 620 + not logged in 621 </div> 622 {:else} 623 + <div class="flex flex-col gap-1"> 624 + {#if _state.focus === 'focused'} 625 {@render composer(_state.replying, _state.quoting)} 626 {:else} 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> 649 {/if} 650 </div> 651 {/if} ··· 658 659 input, 660 .composer { 661 + @apply single-line-input rounded-xs bg-(--nucleus-bg)/35; 662 border-color: color-mix(in srgb, var(--acc-color) 30%, transparent); 663 } 664 665 .composer { 666 + @apply p-1; 667 } 668 669 textarea { ··· 671 } 672 673 input { 674 + @apply p-1.5; 675 } 676 677 .composer { ··· 684 685 textarea:focus { 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; 758 } 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}
+15 -32
src/components/ProfilePicture.svelte
··· 1 - <script lang="ts" module> 2 - // Module-level cache for synchronous access during component recycling 3 - const avatarCache = new SvelteMap<string, string | null>(); 4 - </script> 5 - 6 <script lang="ts"> 7 import { generateColorForDid } from '$lib/accounts'; 8 - import type { AtpClient } from '$lib/at/client'; 9 import { isBlob } from '@atcute/lexicons/interfaces'; 10 import PfpPlaceholder from './PfpPlaceholder.svelte'; 11 import { img } from '$lib/cdn'; 12 import type { Did } from '@atcute/lexicons'; 13 - import { SvelteMap } from 'svelte/reactivity'; 14 15 interface Props { 16 client: AtpClient; ··· 21 let { client, did, size }: Props = $props(); 22 23 // svelte-ignore state_referenced_locally 24 - let avatarUrl = $state<string | null>(avatarCache.get(did) ?? null); 25 26 const loadProfile = async (targetDid: Did) => { 27 - // If we already have it in cache, we might want to re-validate eventually, 28 - // but for UI stability, using the cache is priority. 29 - // However, we still need to handle the case where we don't have it. 30 - if (avatarCache.has(targetDid)) avatarUrl = avatarCache.get(targetDid) ?? null; 31 - else avatarUrl = null; 32 33 try { 34 const profile = await client.getProfile(targetDid); 35 - 36 - if (did !== targetDid) return; 37 - 38 if (profile.ok) { 39 - const record = profile.value; 40 - if (isBlob(record.avatar)) { 41 - const url = img('avatar_thumbnail', targetDid, record.avatar.ref.$link); 42 - avatarUrl = url; 43 - avatarCache.set(targetDid, url); 44 - } else { 45 - avatarUrl = null; 46 - avatarCache.set(targetDid, null); 47 - } 48 - } else { 49 - // Don't cache errors aggressively, or maybe cache 'null' to stop retrying? 50 - // For now, just set local state. 51 - avatarUrl = null; 52 - } 53 } catch (e) { 54 - if (did !== targetDid) return; 55 console.error(`${targetDid}: failed to load pfp`, e); 56 - avatarUrl = null; 57 } 58 }; 59
··· 1 <script lang="ts"> 2 import { generateColorForDid } from '$lib/accounts'; 3 + import type { AtpClient } from '$lib/at/client.svelte'; 4 import { isBlob } from '@atcute/lexicons/interfaces'; 5 import PfpPlaceholder from './PfpPlaceholder.svelte'; 6 import { img } from '$lib/cdn'; 7 import type { Did } from '@atcute/lexicons'; 8 + import { profiles } from '$lib/state.svelte'; 9 10 interface Props { 11 client: AtpClient; ··· 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
+159
src/components/ProfileView.svelte
···
··· 1 + <script lang="ts"> 2 + import { AtpClient, resolveDidDoc } from '$lib/at/client.svelte'; 3 + import { isDid, isHandle, type ActorIdentifier, type Did } from '@atcute/lexicons/syntax'; 4 + import TimelineView from './TimelineView.svelte'; 5 + import ProfileInfo from './ProfileInfo.svelte'; 6 + import type { State as PostComposerState } from './PostComposer.svelte'; 7 + import Icon from '@iconify/svelte'; 8 + import { accounts, generateColorForDid } from '$lib/accounts'; 9 + import { img } from '$lib/cdn'; 10 + import { isBlob } from '@atcute/lexicons/interfaces'; 11 + import { 12 + handles, 13 + profiles, 14 + getBlockRelationship, 15 + fetchBlocked, 16 + blockFlags 17 + } from '$lib/state.svelte'; 18 + import BlockedUserIndicator from './BlockedUserIndicator.svelte'; 19 + import ProfileActions from './ProfileActions.svelte'; 20 + 21 + interface Props { 22 + client: AtpClient; 23 + actor: string; 24 + onBack: () => void; 25 + postComposerState: PostComposerState; 26 + } 27 + 28 + let { client, actor, onBack, postComposerState = $bindable() }: Props = $props(); 29 + 30 + const profile = $derived(profiles.get(actor as Did)); 31 + const displayName = $derived(profile?.displayName ?? ''); 32 + const handle = $derived(isHandle(actor) ? actor : handles.get(actor as Did)); 33 + let loading = $state(true); 34 + let error = $state<string | null>(null); 35 + let did = $state(isDid(actor) ? actor : null); 36 + 37 + let userBlocked = $state(false); 38 + let blockedByTarget = $state(false); 39 + 40 + const loadProfile = async (identifier: ActorIdentifier) => { 41 + loading = true; 42 + error = null; 43 + 44 + const docRes = await resolveDidDoc(identifier); 45 + if (docRes.ok) { 46 + did = docRes.value.did; 47 + handles.set(did, docRes.value.handle); 48 + } else { 49 + error = docRes.error; 50 + return; 51 + } 52 + 53 + // check block relationship 54 + if (client.user?.did) { 55 + let blockRel = getBlockRelationship(client.user.did, did); 56 + blockRel = blockFlags.get(client.user.did)?.has(did) 57 + ? blockRel 58 + : await (async () => { 59 + const [userBlocked, blockedByTarget] = await Promise.all([ 60 + await fetchBlocked(client, did, client.user!.did), 61 + await fetchBlocked(client, client.user!.did, did) 62 + ]); 63 + return { userBlocked, blockedByTarget }; 64 + })(); 65 + userBlocked = blockRel.userBlocked; 66 + blockedByTarget = blockRel.blockedByTarget; 67 + } 68 + 69 + // don't load profile if blocked 70 + if (userBlocked || blockedByTarget) { 71 + loading = false; 72 + return; 73 + } 74 + 75 + const res = await client.getProfile(did, true); 76 + if (res.ok) profiles.set(did, res.value); 77 + else error = res.error; 78 + 79 + loading = false; 80 + }; 81 + 82 + $effect(() => { 83 + // if we have accounts, wait until we are logged in to load the profile 84 + if (!($accounts.length > 0 && !client.user?.did)) loadProfile(actor as ActorIdentifier); 85 + }); 86 + 87 + const color = $derived(did ? generateColorForDid(did) : 'var(--nucleus-accent)'); 88 + const bannerUrl = $derived( 89 + did && profile && isBlob(profile.banner) 90 + ? img('feed_fullsize', did, profile.banner.ref.$link) 91 + : null 92 + ); 93 + </script> 94 + 95 + <div class="flex min-h-dvh flex-col"> 96 + <!-- header --> 97 + <div 98 + class="sticky top-0 z-20 flex items-center gap-4 border-b-2 bg-(--nucleus-bg)/80 p-2 backdrop-blur-md" 99 + style="border-color: {color};" 100 + > 101 + <button 102 + onclick={onBack} 103 + class="rounded-sm p-1 text-(--nucleus-fg) transition-all hover:bg-(--nucleus-fg)/10" 104 + > 105 + <Icon icon="heroicons:arrow-left-20-solid" width={24} /> 106 + </button> 107 + <h2 class="text-xl font-bold"> 108 + {displayName.length > 0 ? displayName : loading ? 'loading...' : (handle ?? 'handle.invalid')} 109 + </h2> 110 + <div class="grow"></div> 111 + {#if did && client.user && client.user.did !== did} 112 + <ProfileActions {client} targetDid={did} bind:userBlocked {blockedByTarget} /> 113 + {/if} 114 + </div> 115 + 116 + {#if !loading} 117 + {#if error} 118 + <div class="p-8 text-center text-red-500"> 119 + <p>failed to load profile: {error}</p> 120 + </div> 121 + {:else if userBlocked || blockedByTarget} 122 + <div class="p-8"> 123 + <BlockedUserIndicator 124 + {client} 125 + did={did!} 126 + reason={userBlocked ? 'blocked' : 'blocks-you'} 127 + size="large" 128 + /> 129 + </div> 130 + {:else} 131 + <!-- banner --> 132 + {#if bannerUrl} 133 + <div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48"> 134 + <img src={bannerUrl} alt="banner" class="h-full w-full object-cover" /> 135 + <div 136 + class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)" 137 + style="opacity: 0.8;" 138 + ></div> 139 + </div> 140 + {/if} 141 + 142 + {#if did} 143 + <div class="px-4 pb-4"> 144 + <div class="relative z-10 {bannerUrl ? '-mt-12' : 'mt-4'} mb-4"> 145 + <ProfileInfo {client} {did} {profile} /> 146 + </div> 147 + 148 + <TimelineView 149 + showReplies={false} 150 + {client} 151 + targetDid={did} 152 + bind:postComposerState 153 + class="min-h-[50vh]" 154 + /> 155 + </div> 156 + {/if} 157 + {/if} 158 + {/if} 159 + </div>
+14 -5
src/components/RichText.svelte
··· 1 <script lang="ts"> 2 import { parseToRichText } from '$lib/richtext'; 3 import { settings } from '$lib/settings'; 4 import type { BakedRichtext } from '@atcute/bluesky-richtext-builder'; 5 import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter'; 6 ··· 14 const richtext: Promise<BakedRichtext> = $derived( 15 facets ? Promise.resolve({ text, facets }) : parseToRichText(text) 16 ); 17 </script> 18 19 {#snippet plainText(text: string)} ··· 27 {/snippet} 28 29 {#snippet segments(segments: RichtextSegment[])} 30 - {#each segments as segment, idx ([segment, idx])} 31 {@const { text, features: _features } = segment} 32 {@const features = _features ?? []} 33 {#if features.length > 0} 34 - {#each features as feature, idx ([feature, idx])} 35 {#if feature.$type === 'app.bsky.richtext.facet#mention'} 36 <a 37 - class="text-(--nucleus-accent2)" 38 - href={`${$settings.socialAppUrl}/profile/${feature.did}`}>{@render plainText(text)}</a 39 > 40 {:else if feature.$type === 'app.bsky.richtext.facet#link'} 41 {@const uri = new URL(feature.uri)} ··· 51 <a 52 class="text-(--nucleus-accent2)" 53 href={`${$settings.socialAppUrl}/search?q=${encodeURIComponent('#' + feature.tag)}`} 54 - >{@render plainText(text)}</a 55 > 56 {:else} 57 <span>{@render plainText(text)}</span>
··· 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 ··· 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)} ··· 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)} ··· 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>
+12 -10
src/components/SettingsView.svelte
··· 5 import Tabs from './Tabs.svelte'; 6 import { portal } from 'svelte-portal'; 7 import { cache } from '$lib/cache'; 8 9 - type Tab = 'style' | 'moderation' | 'advanced'; 10 - let activeTab = $state<Tab>('advanced'); 11 12 let localSettings = $state(get(settings)); 13 let hasReloadChanges = $derived(needsReload($settings, localSettings)); ··· 32 cache.clear(); 33 alert('cache cleared!'); 34 }; 35 </script> 36 37 {#snippet advancedTab()} ··· 138 </div> 139 140 <div class="flex-1"> 141 - {#if activeTab === 'advanced'} 142 {@render advancedTab()} 143 - {:else if activeTab === 'moderation'} 144 <div class="p-4"> 145 <div class="flex h-64 items-center justify-center"> 146 <div class="text-center"> ··· 149 </div> 150 </div> 151 </div> 152 - {:else if activeTab === 'style'} 153 {@render styleTab()} 154 {/if} 155 </div> ··· 160 z-20 w-full max-w-2xl bg-(--nucleus-bg) p-4 pt-2 pb-1 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)] 161 " 162 > 163 - <Tabs 164 - tabs={['style', 'moderation', 'advanced']} 165 - bind:activeTab 166 - onTabChange={(tab) => (activeTab = tab)} 167 - /> 168 </div> 169 </div> 170
··· 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)); ··· 36 cache.clear(); 37 alert('cache cleared!'); 38 }; 39 + 40 + const onTabChange = (tab: string) => router.replace(`/settings/${tab}`); 41 </script> 42 43 {#snippet advancedTab()} ··· 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"> ··· 155 </div> 156 </div> 157 </div> 158 + {:else if tab === 'style'} 159 {@render styleTab()} 160 {/if} 161 </div> ··· 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
+76 -17
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'; 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 { postCursors, fetchTimeline, allPosts, timelines } from '$lib/state.svelte'; 10 import Icon from '@iconify/svelte'; 11 import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 12 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 13 14 interface Props { 15 client?: AtpClient | null; 16 postComposerState: PostComposerState; 17 class?: string; 18 } 19 20 - let { client = null, postComposerState = $bindable(), class: className = '' }: Props = $props(); 21 22 let reverseChronological = $state(true); 23 let viewOwnPosts = $state(true); 24 const expandedThreads = new SvelteSet<ResourceUri>(); 25 26 - const did = $derived(client?.user?.did); 27 28 const threads = $derived( 29 filterThreads( 30 did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [], 31 $accounts, ··· 39 let loadError = $state(''); 40 41 const loadMore = async () => { 42 - if (loading || $accounts.length === 0 || !did) return; 43 44 loading = true; 45 loaderState.status = 'LOADING'; 46 47 try { 48 - await fetchTimeline(did as AtprotoDid); 49 loaderState.loaded(); 50 } catch (error) { 51 loadError = `${error}`; ··· 55 } 56 57 loading = false; 58 - if (postCursors.values().every((cursor) => cursor.end)) loaderState.complete(); 59 }; 60 61 $effect(() => { 62 - if (threads.length === 0 && !loading) loadMore(); 63 }); 64 </script> 65 ··· 88 <div class="mb-1.5"> 89 <BskyPost 90 client={client!} 91 - onQuote={(post) => (postComposerState = { type: 'focused', quoting: post })} 92 - onReply={(post) => (postComposerState = { type: 'focused', replying: post })} 93 {...post} 94 /> 95 </div> ··· 128 class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 129 bind:this={scrollContainer} 130 > 131 - {#if $accounts.length > 0} 132 <InfiniteLoader 133 {loaderState} 134 triggerLoad={loadMore} ··· 166 {/snippet} 167 </InfiniteLoader> 168 {:else} 169 - <div class="flex justify-center py-4"> 170 - <p class="text-xl opacity-80"> 171 - <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 172 - </p> 173 - </div> 174 {/if} 175 </div>
··· 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, ··· 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}`; ··· 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 ··· 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> ··· 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} ··· 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 + };
-352
src/lib/at/client.ts
··· 1 - import { err, expect, map, ok, type OkType, type Result } from '$lib/result'; 2 - import { 3 - ComAtprotoIdentityResolveHandle, 4 - ComAtprotoRepoGetRecord, 5 - ComAtprotoRepoListRecords 6 - } from '@atcute/atproto'; 7 - import { Client as AtcuteClient, simpleFetchHandler } from '@atcute/client'; 8 - import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons'; 9 - import { 10 - isDid, 11 - parseCanonicalResourceUri, 12 - parseResourceUri, 13 - type ActorIdentifier, 14 - type AtprotoDid, 15 - type Cid, 16 - type Did, 17 - type Nsid, 18 - type RecordKey, 19 - type ResourceUri 20 - } from '@atcute/lexicons/syntax'; 21 - import type { 22 - InferInput, 23 - InferXRPCBodyOutput, 24 - ObjectSchema, 25 - RecordKeySchema, 26 - RecordSchema, 27 - XRPCQueryMetadata 28 - } from '@atcute/lexicons/validations'; 29 - import * as v from '@atcute/lexicons/validations'; 30 - import { MiniDocQuery, type MiniDoc } from './slingshot'; 31 - import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation'; 32 - import type { Records } from '@atcute/lexicons/ambient'; 33 - import { cache as rawCache } from '$lib/cache'; 34 - import { AppBskyActorProfile } from '@atcute/bluesky'; 35 - import { WebSocket } from '@soffinal/websocket'; 36 - import type { Notification } from './stardust'; 37 - import { get } from 'svelte/store'; 38 - import { settings } from '$lib/settings'; 39 - import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 40 - import { timestampFromCursor } from '$lib'; 41 - 42 - export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot); 43 - export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust); 44 - export const constellationUrl: URL = new URL(get(settings).endpoints.constellation); 45 - 46 - export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output }; 47 - 48 - const cacheWithHandles = rawCache.define( 49 - 'resolveHandle', 50 - async (handle: Handle): Promise<AtprotoDid> => { 51 - const res = await fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, { 52 - handle 53 - }); 54 - if (!res.ok) throw new Error(res.error); 55 - return res.value.did as AtprotoDid; 56 - } 57 - ); 58 - 59 - const cacheWithDidDocs = cacheWithHandles.define( 60 - 'resolveDidDoc', 61 - async (identifier: ActorIdentifier): Promise<MiniDoc> => { 62 - const res = await fetchMicrocosm(slingshotUrl, MiniDocQuery, { 63 - identifier 64 - }); 65 - if (!res.ok) throw new Error(res.error); 66 - return res.value; 67 - } 68 - ); 69 - 70 - const cacheWithRecords = cacheWithDidDocs.define('fetchRecord', async (uri: ResourceUri) => { 71 - const parsedUri = parseResourceUri(uri); 72 - if (!parsedUri.ok) throw new Error(`can't parse resource uri: ${parsedUri.error}`); 73 - const { repo, collection, rkey } = parsedUri.value; 74 - const res = await fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, { 75 - repo, 76 - collection: collection!, 77 - rkey: rkey! 78 - }); 79 - if (!res.ok) throw new Error(res.error); 80 - return res.value; 81 - }); 82 - 83 - const cache = cacheWithRecords; 84 - 85 - export class AtpClient { 86 - public atcute: AtcuteClient | null = null; 87 - public user: { did: Did; handle: Handle } | null = null; 88 - 89 - async login(agent: OAuthUserAgent): Promise<Result<null, string>> { 90 - try { 91 - const rpc = new AtcuteClient({ handler: agent }); 92 - const res = await rpc.get('com.atproto.server.getSession'); 93 - if (!res.ok) throw res.data.error; 94 - this.user = { 95 - did: res.data.did, 96 - handle: res.data.handle 97 - }; 98 - this.atcute = rpc; 99 - } catch (error) { 100 - return err(`failed to login: ${error}`); 101 - } 102 - 103 - return ok(null); 104 - } 105 - 106 - async getRecordUri< 107 - Collection extends Nsid, 108 - TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 109 - TKey extends RecordKeySchema, 110 - Schema extends RecordSchema<TObject, TKey>, 111 - Output extends InferInput<Schema> 112 - >(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> { 113 - const parsedUri = expect(parseResourceUri(uri)); 114 - if (parsedUri.collection !== schema.object.shape.$type.expected) 115 - return err( 116 - `collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}` 117 - ); 118 - return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!); 119 - } 120 - 121 - async getRecord< 122 - Collection extends Nsid, 123 - TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 124 - TKey extends RecordKeySchema, 125 - Schema extends RecordSchema<TObject, TKey>, 126 - Output extends InferInput<Schema> 127 - >( 128 - schema: Schema, 129 - repo: ActorIdentifier, 130 - rkey: RecordKey 131 - ): Promise<Result<RecordOutput<Output>, string>> { 132 - const collection = schema.object.shape.$type.expected; 133 - 134 - try { 135 - // Call the cached function 136 - const rawValue = await cache.fetchRecord(`at://${repo}/${collection}/${rkey}`); 137 - 138 - const parsed = safeParse(schema, rawValue.value); 139 - if (!parsed.ok) return err(parsed.message); 140 - 141 - return ok({ 142 - uri: rawValue.uri, 143 - cid: rawValue.cid, 144 - record: parsed.value as Output 145 - }); 146 - } catch (e) { 147 - return err(String(e)); 148 - } 149 - } 150 - 151 - async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> { 152 - repo = repo ?? this.user?.did; 153 - if (!repo) return err('not authenticated'); 154 - return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record); 155 - } 156 - 157 - async listRecords<Collection extends keyof Records>( 158 - collection: Collection, 159 - cursor?: string, 160 - limit: number = 100 161 - ): Promise< 162 - Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string> 163 - > { 164 - if (!this.atcute || !this.user) return err('not authenticated'); 165 - const res = await this.atcute.get('com.atproto.repo.listRecords', { 166 - params: { 167 - repo: this.user.did, 168 - collection, 169 - cursor, 170 - limit, 171 - reverse: false 172 - } 173 - }); 174 - if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 175 - 176 - for (const record of res.data.records) 177 - await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); 178 - 179 - return ok(res.data); 180 - } 181 - 182 - async listRecordsUntil<Collection extends keyof Records>( 183 - collection: Collection, 184 - cursor?: string, 185 - timestamp: number = -1 186 - ): Promise<ReturnType<typeof this.listRecords>> { 187 - const data: OkType<Awaited<ReturnType<typeof this.listRecords>>> = { 188 - records: [], 189 - cursor 190 - }; 191 - 192 - let end = false; 193 - while (!end) { 194 - const res = await this.listRecords(collection, data.cursor); 195 - if (!res.ok) return res; 196 - data.cursor = res.value.cursor; 197 - data.records.push(...res.value.records); 198 - end = !data.cursor; 199 - if (!end && timestamp > 0) { 200 - const cursorTimestamp = timestampFromCursor(data.cursor); 201 - if (cursorTimestamp === undefined) { 202 - console.warn( 203 - 'could not parse timestamp from cursor, stopping fetch to prevent infinite loop:', 204 - data.cursor 205 - ); 206 - end = true; 207 - } else if (cursorTimestamp < timestamp) { 208 - end = true; 209 - } else { 210 - console.info( 211 - `${this.user?.did}: continuing to fetch ${collection}, on ${cursorTimestamp} until ${timestamp}` 212 - ); 213 - } 214 - } 215 - } 216 - 217 - return ok(data); 218 - } 219 - 220 - async getBacklinksUri( 221 - uri: ResourceUri, 222 - source: BacklinksSource 223 - ): Promise<Result<Backlinks, string>> { 224 - const parsedResourceUri = expect(parseCanonicalResourceUri(uri)); 225 - return await this.getBacklinks( 226 - parsedResourceUri.repo, 227 - parsedResourceUri.collection, 228 - parsedResourceUri.rkey, 229 - source 230 - ); 231 - } 232 - 233 - async getBacklinks( 234 - repo: ActorIdentifier, 235 - collection: Nsid, 236 - rkey: RecordKey, 237 - source: BacklinksSource, 238 - limit?: number 239 - ): Promise<Result<Backlinks, string>> { 240 - const did = await 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: 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 - 257 - export const newPublicClient = async (ident: ActorIdentifier): Promise<AtpClient> => { 258 - const atp = new AtpClient(); 259 - const didDoc = await resolveDidDoc(ident); 260 - if (!didDoc.ok) { 261 - console.error('failed to resolve did doc', didDoc.error); 262 - return atp; 263 - } 264 - atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) }); 265 - atp.user = { did: didDoc.value.did, handle: didDoc.value.handle }; 266 - return atp; 267 - }; 268 - 269 - // Wrappers that use the cache 270 - 271 - export const resolveHandle = async ( 272 - identifier: ActorIdentifier 273 - ): Promise<Result<AtprotoDid, string>> => { 274 - if (isDid(identifier)) return ok(identifier as AtprotoDid); 275 - 276 - try { 277 - const did = await cache.resolveHandle(identifier); 278 - return ok(did); 279 - } catch (e) { 280 - return err(String(e)); 281 - } 282 - }; 283 - 284 - export const resolveDidDoc = async (ident: ActorIdentifier): Promise<Result<MiniDoc, string>> => { 285 - try { 286 - const doc = await cache.resolveDidDoc(ident); 287 - return ok(doc); 288 - } catch (e) { 289 - return err(String(e)); 290 - } 291 - }; 292 - 293 - type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>; 294 - export type NotificationsStream = WebSocket<NotificationsStreamEncoder>; 295 - export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>; 296 - 297 - export const streamNotifications = ( 298 - subjects: Did[], 299 - ...sources: BacklinksSource[] 300 - ): NotificationsStream => { 301 - const url = new URL(spacedustUrl); 302 - url.protocol = 'wss:'; 303 - url.pathname = '/subscribe'; 304 - const searchParams = []; 305 - sources.every((source) => searchParams.push(['wantedSources', source])); 306 - subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject])); 307 - subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`])); 308 - searchParams.push(['instant', 'true']); 309 - url.search = `?${new URLSearchParams(searchParams)}`; 310 - // console.log(`streaming notifications: ${url}`); 311 - const encoder = WebSocket.getDefaultEncoder<undefined, Notification>(); 312 - const ws = new WebSocket<typeof encoder>(url.toString(), { 313 - encoder 314 - }); 315 - return ws; 316 - }; 317 - 318 - const fetchMicrocosm = async < 319 - Schema extends XRPCQueryMetadata, 320 - Input extends Schema['params'] extends ObjectSchema ? InferOutput<Schema['params']> : undefined, 321 - Output extends InferXRPCBodyOutput<Schema['output']> 322 - >( 323 - api: URL, 324 - schema: Schema, 325 - params: Input, 326 - init?: RequestInit 327 - ): Promise<Result<Output, string>> => { 328 - if (!schema.output || schema.output.type === 'blob') return err('schema must be blob'); 329 - api.pathname = `/xrpc/${schema.nsid}`; 330 - api.search = params ? `?${new URLSearchParams(params)}` : ''; 331 - try { 332 - const body = await fetchJson(api, init); 333 - if (!body.ok) return err(body.error); 334 - const parsed = safeParse(schema.output.schema, body.value); 335 - if (!parsed.ok) return err(parsed.message); 336 - return ok(parsed.value as Output); 337 - } catch (error) { 338 - return err(`FetchError: ${error}`); 339 - } 340 - }; 341 - 342 - const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => { 343 - try { 344 - const response = await fetch(url, init); 345 - const body = await response.json(); 346 - if (response.status === 400) return err(`${body.error}: ${body.message}`); 347 - if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`); 348 - return ok(body); 349 - } catch (error) { 350 - return err(`FetchError: ${error}`); 351 - } 352 - };
···
+1 -1
src/lib/at/constellation.ts
··· 9 }); 10 export const BacklinksQuery = v.query('blue.microcosm.links.getBacklinks', { 11 params: v.object({ 12 - subject: v.resourceUriString(), 13 source: v.string(), 14 did: v.optional(v.array(v.didString())), 15 limit: v.optional(v.integer())
··· 9 }); 10 export const BacklinksQuery = v.query('blue.microcosm.links.getBacklinks', { 11 params: v.object({ 12 + subject: v.string(), 13 source: v.string(), 14 did: v.optional(v.array(v.didString())), 15 limit: v.optional(v.integer())
+77 -44
src/lib/at/fetch.ts
··· 4 type Cid, 5 type ResourceUri 6 } from '@atcute/lexicons'; 7 - import { type AtpClient } from './client'; 8 - import { err, expect, ok, type Result } from '$lib/result'; 9 import type { Backlinks } from './constellation'; 10 import { AppBskyFeedPost } from '@atcute/bluesky'; 11 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 12 - import { replySource } from '$lib'; 13 14 export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main }; 15 export type PostWithBacklinks = PostWithUri & { 16 - replies: Backlinks; 17 }; 18 - export type PostsWithReplyBacklinks = PostWithBacklinks[]; 19 20 - export const fetchPostsWithBacklinks = async ( 21 client: AtpClient, 22 cursor?: string, 23 - limit?: number 24 - ): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => { 25 - const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit); 26 if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`); 27 cursor = recordsList.value.cursor; 28 const records = recordsList.value.records; 29 30 try { 31 const allBacklinks = await Promise.all( 32 records.map(async (r): Promise<PostWithBacklinks> => { 33 - const result = await client.getBacklinksUri(r.uri, replySource); 34 if (!result.ok) throw `cant fetch replies: ${result.error}`; 35 const replies = result.value; 36 return { ··· 47 } 48 }; 49 50 export const hydratePosts = async ( 51 client: AtpClient, 52 - repo: AtprotoDid, 53 - data: PostsWithReplyBacklinks 54 ): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => { 55 let posts: Map<ResourceUri, PostWithUri> = new Map(); 56 try { 57 const allPosts = await Promise.all( 58 data.map(async (post) => { 59 const result: PostWithUri[] = [post]; 60 - const replies = await Promise.all( 61 - post.replies.records.map(async (r) => { 62 - const reply = await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey); 63 - if (!reply.ok) throw `cant fetch reply: ${reply.error}`; 64 - return reply.value; 65 - }) 66 - ); 67 - result.push(...replies); 68 return result; 69 }) 70 ); ··· 79 const parentUri = parent.uri as CanonicalResourceUri; 80 // if we already have this parent, then we already fetched this chain / are fetching it 81 if (posts.has(parentUri)) return; 82 - const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parentUri); 83 if (p.ok) { 84 posts.set(p.value.uri, p.value); 85 parent = p.value.record.reply?.parent; ··· 91 }; 92 await Promise.all(posts.values().map(fetchUpwardsChain)); 93 94 - try { 95 - const fetchDownwardsChain = async (post: PostWithUri) => { 96 - const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri)); 97 - if (repo === postRepo) return; 98 99 - // get chains that are the same author until we exhaust them 100 - const backlinks = await client.getBacklinksUri(post.uri, replySource); 101 - if (!backlinks.ok) return; 102 103 - const promises = []; 104 - for (const reply of backlinks.value.records) { 105 - if (reply.did !== postRepo) continue; 106 - // if we already have this reply, then we already fetched this chain / are fetching it 107 - if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue; 108 - const record = await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey); 109 - if (!record.ok) break; // TODO: this doesnt handle deleted posts in between 110 - posts.set(record.value.uri, record.value); 111 - promises.push(fetchDownwardsChain(record.value)); 112 - } 113 114 - await Promise.all(promises); 115 - }; 116 - await Promise.all(posts.values().map(fetchDownwardsChain)); 117 - } catch (error) { 118 - return err(`cant fetch post reply chain: ${error}`); 119 } 120 121 return ok(posts);
··· 4 type Cid, 5 type ResourceUri 6 } from '@atcute/lexicons'; 7 + import { type AtpClient } from './client.svelte'; 8 + import { err, expect, ok, type Ok, type Result } from '$lib/result'; 9 import type { Backlinks } from './constellation'; 10 import { AppBskyFeedPost } from '@atcute/bluesky'; 11 + import type { Did, RecordKey } from '@atcute/lexicons/syntax'; 12 + import { replySource, toCanonicalUri } from '$lib'; 13 14 export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main }; 15 export type PostWithBacklinks = PostWithUri & { 16 + replies?: Backlinks; 17 }; 18 19 + export const fetchPosts = async ( 20 + subject: Did, 21 client: AtpClient, 22 cursor?: string, 23 + limit?: number, 24 + withBacklinks: boolean = true 25 + ): Promise<Result<{ posts: PostWithBacklinks[]; cursor?: string }, string>> => { 26 + const recordsList = await client.listRecords(subject, 'app.bsky.feed.post', cursor, limit); 27 if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`); 28 cursor = recordsList.value.cursor; 29 const records = recordsList.value.records; 30 31 + if (!withBacklinks) { 32 + return ok({ 33 + posts: records.map((r) => ({ 34 + uri: r.uri, 35 + cid: r.cid, 36 + record: r.value as AppBskyFeedPost.Main 37 + })), 38 + cursor 39 + }); 40 + } 41 + 42 try { 43 const allBacklinks = await Promise.all( 44 records.map(async (r): Promise<PostWithBacklinks> => { 45 + const result = await client.getBacklinks(r.uri, replySource); 46 if (!result.ok) throw `cant fetch replies: ${result.error}`; 47 const replies = result.value; 48 return { ··· 59 } 60 }; 61 62 + export type HydrateOptions = { 63 + downwards: 'sameAuthor' | 'none'; 64 + }; 65 + 66 export const hydratePosts = async ( 67 client: AtpClient, 68 + repo: Did, 69 + data: PostWithBacklinks[], 70 + cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined, 71 + options?: Partial<HydrateOptions> 72 ): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => { 73 let posts: Map<ResourceUri, PostWithUri> = new Map(); 74 try { 75 const allPosts = await Promise.all( 76 data.map(async (post) => { 77 const result: PostWithUri[] = [post]; 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 + } 90 return result; 91 }) 92 ); ··· 101 const parentUri = parent.uri as CanonicalResourceUri; 102 // if we already have this parent, then we already fetched this chain / are fetching it 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 + )); 112 if (p.ok) { 113 posts.set(p.value.uri, p.value); 114 parent = p.value.record.reply?.parent; ··· 120 }; 121 await Promise.all(posts.values().map(fetchUpwardsChain)); 122 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; 128 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; 132 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 + } 145 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 + } 152 } 153 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 WebDidDocumentResolver, 15 XrpcHandleResolver 16 } from '@atcute/identity-resolver'; 17 - import { slingshotUrl } from './client'; 18 import type { ActorIdentifier } from '@atcute/lexicons'; 19 import { err, ok, type Result } from '$lib/result'; 20 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 21 import { clientId, oauthMetadata, redirectUri } from '$lib/oauth'; 22 23 configureOAuth({ 24 metadata: {
··· 14 WebDidDocumentResolver, 15 XrpcHandleResolver 16 } from '@atcute/identity-resolver'; 17 import type { ActorIdentifier } from '@atcute/lexicons'; 18 import { err, ok, type Result } from '$lib/result'; 19 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 20 import { clientId, oauthMetadata, redirectUri } from '$lib/oauth'; 21 + import { slingshotUrl } from '.'; 22 23 configureOAuth({ 24 metadata: {
+5
src/lib/at/types.ts
··· 12 | AppBskyEmbedRecord.Main 13 | AppBskyEmbedRecordWithMedia.Main 14 | AppBskyEmbedVideo.Main;
··· 12 | AppBskyEmbedRecord.Main 13 | AppBskyEmbedRecordWithMedia.Main 14 | AppBskyEmbedVideo.Main; 15 + 16 + export type AppBskyEmbedMedia = 17 + | AppBskyEmbedImages.Main 18 + | AppBskyEmbedVideo.Main 19 + | AppBskyEmbedExternal.Main;
+8 -12
src/lib/cache.ts
··· 29 private writeFlushScheduled = false; 30 31 constructor() { 32 - if (typeof indexedDB === 'undefined') { 33 - return; 34 - } 35 36 this.dbPromise = new Promise((resolve, reject) => { 37 const request = indexedDB.open(DB_NAME, DB_VERSION); ··· 45 46 request.onupgradeneeded = (event) => { 47 const db = (event.target as IDBOpenDBRequest).result; 48 - if (!db.objectStoreNames.contains(STORE_NAME)) { 49 - db.createObjectStore(STORE_NAME); 50 - } 51 }; 52 }); 53 } ··· 165 batch.forEach((op) => { 166 try { 167 let request: IDBRequest; 168 - if (op.type === 'put') { 169 - request = store.put(op.value, op.key); 170 - } else { 171 - request = store.delete(op.key); 172 - } 173 174 request.onsuccess = () => op.resolve(); 175 request.onerror = () => op.reject(request.error); ··· 208 } 209 210 // noops 211 async getTTL(key: string): Promise<void> { 212 return; 213 } ··· 215 return; 216 } 217 } 218 219 export const cache = createCache({ 220 storage: { ··· 223 storage: new IDBStorage() 224 } 225 }, 226 - ttl: 60 * 60 * 24, // 24 hours 227 onError: (err) => console.error(err) 228 });
··· 29 private writeFlushScheduled = false; 30 31 constructor() { 32 + if (typeof indexedDB === 'undefined') return; 33 34 this.dbPromise = new Promise((resolve, reject) => { 35 const request = indexedDB.open(DB_NAME, DB_VERSION); ··· 43 44 request.onupgradeneeded = (event) => { 45 const db = (event.target as IDBOpenDBRequest).result; 46 + if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME); 47 }; 48 }); 49 } ··· 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); ··· 201 } 202 203 // noops 204 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 205 async getTTL(key: string): Promise<void> { 206 return; 207 } ··· 209 return; 210 } 211 } 212 + 213 + export const ttl = 60 * 60 * 3; // 3 hours 214 215 export const cache = createCache({ 216 storage: { ··· 219 storage: new IDBStorage() 220 } 221 }, 222 + ttl, 223 onError: (err) => console.error(err) 224 });
+32 -48
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 { Backlink, BacklinksSource } from './at/constellation'; 4 import { extractDidFromUri, repostSource } from '$lib'; 5 import type { AppBskyGraphFollow } from '@atcute/bluesky'; 6 ··· 13 ) => { 14 if (sort === 'conversational') { 15 if (Math.abs(statsB.conversationalScore! - statsA.conversationalScore!) > 0.1) 16 - // sort based on conversational score 17 return statsB.conversationalScore! - statsA.conversationalScore!; 18 } else { 19 if (sort === 'active') 20 if (Math.abs(statsB.activeScore! - statsA.activeScore!) > 0.0001) 21 - // sort based on activity 22 return statsB.activeScore! - statsA.activeScore!; 23 } 24 - // use recent if scores are similar / we are using recent mode 25 return statsB.lastPostAt!.getTime() - statsA.lastPostAt!.getTime(); 26 }; 27 28 - // Caching to prevent re-calculating stats for every render frame if data is stable 29 const userStatsCache = new Map< 30 Did, 31 { timestamp: number; stats: ReturnType<typeof _calculateStats> } 32 >(); 33 - const STATS_CACHE_TTL = 60 * 1000; // 1 minute 34 35 export const calculateFollowedUserStats = ( 36 sort: Sort, ··· 39 interactionScores: Map<ActorIdentifier, number> | null, 40 now: number 41 ) => { 42 - // For 'active' sort which is computationally heavy, use cache 43 if (sort === 'active') { 44 const cached = userStatsCache.get(did); 45 if (cached && now - cached.timestamp < STATS_CACHE_TTL) { 46 const postsMap = posts.get(did); 47 - // Simple invalidation check: if post count matches, assume cache is valid enough 48 - // This avoids iterating the map just to check contents. 49 - // Ideally we'd have a version/hash on the map. 50 if (postsMap && postsMap.size > 0) return { ...cached.stats, did }; 51 } 52 } ··· 81 if (ageMs < quarterPosts) recentPostCount++; 82 if (sort === 'active') { 83 const ageHours = ageMs / (1000 * 60 * 60); 84 - // score = 1 / t^G 85 activeScore += 1 / Math.pow(ageHours + 1, gravity); 86 } 87 } ··· 99 }; 100 }; 101 102 - // weights 103 const quoteWeight = 4; 104 const replyWeight = 6; 105 const repostWeight = 2; ··· 108 const halfLifeMs = 3 * oneDay; 109 const decayLambda = 0.693 / halfLifeMs; 110 111 - // normalization constants 112 const rateBaseline = 1; 113 const ratePower = 0.5; 114 const windowSize = 7 * oneDay; 115 116 - // Cache for post rates to avoid iterating every user's timeline every time 117 const rateCache = new Map<Did, { rate: number; calculatedAt: number; postCount: number }>(); 118 119 const getPostRate = (did: Did, posts: Map<ResourceUri, PostWithUri>, now: number): number => { 120 const cached = rateCache.get(did); 121 - // If cached and number of posts hasn't changed, return cached rate 122 if (cached && cached.postCount === posts.size && now - cached.calculatedAt < 5 * 60 * 1000) 123 return cached.rate; 124 ··· 151 user: Did, 152 followsMap: Map<ResourceUri, AppBskyGraphFollow.Main>, 153 allPosts: Map<Did, Map<ResourceUri, PostWithUri>>, 154 - backlinks_: Map<ResourceUri, Map<BacklinksSource, Set<Backlink>>>, 155 - replyIndex: Map<Did, Set<ResourceUri>>, // NEW: Inverted Index 156 now: number 157 ) => { 158 const scores = new Map<Did, number>(); ··· 162 return Math.exp(-decayLambda * age); 163 }; 164 165 - // Helper to add score 166 const addScore = (did: Did, weight: number, time: number) => { 167 const current = scores.get(did) ?? 0; 168 scores.set(did, current + weight * decay(time)); 169 }; 170 171 - // 1. Process MY posts (Me -> Others) 172 - // This is relatively cheap as "my posts" are few compared to "everyone's posts" 173 const myPosts = allPosts.get(user); 174 if (myPosts) { 175 const seenRoots = new Set<ResourceUri>(); 176 for (const post of myPosts.values()) { 177 const t = new Date(post.record.createdAt).getTime(); 178 179 - // If I replied to someone 180 if (post.record.reply) { 181 const parentUri = post.record.reply.parent.uri; 182 const rootUri = post.record.reply.root.uri; ··· 191 } 192 } 193 194 - // If I quoted someone 195 if (post.record.embed?.$type === 'app.bsky.embed.record') { 196 const targetDid = extractDidFromUri(post.record.embed.record.uri); 197 if (targetDid && targetDid !== user) addScore(targetDid, quoteWeight, t); ··· 199 } 200 } 201 202 - // 2. Process OTHERS -> ME (using Index) 203 - // Optimized: Use replyIndex instead of iterating all follows 204 const repliesToMe = replyIndex.get(user); 205 if (repliesToMe) { 206 for (const uri of repliesToMe) { 207 const authorDid = extractDidFromUri(uri); 208 - if (!authorDid || authorDid === user) continue; // Self-reply 209 210 const postsMap = allPosts.get(authorDid); 211 const post = postsMap?.get(uri); 212 - if (!post) continue; // Post data not loaded? 213 214 const t = new Date(post.record.createdAt).getTime(); 215 addScore(authorDid, replyWeight, t); 216 } 217 } 218 219 - for (const [uri, backlinks] of backlinks_) { 220 - const targetDid = extractDidFromUri(uri); 221 - if (!targetDid || targetDid !== user) continue; // Only care about interactions on MY posts 222 223 - const reposts = backlinks.get(repostSource); 224 - if (reposts) { 225 const adds = new Map<Did, { score: number; repostCount: number }>(); 226 - for (const repost of reposts) { 227 - if (repost.did === user) continue; 228 - const add = adds.get(repost.did) ?? { score: 0, repostCount: 0 }; 229 const diminishFactor = 9; 230 - const weight = repostWeight * (diminishFactor / (add.repostCount + diminishFactor)); 231 - adds.set(repost.did, { 232 - score: add.score + weight, 233 - repostCount: add.repostCount + 1 234 - }); 235 - } 236 237 - // Get the timestamp of the post being reposted to calculate decay 238 - // (Interaction timestamp is unknown for backlinks usually, so we use post timestamp as proxy or 'now'? 239 - // Original code used `post.record.createdAt`. 240 - const myPost = myPosts?.get(uri); 241 - if (myPost) { 242 - const t = new Date(myPost.record.createdAt).getTime(); 243 - for (const [did, add] of adds.entries()) addScore(did, add.score, t); 244 } 245 } 246 } 247 248 - // Apply normalization 249 for (const [did, score] of scores) { 250 const posts = allPosts.get(did); 251 const rate = posts ? getPostRate(did, posts, now) : 0;
··· 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 ··· 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, ··· 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 } ··· 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 } ··· 90 }; 91 }; 92 93 const quoteWeight = 4; 94 const replyWeight = 6; 95 const repostWeight = 2; ··· 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 ··· 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>(); ··· 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; ··· 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); ··· 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;
+7 -2
src/lib/index.ts
··· 5 ParsedResourceUri, 6 ResourceUri 7 } from '@atcute/lexicons'; 8 - import type { 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 = (parsed: ParsedCanonicalResourceUri): CanonicalResourceUri => { 15 return `at://${parsed.repo}/${parsed.collection}/${parsed.rkey}${parsed.fragment ? `#${parsed.fragment}` : ''}`; 16 }; 17 ··· 25 export const likeSource: BacklinksSource = 'app.bsky.feed.like:subject.uri'; 26 export const repostSource: BacklinksSource = 'app.bsky.feed.repost:subject.uri'; 27 export const replySource: BacklinksSource = 'app.bsky.feed.post:reply.parent.uri'; 28 29 export const timestampFromCursor = (cursor: string | undefined) => { 30 if (!cursor) return undefined;
··· 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 ··· 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;
+2 -1
src/lib/oauth.ts
··· 7 client_uri: domain, 8 logo_uri: `${domain}/favicon.png`, 9 redirect_uris: [`${domain}/`], 10 - scope: 'atproto repo:*?action=create&action=update&action=delete blob:*/*', 11 grant_types: ['authorization_code', 'refresh_token'], 12 response_types: ['code'], 13 token_endpoint_auth_method: 'none',
··· 7 client_uri: domain, 8 logo_uri: `${domain}/favicon.png`, 9 redirect_uris: [`${domain}/`], 10 + scope: 11 + 'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* blob:*/*', 12 grant_types: ['authorization_code', 'refresh_token'], 13 response_types: ['code'], 14 token_endpoint_auth_method: 'none',
+12 -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 - }; 10 11 - export const ok = <T, E>(value: T): Result<T, E> => { 12 return { ok: true, value }; 13 }; 14 - export const err = <T, E>(error: E): Result<T, E> => { 15 return { ok: false, error }; 16 }; 17 export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
··· 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 11 + export const ok = <T>(value: T): Ok<T> => { 12 return { ok: true, value }; 13 }; 14 + export const err = <E>(error: E): Err<E> => { 15 + // console.error(error); 16 return { ok: false, error }; 17 }; 18 export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
+1 -1
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'; 5 6 export const parseToRichText = (text: string): ReturnType<typeof processTokens> => 7 processTokens(tokenize(text));
··· 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));
+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 + }
+427 -157
src/lib/state.svelte.ts
··· 1 import { writable } from 'svelte/store'; 2 import { 3 AtpClient, 4 - newPublicClient, 5 type NotificationsStream, 6 type NotificationsStreamEvent 7 - } from './at/client'; 8 import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity'; 9 - import type { Did, InferOutput, Nsid, ResourceUri } from '@atcute/lexicons'; 10 - import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch'; 11 import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; 12 - import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; 13 - import type { ComAtprotoRepoListRecords } from '@atcute/atproto'; 14 import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream'; 15 - import { expect } from './result'; 16 import type { Backlink, BacklinksSource } from './at/constellation'; 17 import { now as tidNow } from '@atcute/tid'; 18 import type { Records } from '@atcute/lexicons/ambient'; 19 import { 20 extractDidFromUri, 21 likeSource, 22 replySource, 23 repostSource, 24 - timestampFromCursor 25 } from '$lib'; 26 27 export const notificationStream = writable<NotificationsStream | null>(null); 28 export const jetstream = writable<JetstreamSubscription | null>(null); 29 30 - export type BacklinksMap = SvelteMap<BacklinksSource, SvelteSet<Backlink>>; 31 - export const allBacklinks = new SvelteMap<ResourceUri, BacklinksMap>(); 32 33 export const addBacklinks = ( 34 subject: ResourceUri, 35 source: BacklinksSource, 36 links: Iterable<Backlink> 37 ) => { 38 - let postsMap = allBacklinks.get(subject); 39 - if (!postsMap) { 40 - postsMap = new SvelteMap(); 41 - allBacklinks.set(subject, postsMap); 42 } 43 - let backlinksSet = postsMap.get(source); 44 - if (!backlinksSet) { 45 - backlinksSet = new SvelteSet(); 46 - postsMap.set(source, backlinksSet); 47 } 48 for (const link of links) { 49 - backlinksSet.add(link); 50 - // console.log( 51 - // `added backlink at://${link.did}/${link.collection}/${link.rkey} to ${subject} from ${source}` 52 - // ); 53 } 54 }; 55 ··· 58 source: BacklinksSource, 59 links: Iterable<Backlink> 60 ) => { 61 - const postsMap = allBacklinks.get(subject); 62 - if (!postsMap) return; 63 - const backlinksSet = postsMap.get(source); 64 - if (!backlinksSet) return; 65 - for (const link of links) backlinksSet.delete(link); 66 }; 67 68 - export const findBacklinksBy = ( 69 - subject: ResourceUri, 70 - source: BacklinksSource, 71 - did: Did 72 - ): Backlink[] => { 73 - const postsMap = allBacklinks.get(subject); 74 - if (!postsMap) return []; 75 - const backlinksSet = postsMap.get(source); 76 - if (!backlinksSet) return []; 77 - return Array.from(backlinksSet.values().filter((link) => link.did === did)); 78 }; 79 80 // eslint-disable-next-line @typescript-eslint/no-explicit-any ··· 98 >(); 99 100 export const fetchLinksUntil = async ( 101 client: AtpClient, 102 backlinkSource: BacklinksSource, 103 timestamp: number = -1 104 ) => { 105 - const did = client.user?.did; 106 - if (!did) return; 107 - 108 - let cursorMap = backlinksCursors.get(did); 109 if (!cursorMap) { 110 cursorMap = new SvelteMap<BacklinksSource, string | undefined>(); 111 - backlinksCursors.set(did, cursorMap); 112 } 113 114 const [_collection, source] = backlinkSource.split(':'); 115 const collection = _collection as keyof Records; 116 const cursor = cursorMap.get(backlinkSource); 117 - console.log(`${did}: fetchLinksUntil`, backlinkSource, cursor, timestamp); 118 - const result = await client.listRecordsUntil(collection, cursor, timestamp); 119 120 if (!result.ok) { 121 console.error('failed to fetch links until', result.error); ··· 150 removeBacklinks(post.uri, source, links); 151 await Promise.allSettled( 152 links.map((link) => 153 - client.atcute?.post('com.atproto.repo.deleteRecord', { 154 input: { repo: did, collection, rkey: link.rkey! } 155 }) 156 ) ··· 179 // eslint-disable-next-line svelte/prefer-svelte-reactivity 180 createdAt: new Date().toISOString() 181 }; 182 - setNestedValue(record, subject.split('.'), post.uri); 183 - await client.atcute?.post('com.atproto.repo.createRecord', { 184 input: { 185 repo: did, 186 collection, ··· 193 export const pulsingPostId = writable<string | null>(null); 194 195 export const viewClient = new AtpClient(); 196 - export const clients = new SvelteMap<AtprotoDid, AtpClient>(); 197 - export const getClient = async (did: AtprotoDid): Promise<AtpClient> => { 198 - if (!clients.has(did)) clients.set(did, await newPublicClient(did)); 199 - return clients.get(did)!; 200 - }; 201 202 export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>(); 203 ··· 205 did: Did, 206 followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]> 207 ) => { 208 - if (!follows.has(did)) { 209 - follows.set(did, new SvelteMap(followMap)); 210 return; 211 } 212 - const map = follows.get(did)!; 213 for (const [uri, record] of followMap) map.set(uri, record); 214 }; 215 216 - export const fetchFollows = async (did: AtprotoDid) => { 217 - const client = await getClient(did); 218 - const res = await client.listRecordsUntil('app.bsky.graph.follow'); 219 - if (!res.ok) return; 220 addFollows( 221 - did, 222 res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main]) 223 ); 224 }; 225 226 - export const fetchForInteractions = async (did: AtprotoDid) => { 227 - const client = await getClient(did); 228 - const res = await client.listRecords('app.bsky.feed.post'); 229 if (!res.ok) return; 230 - addPostsRaw(did, res.value); 231 232 const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1; 233 - const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000; 234 const timestamp = Math.min(cursorTimestamp, threeDaysAgo); 235 - console.log(`${did}: fetchFollowPosts`, res.value.cursor, timestamp); 236 - await Promise.all([repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); 237 }; 238 239 export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 240 // did -> post uris that are replies to that did 241 export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 242 243 - export const addPostsRaw = ( 244 - did: AtprotoDid, 245 - newPosts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']> 246 - ) => { 247 - const postsWithUri = newPosts.records.map((post): [ResourceUri, PostWithUri] => [ 248 - post.uri, 249 - { cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri 250 - ]); 251 - addPosts(postsWithUri); 252 - postCursors.set(did, { value: newPosts.cursor, end: newPosts.cursor === undefined }); 253 }; 254 255 - export const addPosts = (newPosts: Iterable<[ResourceUri, PostWithUri]>) => { 256 - for (const [uri, post] of newPosts) { 257 - const parsedUri = expect(parseCanonicalResourceUri(uri)); 258 let posts = allPosts.get(parsedUri.repo); 259 if (!posts) { 260 posts = new SvelteMap(); 261 allPosts.set(parsedUri.repo, posts); 262 } 263 - posts.set(uri, post); 264 - const link: Backlink = { 265 - did: parsedUri.repo, 266 - collection: parsedUri.collection, 267 - rkey: parsedUri.rkey 268 - }; 269 if (post.record.reply) { 270 addBacklinks(post.record.reply.parent.uri, replySource, [link]); 271 272 // update reply index 273 const parentDid = extractDidFromUri(post.record.reply.parent.uri); ··· 277 set = new SvelteSet(); 278 replyIndex.set(parentDid, set); 279 } 280 - set.add(uri); 281 } 282 } 283 } 284 }; 285 286 export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 287 export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 288 289 export const addTimeline = (did: Did, uris: Iterable<ResourceUri>) => { 290 let timeline = timelines.get(did); 291 if (!timeline) { 292 timeline = new SvelteSet(); 293 timelines.set(did, timeline); 294 } 295 - for (const uri of uris) timeline.add(uri); 296 }; 297 298 - export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => { 299 - const client = await getClient(did); 300 - 301 - const cursor = postCursors.get(did); 302 if (cursor && cursor.end) return; 303 304 - const accPosts = await fetchPostsWithBacklinks(client, cursor?.value, limit); 305 - if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`; 306 307 // if the cursor is undefined, we've reached the end of the timeline 308 - if (!accPosts.value.cursor) { 309 - postCursors.set(did, { ...cursor, end: true }); 310 - return; 311 } 312 313 - postCursors.set(did, { value: accPosts.value.cursor, end: false }); 314 - const hydrated = await hydratePosts(client, did, accPosts.value.posts); 315 - if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`; 316 317 - addPosts(hydrated.value); 318 - addTimeline(did, hydrated.value.keys()); 319 320 - const timestamp = timestampFromCursor(accPosts.value.cursor); 321 - console.log(`${did}: fetchTimeline`, accPosts.value.cursor, timestamp); 322 - await Promise.all([likeSource, repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); 323 }; 324 325 - export const handleJetstreamEvent = (event: JetstreamEvent) => { 326 if (event.kind !== 'commit') return; 327 328 const { did, commit } = event; 329 - if (commit.collection !== 'app.bsky.feed.post') return; 330 - 331 - const uri: ResourceUri = `at://${did}/${commit.collection}/${commit.rkey}`; 332 - 333 - if (commit.operation === 'create') { 334 - const { cid, record } = commit; 335 - const post: PostWithUri = { 336 - uri, 337 - cid, 338 - // assume record is valid, we trust the jetstream 339 - record: record as AppBskyFeedPost.Main 340 - }; 341 - addPosts([[uri, post]]); 342 - addTimeline(did, [uri]); 343 - } else if (commit.operation === 'delete') { 344 - allPosts.get(did)?.delete(uri); 345 } 346 }; 347 348 - export const handleNotification = async (event: NotificationsStreamEvent) => { 349 - if (event.type === 'message') { 350 - const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject)); 351 - const did = parsedSubjectUri.repo as AtprotoDid; 352 - const client = await getClient(did); 353 - const subjectPost = await client.getRecord( 354 - AppBskyFeedPost.mainSchema, 355 - did, 356 - parsedSubjectUri.rkey 357 - ); 358 - if (!subjectPost.ok) return; 359 360 - const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 361 - const hydrated = await hydratePosts(client, did, [ 362 - { 363 - record: subjectPost.value.record, 364 - uri: event.data.link.subject, 365 - cid: subjectPost.value.cid, 366 - replies: { 367 - cursor: null, 368 - total: 1, 369 - records: [ 370 - { 371 - did: parsedSourceUri.repo, 372 - collection: parsedSourceUri.collection, 373 - rkey: parsedSourceUri.rkey 374 - } 375 - ] 376 - } 377 } 378 - ]); 379 - if (!hydrated.ok) { 380 - console.error(`cant hydrate posts ${did}: ${hydrated.error}`); 381 - return; 382 } 383 384 - // console.log(hydrated); 385 - addPosts(hydrated.value); 386 - addTimeline(did, hydrated.value.keys()); 387 } 388 }; 389 ··· 393 setInterval(() => { 394 currentTime.setTime(Date.now()); 395 }, 1000);
··· 1 import { writable } from 'svelte/store'; 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'; 35 36 export const notificationStream = writable<NotificationsStream | null>(null); 37 export const jetstream = writable<JetstreamSubscription | null>(null); 38 39 + export const profiles = new SvelteMap<Did, AppBskyActorProfile.Main>(); 40 + export const handles = new SvelteMap<Did, Handle>(); 41 + 42 + // source -> subject -> did (who did the interaction) -> rkey 43 + export type BacklinksMap = SvelteMap< 44 + BacklinksSource, 45 + SvelteMap<ResourceUri, SvelteMap<Did, SvelteSet<RecordKey>>> 46 + >; 47 + export const allBacklinks: BacklinksMap = new SvelteMap(); 48 49 export const addBacklinks = ( 50 subject: ResourceUri, 51 source: BacklinksSource, 52 links: Iterable<Backlink> 53 ) => { 54 + let subjectMap = allBacklinks.get(source); 55 + if (!subjectMap) { 56 + subjectMap = new SvelteMap(); 57 + allBacklinks.set(source, subjectMap); 58 } 59 + 60 + let didMap = subjectMap.get(subject); 61 + if (!didMap) { 62 + didMap = new SvelteMap(); 63 + subjectMap.set(subject, didMap); 64 } 65 + 66 for (const link of links) { 67 + let rkeys = didMap.get(link.did); 68 + if (!rkeys) { 69 + rkeys = new SvelteSet(); 70 + didMap.set(link.did, rkeys); 71 + } 72 + rkeys.add(link.rkey); 73 } 74 }; 75 ··· 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 ··· 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); ··· 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 ) ··· 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, ··· 241 export const pulsingPostId = writable<string | null>(null); 242 243 export const viewClient = new AtpClient(); 244 + export const clients = new SvelteMap<Did, AtpClient>(); 245 246 export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>(); 247 ··· 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); ··· 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 ··· 661 setInterval(() => { 662 currentTime.setTime(Date.now()); 663 }, 1000); 664 + 665 + export const router = new Router();
+1 -3
src/lib/theme.ts
··· 18 const id = input.split(':').pop() || input; 19 20 hash = 0; 21 - for (let i = 0; i < Math.min(10, id.length); i++) { 22 - hash = (hash << 4) + id.charCodeAt(i); 23 - } 24 hash = hash >>> 0; 25 26 // magic mixing
··· 18 const id = input.split(':').pop() || input; 19 20 hash = 0; 21 + for (let i = 0; i < Math.min(10, id.length); i++) hash = (hash << 4) + id.charCodeAt(i); 22 hash = hash >>> 0; 23 24 // magic mixing
+6 -3
src/lib/thread.ts
··· 1 import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons'; 2 import type { Account } from './accounts'; 3 import { expect } from './result'; 4 import type { PostWithUri } from './at/fetch'; 5 6 export type ThreadPost = { 7 data: PostWithUri; ··· 11 parentUri: ResourceUri | null; 12 depth: number; 13 newestTime: number; 14 }; 15 16 export type Thread = { ··· 43 rkey: parsedUri.rkey, 44 parentUri, 45 depth: 0, 46 - newestTime: new Date(data.record.createdAt).getTime() 47 }; 48 49 if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); ··· 150 } 151 152 threads.sort((a, b) => b.newestTime - a.newestTime); 153 - 154 - // console.log(threads); 155 156 return threads; 157 };
··· 1 + // updated src/lib/thread.ts 2 + 3 import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons'; 4 import type { Account } from './accounts'; 5 import { expect } from './result'; 6 import type { PostWithUri } from './at/fetch'; 7 + import { isBlockedBy } from './state.svelte'; 8 9 export type ThreadPost = { 10 data: PostWithUri; ··· 14 parentUri: ResourceUri | null; 15 depth: number; 16 newestTime: number; 17 + isBlocked?: boolean; 18 }; 19 20 export type Thread = { ··· 47 rkey: parsedUri.rkey, 48 parentUri, 49 depth: 0, 50 + newestTime: new Date(data.record.createdAt).getTime(), 51 + isBlocked: isBlockedBy(parsedUri.repo, account) 52 }; 53 54 if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); ··· 155 } 156 157 threads.sort((a, b) => b.newestTime - a.newestTime); 158 159 return threads; 160 };
-345
src/routes/+page.svelte
··· 1 - <script lang="ts"> 2 - import PostComposer, { type State as PostComposerState } from '$components/PostComposer.svelte'; 3 - import AccountSelector from '$components/AccountSelector.svelte'; 4 - import SettingsView from '$components/SettingsView.svelte'; 5 - import NotificationsView from '$components/NotificationsView.svelte'; 6 - import FollowingView from '$components/FollowingView.svelte'; 7 - import TimelineView from '$components/TimelineView.svelte'; 8 - import { AtpClient, streamNotifications } from '$lib/at/client'; 9 - import { accounts, type Account } from '$lib/accounts'; 10 - import { onMount, tick } from 'svelte'; 11 - import { SvelteMap } from 'svelte/reactivity'; 12 - import { 13 - clients, 14 - postCursors, 15 - fetchForInteractions, 16 - fetchFollows, 17 - follows, 18 - notificationStream, 19 - allPosts, 20 - viewClient, 21 - jetstream, 22 - handleJetstreamEvent, 23 - handleNotification 24 - } from '$lib/state.svelte'; 25 - import { get } from 'svelte/store'; 26 - import Icon from '@iconify/svelte'; 27 - import { sessions } from '$lib/at/oauth'; 28 - import type { AtprotoDid, Did } from '@atcute/lexicons/syntax'; 29 - import type { PageProps } from './+page'; 30 - import { JetstreamSubscription } from '@atcute/jetstream'; 31 - import { settings } from '$lib/settings'; 32 - 33 - const { data: loadData }: PageProps = $props(); 34 - 35 - // svelte-ignore state_referenced_locally 36 - let errors = $state(loadData.client.ok ? [] : [loadData.client.error]); 37 - let errorsOpen = $state(false); 38 - 39 - let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null); 40 - $effect(() => { 41 - if (selectedDid) localStorage.setItem('selectedDid', selectedDid); 42 - else localStorage.removeItem('selectedDid'); 43 - }); 44 - const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null); 45 - 46 - const loginAccount = async (account: Account) => { 47 - if (clients.has(account.did)) return; 48 - const client = new AtpClient(); 49 - const result = await client.login(await sessions.get(account.did)); 50 - if (!result.ok) { 51 - errors.push(`failed to login into @${account.handle ?? account.did}: ${result.error}`); 52 - return; 53 - } 54 - clients.set(account.did, client); 55 - }; 56 - const handleAccountSelected = async (did: AtprotoDid) => { 57 - selectedDid = did; 58 - const account = $accounts.find((acc) => acc.did === did); 59 - if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute)) 60 - await loginAccount(account); 61 - }; 62 - const handleLogout = async (did: AtprotoDid) => { 63 - await sessions.remove(did); 64 - const newAccounts = $accounts.filter((acc) => acc.did !== did); 65 - $accounts = newAccounts; 66 - clients.delete(did); 67 - postCursors.delete(did); 68 - handleAccountSelected(newAccounts[0]?.did); 69 - }; 70 - 71 - type View = 'timeline' | 'notifications' | 'following' | 'settings'; 72 - let currentView = $state<View>('timeline'); 73 - let animClass = $state('animate-fade-in-scale'); 74 - let scrollPositions = new SvelteMap<View, number>(); 75 - 76 - const viewOrder: Record<View, number> = { 77 - timeline: 0, 78 - following: 1, 79 - notifications: 2, 80 - settings: 3 81 - }; 82 - 83 - const switchView = async (newView: View) => { 84 - if (currentView === newView) return; 85 - scrollPositions.set(currentView, window.scrollY); 86 - 87 - const direction = viewOrder[newView] > viewOrder[currentView] ? 'right' : 'left'; 88 - animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left'; 89 - currentView = newView; 90 - 91 - await tick(); 92 - 93 - window.scrollTo({ top: scrollPositions.get(newView) || 0, behavior: 'instant' }); 94 - }; 95 - 96 - let postComposerState = $state<PostComposerState>({ type: 'null' }); 97 - let showScrollToTop = $state(false); 98 - const handleScroll = () => { 99 - if (currentView === 'timeline') showScrollToTop = window.scrollY > 300; 100 - }; 101 - const scrollToTop = () => { 102 - window.scrollTo({ top: 0, behavior: 'smooth' }); 103 - }; 104 - 105 - onMount(() => { 106 - window.addEventListener('scroll', handleScroll); 107 - 108 - accounts.subscribe((newAccounts) => { 109 - get(notificationStream)?.stop(); 110 - // jetstream.set(null); 111 - if (newAccounts.length === 0) return; 112 - notificationStream.set( 113 - streamNotifications( 114 - newAccounts.map((account) => account.did), 115 - 'app.bsky.feed.post:reply.parent.uri', 116 - 'app.bsky.feed.post:embed.record.record.uri', 117 - 'app.bsky.feed.post:embed.record.uri' 118 - ) 119 - ); 120 - }); 121 - notificationStream.subscribe((stream) => { 122 - if (!stream) return; 123 - stream.listen(handleNotification); 124 - }); 125 - 126 - console.log(`creating jetstream subscription to ${$settings.endpoints.jetstream}`); 127 - const jetstreamSub = new JetstreamSubscription({ 128 - url: $settings.endpoints.jetstream, 129 - wantedCollections: ['app.bsky.feed.post'], 130 - wantedDids: ['did:web:guestbook.gaze.systems'] // initially contain sentinel 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(() => { 148 - $accounts.forEach((account) => { 149 - fetchFollows(account.did).then(() => 150 - follows 151 - .get(account.did) 152 - ?.forEach((follow) => fetchForInteractions(follow.subject as AtprotoDid)) 153 - ); 154 - fetchForInteractions(account.did); 155 - }); 156 - }); 157 - } else { 158 - selectedDid = null; 159 - } 160 - 161 - return () => window.removeEventListener('scroll', handleScroll); 162 - }); 163 - 164 - $effect(() => { 165 - const wantedDids: Did[] = ['did:web:guestbook.gaze.systems']; 166 - 167 - for (const followMap of follows.values()) 168 - for (const follow of followMap.values()) wantedDids.push(follow.subject); 169 - for (const account of $accounts) wantedDids.push(account.did); 170 - 171 - console.log('updating jetstream options:', wantedDids); 172 - $jetstream?.updateOptions({ wantedDids }); 173 - }); 174 - </script> 175 - 176 - {#snippet appButton( 177 - onClick: () => void, 178 - icon: string, 179 - ariaLabel: string, 180 - isActive: boolean, 181 - iconHover?: string 182 - )} 183 - <button 184 - onclick={onClick} 185 - class="group rounded-sm p-2 transition-all hover:scale-110 hover:shadow-lg 186 - {isActive 187 - ? 'bg-(--nucleus-accent)/25 text-(--nucleus-accent)' 188 - : 'bg-(--nucleus-accent)/10 text-(--nucleus-accent) hover:bg-(--nucleus-accent)/15'}" 189 - aria-label={ariaLabel} 190 - > 191 - <Icon class="group-hover:hidden" {icon} width={28} /> 192 - <Icon class="hidden group-hover:block" icon={iconHover ?? icon} width={28} /> 193 - </button> 194 - {/snippet} 195 - 196 - <div class="mx-auto flex min-h-dvh max-w-2xl flex-col"> 197 - <div class="flex-1"> 198 - <!-- timeline --> 199 - <TimelineView 200 - class={currentView === 'timeline' ? `${animClass}` : 'hidden'} 201 - client={selectedClient} 202 - bind:postComposerState 203 - /> 204 - 205 - {#if currentView === 'settings'} 206 - <div class={animClass}> 207 - <SettingsView /> 208 - </div> 209 - {/if} 210 - {#if currentView === 'notifications'} 211 - <div class={animClass}> 212 - <NotificationsView /> 213 - </div> 214 - {/if} 215 - {#if currentView === 'following'} 216 - <div class={animClass}> 217 - <FollowingView selectedClient={selectedClient!} selectedDid={selectedDid!} /> 218 - </div> 219 - {/if} 220 - </div> 221 - 222 - <!-- header / footer --> 223 - <div id="app-footer" class="sticky bottom-0 z-10 mt-4"> 224 - {#if errors.length > 0} 225 - <div class="relative m-3 mb-1 error-disclaimer"> 226 - <div class="flex items-center gap-2 text-red-500"> 227 - <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> 228 - there are ({errors.length}) errors 229 - <div class="grow"></div> 230 - <button onclick={() => (errorsOpen = !errorsOpen)} class="action-button p-1 px-1.5" 231 - >{errorsOpen ? 'hide details' : 'see details'}</button 232 - > 233 - </div> 234 - {#if errorsOpen} 235 - <div 236 - class="absolute right-0 bottom-full left-0 z-10 mb-2 flex animate-fade-in-scale-fast flex-col gap-1 error-disclaimer shadow-lg transition-all" 237 - > 238 - {#each errors as error, idx (idx)} 239 - <p>โ€ข {error}</p> 240 - {/each} 241 - </div> 242 - {/if} 243 - </div> 244 - {/if} 245 - 246 - <div 247 - class=" 248 - {currentView === 'timeline' || currentView === 'following' ? '' : 'hidden'} 249 - z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all 250 - " 251 - > 252 - <!-- composer and error disclaimer (above thread list, not scrollable) --> 253 - <div class="footer-border-bg rounded-sm px-0.5 py-0.5"> 254 - <div class="footer-bg flex gap-2 rounded-sm p-1.5 shadow-2xl"> 255 - <AccountSelector 256 - client={viewClient} 257 - accounts={$accounts} 258 - bind:selectedDid 259 - onAccountSelected={handleAccountSelected} 260 - onLogout={handleLogout} 261 - /> 262 - 263 - {#if selectedClient} 264 - <div class="flex-1"> 265 - <PostComposer 266 - client={selectedClient} 267 - onPostSent={(post) => allPosts.get(selectedDid!)?.set(post.uri, post)} 268 - bind:_state={postComposerState} 269 - /> 270 - </div> 271 - {:else} 272 - <div 273 - class="flex flex-1 items-center justify-center rounded-sm border-2 border-(--nucleus-accent)/20 bg-(--nucleus-accent)/4 px-4 py-2.5 backdrop-blur-sm" 274 - > 275 - <p class="text-sm opacity-80">select or add an account to post</p> 276 - </div> 277 - {/if} 278 - 279 - {#if postComposerState.type === 'null' && showScrollToTop} 280 - {@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top', false)} 281 - {/if} 282 - </div> 283 - </div> 284 - </div> 285 - 286 - <div id="footer-portal" class="contents"></div> 287 - 288 - <div class="footer-border-bg rounded-t-sm px-0.5 pt-0.5"> 289 - <div class="footer-bg rounded-t-sm"> 290 - <div class="flex items-center gap-1.5 px-2 py-1"> 291 - <div class="mb-2"> 292 - <h1 class="text-3xl font-bold tracking-tight">nucleus</h1> 293 - <div class="mt-1 flex gap-2"> 294 - <div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div> 295 - <div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div> 296 - </div> 297 - </div> 298 - <div class="grow"></div> 299 - {@render appButton( 300 - () => switchView('timeline'), 301 - 'heroicons:home', 302 - 'timeline', 303 - currentView === 'timeline', 304 - 'heroicons:home-solid' 305 - )} 306 - {@render appButton( 307 - () => switchView('following'), 308 - 'heroicons:users', 309 - 'following', 310 - currentView === 'following', 311 - 'heroicons:users-solid' 312 - )} 313 - {@render appButton( 314 - () => switchView('notifications'), 315 - 'heroicons:bell', 316 - 'notifications', 317 - currentView === 'notifications', 318 - 'heroicons:bell-solid' 319 - )} 320 - {@render appButton( 321 - () => switchView('settings'), 322 - 'heroicons:cog-6-tooth', 323 - 'settings', 324 - currentView === 'settings', 325 - 'heroicons:cog-6-tooth-solid' 326 - )} 327 - </div> 328 - </div> 329 - </div> 330 - </div> 331 - </div> 332 - 333 - <style> 334 - .footer-bg { 335 - background: linear-gradient( 336 - to right, 337 - color-mix(in srgb, var(--nucleus-accent) 18%, var(--nucleus-bg)), 338 - color-mix(in srgb, var(--nucleus-accent2) 13%, var(--nucleus-bg)) 339 - ); 340 - } 341 - 342 - .footer-border-bg { 343 - background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2)); 344 - } 345 - </style>
···
-45
src/routes/+page.ts
··· 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 // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 12 // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 // See https://svelte.dev/docs/kit/adapters for more information about adapters. 14 - adapter: adapter(), 15 alias: { 16 $lib: 'src/lib', 17 '$lib/*': 'src/lib/*',
··· 11 // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 12 // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 // See https://svelte.dev/docs/kit/adapters for more information about adapters. 14 + adapter: adapter({ 15 + fallback: 'index.html', 16 + precompress: true 17 + }), 18 alias: { 19 $lib: 'src/lib', 20 '$lib/*': 'src/lib/*',