Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

cope settings sync

+1103 -187
+2
package.json
··· 211 211 "multiformats": "9.9.0", 212 212 "nanoid": "^5.1.9", 213 213 "normalize-url": "^8.1.1", 214 + "pako": "^2.1.0", 214 215 "patch-package": "^8.0.1", 215 216 "postinstall-postinstall": "^2.1.0", 216 217 "psl": "^1.15.0", ··· 284 285 "@types/lodash.chunk": "^4.2.9", 285 286 "@types/lodash.debounce": "^4.0.9", 286 287 "@types/lodash.shuffle": "^4.2.9", 288 + "@types/pako": "^2.0.4", 287 289 "@types/psl": "^1.11.0", 288 290 "@types/react": "^19.2.14", 289 291 "@types/react-dom": "^19.2.3",
+38 -178
pnpm-lock.yaml
··· 5 5 excludeLinksFromLockfile: false 6 6 7 7 overrides: 8 - '@babel/helper-define-polyfill-provider>@babel/helper-compilation-targets': ^7.28.6 8 + '@expo/image-utils': 0.8.12 9 + '@react-native/babel-preset': 0.81.5 10 + '@react-native/normalize-colors': 0.81.5 11 + '@types/estree': 1.0.6 12 + metro: 0.83.3 13 + metro-config: 0.83.3 14 + metro-core: 0.83.3 15 + metro-runtime: 0.83.3 16 + metro-source-map: 0.83.3 17 + multiformats: 9.9.0 18 + react-native-compressor: 1.13.0 19 + react-native-mmkv: npm:@bsky.app/react-native-mmkv@2.12.5 20 + sonner-native: 0.21.0 21 + unicode-segmenter: 0.14.5 9 22 10 23 importers: 11 24 ··· 386 399 normalize-url: 387 400 specifier: ^8.1.1 388 401 version: 8.1.1 402 + pako: 403 + specifier: ^2.1.0 404 + version: 2.1.0 389 405 patch-package: 390 406 specifier: ^8.0.1 391 407 version: 8.0.1 ··· 600 616 '@types/lodash.shuffle': 601 617 specifier: ^4.2.9 602 618 version: 4.2.9 619 + '@types/pako': 620 + specifier: ^2.0.4 621 + version: 2.0.4 603 622 '@types/psl': 604 623 specifier: ^1.11.0 605 624 version: 1.11.0 ··· 2625 2644 '@expo/html-elements@0.12.5': 2626 2645 resolution: {integrity: sha512-28KWO88YKykKU7ke5sEQs5TivFRMs1Aktz13xxgqAf5rTgb+lka0VKVt3W2fG7ksbUQ407rtUqz7SEAq298NvQ==} 2627 2646 2628 - '@expo/image-utils@0.3.23': 2629 - resolution: {integrity: sha512-nhUVvW0TrRE4jtWzHQl8TR4ox7kcmrc2I0itaeJGjxF5A54uk7avgA0wRt7jP1rdvqQo1Ke1lXyLYREdhN9tPw==} 2630 - 2631 2647 '@expo/image-utils@0.8.12': 2632 2648 resolution: {integrity: sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A==} 2633 2649 ··· 2672 2688 '@expo/sdk-runtime-versions@1.0.0': 2673 2689 resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} 2674 2690 2675 - '@expo/spawn-async@1.5.0': 2676 - resolution: {integrity: sha512-LB7jWkqrHo+5fJHNrLAFdimuSXQ2MQ4lA7SQW5bf/HbsXuV2VrT/jN/M8f/KoWt0uJMGN4k/j7Opx4AvOOxSew==} 2677 - engines: {node: '>=4'} 2678 - 2679 2691 '@expo/spawn-async@1.7.2': 2680 2692 resolution: {integrity: sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==} 2681 2693 engines: {node: '>=12'} ··· 4075 4087 resolution: {integrity: sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==} 4076 4088 engines: {node: '>= 20.19.4'} 4077 4089 4078 - '@react-native/normalize-colors@0.74.89': 4079 - resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} 4080 - 4081 4090 '@react-native/normalize-colors@0.81.5': 4082 4091 resolution: {integrity: sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==} 4083 4092 ··· 5294 5303 5295 5304 '@types/estree@1.0.6': 5296 5305 resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} 5297 - 5298 - '@types/estree@1.0.8': 5299 - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 5300 5306 5301 5307 '@types/express-serve-static-core@4.19.8': 5302 5308 resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} ··· 5386 5392 '@types/node@20.19.39': 5387 5393 resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} 5388 5394 5395 + '@types/pako@2.0.4': 5396 + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} 5397 + 5389 5398 '@types/pg@8.20.0': 5390 5399 resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} 5391 5400 ··· 6025 6034 6026 6035 asynckit@0.4.0: 6027 6036 resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} 6028 - 6029 - at-least-node@1.0.0: 6030 - resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} 6031 - engines: {node: '>= 4.0.0'} 6032 6037 6033 6038 atomic-sleep@1.0.0: 6034 6039 resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} ··· 6683 6688 cross-fetch@3.2.0: 6684 6689 resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} 6685 6690 6686 - cross-spawn@6.0.6: 6687 - resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} 6688 - engines: {node: '>=4.8'} 6689 - 6690 6691 cross-spawn@7.0.6: 6691 6692 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} 6692 6693 engines: {node: '>= 8'} 6693 - 6694 - crypto-random-string@1.0.0: 6695 - resolution: {integrity: sha512-GsVpkFPlycH7/fRR7Dhcmnoii54gV1nz7y4CWyeFS14N+JVBBhY+r8amRHE4BwSYal7BPTDp8isvAlCxyFt3Hg==} 6696 - engines: {node: '>=4'} 6697 6694 6698 6695 css-background-parser@0.1.0: 6699 6696 resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} ··· 7929 7926 fs-extra@8.1.0: 7930 7927 resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} 7931 7928 engines: {node: '>=6 <7 || >=8'} 7932 - 7933 - fs-extra@9.0.0: 7934 - resolution: {integrity: sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g==} 7935 - engines: {node: '>=10'} 7936 7929 7937 7930 fs-monkey@1.1.0: 7938 7931 resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} ··· 9328 9321 engines: {node: '>=4'} 9329 9322 hasBin: true 9330 9323 9331 - mime@2.6.0: 9332 - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} 9333 - engines: {node: '>=4.0.0'} 9334 - hasBin: true 9335 - 9336 9324 mimic-fn@1.2.0: 9337 9325 resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} 9338 9326 engines: {node: '>=4'} ··· 9423 9411 resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} 9424 9412 hasBin: true 9425 9413 9426 - multiformats@13.4.2: 9427 - resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} 9428 - 9429 9414 multiformats@9.9.0: 9430 9415 resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 9431 9416 ··· 9473 9458 9474 9459 nested-error-stacks@2.0.1: 9475 9460 resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} 9476 - 9477 - nice-try@1.0.5: 9478 - resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} 9479 9461 9480 9462 no-case@3.0.4: 9481 9463 resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} ··· 9723 9705 pako@0.2.9: 9724 9706 resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} 9725 9707 9708 + pako@2.1.0: 9709 + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} 9710 + 9726 9711 param-case@3.0.4: 9727 9712 resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} 9728 9713 ··· 9777 9762 9778 9763 path-is-inside@1.0.2: 9779 9764 resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} 9780 - 9781 - path-key@2.0.1: 9782 - resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} 9783 - engines: {node: '>=4'} 9784 9765 9785 9766 path-key@3.1.1: 9786 9767 resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} ··· 10987 10968 semver-compare@1.0.0: 10988 10969 resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} 10989 10970 10990 - semver@5.7.2: 10991 - resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} 10992 - hasBin: true 10993 - 10994 10971 semver@6.3.1: 10995 10972 resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} 10996 - hasBin: true 10997 - 10998 - semver@7.3.2: 10999 - resolution: {integrity: sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==} 11000 - engines: {node: '>=10'} 11001 10973 hasBin: true 11002 10974 11003 10975 semver@7.5.4: ··· 11072 11044 resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} 11073 11045 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 11074 11046 11075 - shebang-command@1.2.0: 11076 - resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} 11077 - engines: {node: '>=0.10.0'} 11078 - 11079 11047 shebang-command@2.0.0: 11080 11048 resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} 11081 11049 engines: {node: '>=8'} 11082 - 11083 - shebang-regex@1.0.0: 11084 - resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} 11085 - engines: {node: '>=0.10.0'} 11086 11050 11087 11051 shebang-regex@3.0.0: 11088 11052 resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} ··· 11485 11449 tdigest@0.1.2: 11486 11450 resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} 11487 11451 11488 - temp-dir@1.0.0: 11489 - resolution: {integrity: sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==} 11490 - engines: {node: '>=4'} 11491 - 11492 - tempy@0.3.0: 11493 - resolution: {integrity: sha512-WrH/pui8YCwmeiAoxV+lpRH9HpRtgBhSR2ViBPgpGb/wnYDzp21R4MN45fsCGvLROvY67o3byhJRYRONJyImVQ==} 11494 - engines: {node: '>=8'} 11495 - 11496 11452 terminal-link@2.1.1: 11497 11453 resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} 11498 11454 engines: {node: '>=8'} ··· 11670 11626 resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} 11671 11627 engines: {node: '>=10'} 11672 11628 11673 - type-fest@0.3.1: 11674 - resolution: {integrity: sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==} 11675 - engines: {node: '>=6'} 11676 - 11677 11629 type-fest@0.7.1: 11678 11630 resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} 11679 11631 engines: {node: '>=8'} ··· 11795 11747 unicode-trie@2.0.0: 11796 11748 resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} 11797 11749 11798 - unique-string@1.0.0: 11799 - resolution: {integrity: sha512-ODgiYu03y5g76A1I9Gt0/chLCzQjvzDy7DsZGsLOE/1MrF6wriEskSncj1+/C58Xk/kPZDppSctDybCwOSaGAg==} 11800 - engines: {node: '>=4'} 11801 - 11802 11750 universalify@0.1.2: 11803 11751 resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} 11804 11752 engines: {node: '>= 4.0.0'} ··· 11806 11754 universalify@0.2.0: 11807 11755 resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} 11808 11756 engines: {node: '>= 4.0.0'} 11809 - 11810 - universalify@1.0.0: 11811 - resolution: {integrity: sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==} 11812 - engines: {node: '>= 10.0.0'} 11813 11757 11814 11758 universalify@2.0.1: 11815 11759 resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} ··· 12190 12134 resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} 12191 12135 engines: {node: '>= 0.4'} 12192 12136 12193 - which@1.3.1: 12194 - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} 12195 - hasBin: true 12196 - 12197 12137 which@2.0.2: 12198 12138 resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 12199 12139 engines: {node: '>= 8'} ··· 15674 15614 15675 15615 '@expo/html-elements@0.12.5': {} 15676 15616 15677 - '@expo/image-utils@0.3.23': 15678 - dependencies: 15679 - '@expo/spawn-async': 1.5.0 15680 - chalk: 4.1.2 15681 - fs-extra: 9.0.0 15682 - getenv: 1.0.0 15683 - jimp-compact: 0.16.1 15684 - mime: 2.6.0 15685 - node-fetch: 2.7.0 15686 - parse-png: 2.1.0 15687 - resolve-from: 5.0.0 15688 - semver: 7.3.2 15689 - tempy: 0.3.0 15690 - transitivePeerDependencies: 15691 - - encoding 15692 - 15693 15617 '@expo/image-utils@0.8.12': 15694 15618 dependencies: 15695 15619 '@expo/spawn-async': 1.7.2 ··· 15806 15730 15807 15731 '@expo/sdk-runtime-versions@1.0.0': {} 15808 15732 15809 - '@expo/spawn-async@1.5.0': 15810 - dependencies: 15811 - cross-spawn: 6.0.6 15812 - 15813 15733 '@expo/spawn-async@1.7.2': 15814 15734 dependencies: 15815 15735 cross-spawn: 7.0.6 ··· 15856 15776 - clean-css 15857 15777 - csso 15858 15778 - debug 15859 - - encoding 15860 15779 - esbuild 15861 15780 - supports-color 15862 15781 - uglify-js ··· 16125 16044 '@ipld/dag-cbor@9.2.6': 16126 16045 dependencies: 16127 16046 cborg: 5.1.0 16128 - multiformats: 13.4.2 16047 + multiformats: 9.9.0 16129 16048 16130 16049 '@isaacs/cliui@8.0.2': 16131 16050 dependencies: ··· 17478 17397 17479 17398 '@react-native/js-polyfills@0.81.5': {} 17480 17399 17481 - '@react-native/normalize-colors@0.74.89': {} 17482 - 17483 17400 '@react-native/normalize-colors@0.81.5': {} 17484 17401 17485 17402 '@react-native/typescript-config@0.81.6': {} ··· 18669 18586 '@types/eslint-scope@3.7.7': 18670 18587 dependencies: 18671 18588 '@types/eslint': 9.6.1 18672 - '@types/estree': 1.0.8 18589 + '@types/estree': 1.0.6 18673 18590 18674 18591 '@types/eslint@9.6.1': 18675 18592 dependencies: 18676 - '@types/estree': 1.0.8 18593 + '@types/estree': 1.0.6 18677 18594 '@types/json-schema': 7.0.15 18678 18595 18679 18596 '@types/estree@1.0.6': {} 18680 18597 18681 - '@types/estree@1.0.8': {} 18682 - 18683 18598 '@types/express-serve-static-core@4.19.8': 18684 18599 dependencies: 18685 18600 '@types/node': 20.19.39 ··· 18787 18702 '@types/node@20.19.39': 18788 18703 dependencies: 18789 18704 undici-types: 6.21.0 18705 + 18706 + '@types/pako@2.0.4': {} 18790 18707 18791 18708 '@types/pg@8.20.0': 18792 18709 dependencies: ··· 19508 19425 19509 19426 asynckit@0.4.0: {} 19510 19427 19511 - at-least-node@1.0.0: {} 19512 - 19513 19428 atomic-sleep@1.0.0: {} 19514 19429 19515 19430 autoprefixer@10.5.0(postcss@8.5.10): ··· 20284 20199 transitivePeerDependencies: 20285 20200 - encoding 20286 20201 20287 - cross-spawn@6.0.6: 20288 - dependencies: 20289 - nice-try: 1.0.5 20290 - path-key: 2.0.1 20291 - semver: 5.7.2 20292 - shebang-command: 1.2.0 20293 - which: 1.3.1 20294 - 20295 20202 cross-spawn@7.0.6: 20296 20203 dependencies: 20297 20204 path-key: 3.1.1 20298 20205 shebang-command: 2.0.0 20299 20206 which: 2.0.2 20300 - 20301 - crypto-random-string@1.0.0: {} 20302 20207 20303 20208 css-background-parser@0.1.0: {} 20304 20209 ··· 21427 21332 21428 21333 expo-pwa@0.0.127(expo@54.0.33(@babel/core@7.25.2)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)): 21429 21334 dependencies: 21430 - '@expo/image-utils': 0.3.23 21335 + '@expo/image-utils': 0.8.12 21431 21336 chalk: 4.1.2 21432 21337 commander: 2.20.0 21433 21338 expo: 54.0.33(@babel/core@7.25.2)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) 21434 21339 update-check: 1.5.3 21435 - transitivePeerDependencies: 21436 - - encoding 21437 21340 21438 21341 expo-screen-orientation@9.0.8(expo@54.0.33(@babel/core@7.25.2)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0)): 21439 21342 dependencies: ··· 21810 21713 jsonfile: 4.0.0 21811 21714 universalify: 0.1.2 21812 21715 21813 - fs-extra@9.0.0: 21814 - dependencies: 21815 - at-least-node: 1.0.0 21816 - graceful-fs: 4.2.11 21817 - jsonfile: 6.2.1 21818 - universalify: 1.0.0 21819 - 21820 21716 fs-monkey@1.1.0: {} 21821 21717 21822 21718 fs.realpath@1.0.0: {} ··· 23567 23463 23568 23464 mime@1.6.0: {} 23569 23465 23570 - mime@2.6.0: {} 23571 - 23572 23466 mimic-fn@1.2.0: {} 23573 23467 23574 23468 mimic-fn@2.1.0: {} ··· 23634 23528 dns-packet: 5.6.1 23635 23529 thunky: 1.1.0 23636 23530 23637 - multiformats@13.4.2: {} 23638 - 23639 23531 multiformats@9.9.0: {} 23640 23532 23641 23533 murmurhash@2.0.1: {} ··· 23665 23557 neo-async@2.6.2: {} 23666 23558 23667 23559 nested-error-stacks@2.0.1: {} 23668 - 23669 - nice-try@1.0.5: {} 23670 23560 23671 23561 no-case@3.0.4: 23672 23562 dependencies: ··· 23923 23813 package-json-from-dist@1.0.1: {} 23924 23814 23925 23815 pako@0.2.9: {} 23816 + 23817 + pako@2.1.0: {} 23926 23818 23927 23819 param-case@3.0.4: 23928 23820 dependencies: ··· 23989 23881 23990 23882 path-is-inside@1.0.2: {} 23991 23883 23992 - path-key@2.0.1: {} 23993 - 23994 23884 path-key@3.1.1: {} 23995 23885 23996 23886 path-key@4.0.0: {} ··· 24909 24799 react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): 24910 24800 dependencies: 24911 24801 '@babel/runtime': 7.25.9 24912 - '@react-native/normalize-colors': 0.74.89 24802 + '@react-native/normalize-colors': 0.81.5 24913 24803 fbjs: 3.0.5 24914 24804 inline-style-prefixer: 7.0.1 24915 24805 memoize-one: 6.0.0 ··· 25258 25148 25259 25149 rollup@4.60.2: 25260 25150 dependencies: 25261 - '@types/estree': 1.0.8 25151 + '@types/estree': 1.0.6 25262 25152 optionalDependencies: 25263 25153 '@rollup/rollup-android-arm-eabi': 4.60.2 25264 25154 '@rollup/rollup-android-arm64': 4.60.2 ··· 25387 25277 25388 25278 semver-compare@1.0.0: {} 25389 25279 25390 - semver@5.7.2: {} 25391 - 25392 25280 semver@6.3.1: {} 25393 - 25394 - semver@7.3.2: {} 25395 25281 25396 25282 semver@7.5.4: 25397 25283 dependencies: ··· 25530 25416 '@img/sharp-win32-ia32': 0.33.5 25531 25417 '@img/sharp-win32-x64': 0.33.5 25532 25418 25533 - shebang-command@1.2.0: 25534 - dependencies: 25535 - shebang-regex: 1.0.0 25536 - 25537 25419 shebang-command@2.0.0: 25538 25420 dependencies: 25539 25421 shebang-regex: 3.0.0 25540 - 25541 - shebang-regex@1.0.0: {} 25542 25422 25543 25423 shebang-regex@3.0.0: {} 25544 25424 ··· 26005 25885 dependencies: 26006 25886 bintrees: 1.0.2 26007 25887 26008 - temp-dir@1.0.0: {} 26009 - 26010 - tempy@0.3.0: 26011 - dependencies: 26012 - temp-dir: 1.0.0 26013 - type-fest: 0.3.1 26014 - unique-string: 1.0.0 26015 - 26016 25888 terminal-link@2.1.1: 26017 25889 dependencies: 26018 25890 ansi-escapes: 4.3.2 ··· 26215 26087 26216 26088 type-fest@0.21.3: {} 26217 26089 26218 - type-fest@0.3.1: {} 26219 - 26220 26090 type-fest@0.7.1: {} 26221 26091 26222 26092 type-fest@1.4.0: {} ··· 26308 26178 26309 26179 uint8arrays@5.1.1: 26310 26180 dependencies: 26311 - multiformats: 13.4.2 26181 + multiformats: 9.9.0 26312 26182 26313 26183 unbox-primitive@1.1.0: 26314 26184 dependencies: ··· 26343 26213 pako: 0.2.9 26344 26214 tiny-inflate: 1.0.3 26345 26215 26346 - unique-string@1.0.0: 26347 - dependencies: 26348 - crypto-random-string: 1.0.0 26349 - 26350 26216 universalify@0.1.2: {} 26351 26217 26352 26218 universalify@0.2.0: {} 26353 - 26354 - universalify@1.0.0: {} 26355 26219 26356 26220 universalify@2.0.1: {} 26357 26221 ··· 26678 26542 webpack@5.106.2(@swc/core@1.15.30)(esbuild@0.27.7): 26679 26543 dependencies: 26680 26544 '@types/eslint-scope': 3.7.7 26681 - '@types/estree': 1.0.8 26545 + '@types/estree': 1.0.6 26682 26546 '@types/json-schema': 7.0.15 26683 26547 '@webassemblyjs/ast': 1.14.1 26684 26548 '@webassemblyjs/wasm-edit': 1.14.1 ··· 26780 26644 get-proto: 1.0.1 26781 26645 gopd: 1.2.0 26782 26646 has-tostringtag: 1.0.2 26783 - 26784 - which@1.3.1: 26785 - dependencies: 26786 - isexe: 2.0.0 26787 26647 26788 26648 which@2.0.2: 26789 26649 dependencies:
+3
src/App.native.tsx
··· 78 78 setupDeviceId, 79 79 } from '#/analytics' 80 80 import {IS_ANDROID, IS_IOS} from '#/env' 81 + import {SettingsSyncGate} from '#/features/settingsSync' 81 82 import { 82 83 prefetchLiveEvents, 83 84 Provider as LiveEventsProvider, ··· 154 155 key={currentAccount?.did}> 155 156 <AnalyticsFeaturesContext> 156 157 <QueryProvider currentDid={currentAccount?.did}> 158 + <SettingsSyncGate> 157 159 <PolicyUpdateOverlayProvider> 158 160 <LiveEventsProvider> 159 161 <AgeAssuranceV2Provider> ··· 203 205 </AgeAssuranceV2Provider> 204 206 </LiveEventsProvider> 205 207 </PolicyUpdateOverlayProvider> 208 + </SettingsSyncGate> 206 209 </QueryProvider> 207 210 </AnalyticsFeaturesContext> 208 211 </Fragment>
+3
src/App.web.tsx
··· 72 72 features, 73 73 setupDeviceId, 74 74 } from '#/analytics' 75 + import {SettingsSyncGate} from '#/features/settingsSync' 75 76 import { 76 77 prefetchLiveEvents, 77 78 Provider as LiveEventsProvider, ··· 185 186 key={currentAccount?.did}> 186 187 <AnalyticsFeaturesContext> 187 188 <QueryProvider currentDid={currentAccount?.did}> 189 + <SettingsSyncGate> 188 190 <PolicyUpdateOverlayProvider> 189 191 <LiveEventsProvider> 190 192 <AgeAssuranceV2Provider> ··· 234 236 </AgeAssuranceV2Provider> 235 237 </LiveEventsProvider> 236 238 </PolicyUpdateOverlayProvider> 239 + </SettingsSyncGate> 237 240 </QueryProvider> 238 241 </AnalyticsFeaturesContext> 239 242 </Fragment>
+6
src/Navigation.tsx
··· 131 131 import {RunesSettingsScreen} from '#/screens/Settings/RunesSettings' 132 132 import {RunesBadgesSettingsScreen} from '#/screens/Settings/RunesSettings/BadgesSettings' 133 133 import {RunesDisplaySettingsScreen} from '#/screens/Settings/RunesSettings/DisplaySettings' 134 + import {RunesSettingsSyncSettingsScreen} from '#/screens/Settings/RunesSettings/SettingsSyncSettings' 134 135 import {RunesExtraSettingsScreen} from '#/screens/Settings/RunesSettings/ExtraSettings' 135 136 import {RunesImpressionsSettingsScreen} from '#/screens/Settings/RunesSettings/ImpressionsSettings' 136 137 import {RunesInfrastructureSettingsScreen} from '#/screens/Settings/RunesSettings/InfrastructureSettings' ··· 466 467 name="RunesExtraSettings" 467 468 getComponent={() => RunesExtraSettingsScreen} 468 469 options={{title: title(msg`Extra settings`), requireAuth: true}} 470 + /> 471 + <Stack.Screen 472 + name="RunesSettingsSyncSettings" 473 + getComponent={() => RunesSettingsSyncSettingsScreen} 474 + options={{title: title(msg`Settings Sync`), requireAuth: true}} 469 475 /> 470 476 <Stack.Screen 471 477 name="AppearanceSettings"
+215
src/features/settingsSync/index.tsx
··· 1 + /** 2 + * Cloud sync feature module. 3 + * 4 + * Provides: 5 + * - SettingsSyncGate — drop into the provider tree (inside QueryProvider, 6 + * after session is available). Auto-pulls on startup 7 + * when settings sync is enabled and a session exists. 8 + * - useSettingsSyncStatus — current sync state for UI 9 + * - usePushToCloud — trigger a push manually 10 + * - usePullFromCloud — trigger a pull manually 11 + * 12 + * The gate does NOT re-export settingsSyncEnabled / useSetSettingsSyncEnabled; those 13 + * come from '#/state/preferences' as normal preference hooks. 14 + */ 15 + 16 + import { 17 + createContext, 18 + type PropsWithChildren, 19 + useCallback, 20 + useContext, 21 + useEffect, 22 + useRef, 23 + useState, 24 + } from 'react' 25 + 26 + import {logger} from '#/logger' 27 + import * as persisted from '#/state/persisted' 28 + import {useSettingsSyncEnabled} from '#/state/preferences' 29 + import {SYNCED_PREFS_KEYS} from '#/state/preferences/settings-sync' 30 + import { 31 + usePullStorageManifestMutation, 32 + usePushStorageManifestMutation, 33 + } from '#/state/queries/storage-manifest' 34 + import {useSession} from '#/state/session' 35 + 36 + const AUTO_PUSH_DEBOUNCE_MS = 2000 37 + // After a pull writes all keys, suppress the debounced push that would 38 + // otherwise re-upload the data we just downloaded. 39 + const POST_PULL_COOLDOWN_MS = AUTO_PUSH_DEBOUNCE_MS + 500 40 + 41 + // --------------------------------------------------------------------------- 42 + // Context types 43 + // --------------------------------------------------------------------------- 44 + 45 + export type SyncStatus = 46 + | {type: 'idle'} 47 + | {type: 'pushing'} 48 + | {type: 'pulling'} 49 + | {type: 'pushed'; at: Date} 50 + | {type: 'pulled'; at: Date} 51 + | {type: 'error'; message: string} 52 + 53 + type SettingsSyncContextValue = { 54 + status: SyncStatus 55 + pushToCloud: () => void 56 + pullFromCloud: () => void 57 + } 58 + 59 + const SettingsSyncContext = createContext<SettingsSyncContextValue>({ 60 + status: {type: 'idle'}, 61 + pushToCloud: () => {}, 62 + pullFromCloud: () => {}, 63 + }) 64 + SettingsSyncContext.displayName = 'SettingsSyncContext' 65 + 66 + // --------------------------------------------------------------------------- 67 + // Gate / Provider 68 + // --------------------------------------------------------------------------- 69 + 70 + /** 71 + * Mount this component inside QueryProvider (so TanStack Query hooks work) 72 + * and after SessionProvider. It handles the startup auto-pull and exposes 73 + * the sync status context to descendant settings screens. 74 + */ 75 + export function SettingsSyncGate({children}: PropsWithChildren<{}>) { 76 + const {hasSession, currentAccount} = useSession() 77 + const currentDid = currentAccount?.did 78 + const settingsSyncEnabled = useSettingsSyncEnabled() 79 + 80 + const pushMutation = usePushStorageManifestMutation() 81 + const pullMutation = usePullStorageManifestMutation() 82 + 83 + const [status, setStatus] = useState<SyncStatus>({type: 'idle'}) 84 + 85 + // Track whether we've already auto-pulled for this DID 86 + const hasPulledRef = useRef(false) 87 + const lastDidRef = useRef<string | undefined>(undefined) 88 + // Debounce handle and pull-cooldown timestamp for auto-push 89 + const pushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) 90 + const lastPullAtRef = useRef(0) 91 + 92 + // When the account DID changes, clear the cached draft ID (it belongs to 93 + // the previous account) and reset the pull guard so we pull fresh data. 94 + useEffect(() => { 95 + if (currentDid === lastDidRef.current) return 96 + lastDidRef.current = currentDid 97 + hasPulledRef.current = false 98 + setStatus({type: 'idle'}) 99 + if (currentDid) { 100 + // The cached draft ID is per-account; don't let the old one mislead 101 + // findStorageDraft into a false fast-path hit on the new account. 102 + persisted.write('settingsSyncDraftId', undefined) 103 + } 104 + }, [currentDid]) 105 + 106 + // Auto-pull once per account when we have a session and sync is enabled 107 + useEffect(() => { 108 + if (!hasSession || !settingsSyncEnabled || hasPulledRef.current) return 109 + hasPulledRef.current = true 110 + 111 + logger.debug('settings-sync: auto-pulling on startup', {did: currentDid}) 112 + setStatus({type: 'pulling'}) 113 + pullMutation.mutate(undefined, { 114 + onSuccess: decoded => { 115 + lastPullAtRef.current = Date.now() 116 + if (decoded) { 117 + setStatus({type: 'pulled', at: new Date()}) 118 + logger.debug('settings-sync: startup pull applied') 119 + } else { 120 + setStatus({type: 'idle'}) 121 + } 122 + }, 123 + onError: e => { 124 + setStatus({type: 'error', message: String(e)}) 125 + }, 126 + }) 127 + // pullMutation is stable (from useMutation); no re-run risk 128 + // eslint-disable-next-line react-hooks/exhaustive-deps 129 + }, [hasSession, settingsSyncEnabled, currentDid]) 130 + 131 + // Reset the pull guard when the user toggles settings sync off then on again 132 + useEffect(() => { 133 + if (!settingsSyncEnabled) { 134 + hasPulledRef.current = false 135 + if (pushTimerRef.current) clearTimeout(pushTimerRef.current) 136 + setStatus({type: 'idle'}) 137 + } 138 + }, [settingsSyncEnabled]) 139 + 140 + // Auto-push: subscribe to every synced key and debounce writes to cloud. 141 + // Skips the push window immediately after a pull to avoid re-uploading 142 + // data we just downloaded. 143 + useEffect(() => { 144 + if (!settingsSyncEnabled || !hasSession) return 145 + 146 + const scheduleSync = () => { 147 + if (Date.now() - lastPullAtRef.current < POST_PULL_COOLDOWN_MS) return 148 + if (pushTimerRef.current) clearTimeout(pushTimerRef.current) 149 + pushTimerRef.current = setTimeout(() => { 150 + pushTimerRef.current = null 151 + logger.debug('settings-sync: auto-pushing after preference change') 152 + pushMutation.mutate(undefined, { 153 + onSuccess: () => setStatus({type: 'pushed', at: new Date()}), 154 + onError: e => setStatus({type: 'error', message: String(e)}), 155 + }) 156 + }, AUTO_PUSH_DEBOUNCE_MS) 157 + } 158 + 159 + const unsubs = SYNCED_PREFS_KEYS.map(key => 160 + persisted.onUpdate(key, scheduleSync), 161 + ) 162 + return () => { 163 + unsubs.forEach(unsub => unsub()) 164 + if (pushTimerRef.current) clearTimeout(pushTimerRef.current) 165 + } 166 + // pushMutation.mutate is stable; currentDid triggers a new subscription 167 + // when the account changes so the old timer can't fire for the new account. 168 + // eslint-disable-next-line react-hooks/exhaustive-deps 169 + }, [settingsSyncEnabled, hasSession, currentDid]) 170 + 171 + const pushToCloud = useCallback(() => { 172 + setStatus({type: 'pushing'}) 173 + pushMutation.mutate(undefined, { 174 + onSuccess: () => setStatus({type: 'pushed', at: new Date()}), 175 + onError: e => setStatus({type: 'error', message: String(e)}), 176 + }) 177 + }, [pushMutation]) 178 + 179 + const pullFromCloud = useCallback(() => { 180 + setStatus({type: 'pulling'}) 181 + pullMutation.mutate(undefined, { 182 + onSuccess: decoded => { 183 + lastPullAtRef.current = Date.now() 184 + if (decoded) { 185 + setStatus({type: 'pulled', at: new Date()}) 186 + } else { 187 + setStatus({type: 'error', message: 'No cloud settings found'}) 188 + } 189 + }, 190 + onError: e => setStatus({type: 'error', message: String(e)}), 191 + }) 192 + }, [pullMutation]) 193 + 194 + return ( 195 + <SettingsSyncContext.Provider value={{status, pushToCloud, pullFromCloud}}> 196 + {children} 197 + </SettingsSyncContext.Provider> 198 + ) 199 + } 200 + 201 + // --------------------------------------------------------------------------- 202 + // Hooks 203 + // --------------------------------------------------------------------------- 204 + 205 + export function useSettingsSyncStatus() { 206 + return useContext(SettingsSyncContext).status 207 + } 208 + 209 + export function usePushToCloud() { 210 + return useContext(SettingsSyncContext).pushToCloud 211 + } 212 + 213 + export function usePullFromCloud() { 214 + return useContext(SettingsSyncContext).pullFromCloud 215 + }
+1
src/lib/routes/types.ts
··· 59 59 RunesDisplaySettings: undefined 60 60 RunesInfrastructureSettings: undefined 61 61 RunesExtraSettings: undefined 62 + RunesSettingsSyncSettings: undefined 62 63 AccountSettings: undefined 63 64 AutomationLabelSettings: undefined 64 65 PetLabelSettings: undefined
+227
src/lib/storage-manifest/codec.ts
··· 1 + /** 2 + * Witchsky Storage Manifest — codec 3 + * 4 + * Encodes arbitrary JSON as a thread of draft-post text segments. 5 + * The first segment is a plaintext manifest header; subsequent segments 6 + * contain the payload encoded as gzip+u15 (15-bit Unicode codepoints 7 + * starting at U+3400, making the data look like CJK Unified Ideographs). 8 + * 9 + * Manifest format (one field per line): 10 + * witchsky:storage:v1 11 + * Do not change me! These are your Witchsky settings. 12 + * updatedAt=<ISO8601> 13 + * codec=gzip+u15 14 + * overflowSegments=<N> 15 + * bytes=<N> 16 + * sha256=<hex> 17 + * manifestHash=<hex> 18 + * 19 + * manifestHash is sha256 of all lines above it joined by '\n', so the 20 + * manifest is self-authenticating. 21 + */ 22 + 23 + import {gzip, inflate} from 'pako' 24 + import {sha256} from '@noble/hashes/sha256' 25 + 26 + // --------------------------------------------------------------------------- 27 + // Constants 28 + // --------------------------------------------------------------------------- 29 + 30 + const BASE = 0x3400 31 + const BITS_PER_CHAR = 15 32 + const SEGMENT_MAX_GRAPHEMES = 1000 33 + 34 + // --------------------------------------------------------------------------- 35 + // Helpers 36 + // --------------------------------------------------------------------------- 37 + 38 + function toHex(bytes: Uint8Array): string { 39 + return Array.from(bytes) 40 + .map(b => b.toString(16).padStart(2, '0')) 41 + .join('') 42 + } 43 + 44 + // --------------------------------------------------------------------------- 45 + // u15 codec (reference implementation from spec) 46 + // --------------------------------------------------------------------------- 47 + 48 + function u15Encode(data: Uint8Array): string { 49 + const bits: number[] = [] 50 + for (const byte of data) { 51 + for (let i = 7; i >= 0; i--) bits.push((byte >> i) & 1) 52 + } 53 + while (bits.length % BITS_PER_CHAR !== 0) bits.push(0) 54 + let result = '' 55 + for (let i = 0; i < bits.length; i += BITS_PER_CHAR) { 56 + let val = 0 57 + for (let j = 0; j < BITS_PER_CHAR; j++) val = (val << 1) | bits[i + j] 58 + result += String.fromCodePoint(val + BASE) 59 + } 60 + return result 61 + } 62 + 63 + function u15Decode(encoded: string): Uint8Array { 64 + const bits: number[] = [] 65 + // for…of correctly handles Unicode codepoints (no broken surrogates) 66 + for (const char of encoded) { 67 + const val = char.codePointAt(0)! - BASE 68 + for (let i = BITS_PER_CHAR - 1; i >= 0; i--) bits.push((val >> i) & 1) 69 + } 70 + const data = new Uint8Array(Math.floor(bits.length / 8)) 71 + for (let i = 0; i < data.length; i++) { 72 + let byte = 0 73 + for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i * 8 + j] 74 + data[i] = byte 75 + } 76 + return data 77 + } 78 + 79 + // --------------------------------------------------------------------------- 80 + // Public API 81 + // --------------------------------------------------------------------------- 82 + 83 + const MANIFEST_COMMENT = 'Do not change me! These are your Witchsky settings.' 84 + 85 + /** 86 + * Encode an arbitrary value to an array of draft-post text segments. 87 + * segments[0] is the manifest; segments[1..] are u15-encoded data chunks, 88 + * each at most SEGMENT_MAX_GRAPHEMES characters. 89 + */ 90 + export function encode(data: unknown): string[] { 91 + const json = JSON.stringify(data) 92 + const compressed = gzip(new TextEncoder().encode(json)) 93 + const compressedHash = toHex(sha256(compressed)) 94 + 95 + const encoded = u15Encode(compressed) 96 + 97 + // All codepoints are in U+3400–U+4DBF (CJK Extension A), no surrogates, 98 + // so string .length === grapheme count. Safe to slice by index. 99 + const dataSegments: string[] = [] 100 + for (let i = 0; i < encoded.length; i += SEGMENT_MAX_GRAPHEMES) { 101 + dataSegments.push(encoded.slice(i, i + SEGMENT_MAX_GRAPHEMES)) 102 + } 103 + // Edge case: empty payload produces a single empty segment; omit it so 104 + // overflowSegments can be 0 and still round-trip through decode. 105 + if (dataSegments.length === 1 && dataSegments[0] === '') { 106 + dataSegments.length = 0 107 + } 108 + 109 + // Build manifest without manifestHash, then hash it. 110 + // Each field is on its own line; line 2 is a human-readable comment. 111 + const partial = [ 112 + 'witchsky:storage:v1', 113 + MANIFEST_COMMENT, 114 + `updatedAt=${new Date().toISOString()}`, 115 + `codec=gzip+u15`, 116 + `overflowSegments=${dataSegments.length}`, 117 + `bytes=${compressed.length}`, 118 + `sha256=${compressedHash}`, 119 + ].join('\n') 120 + const manifestHash = toHex(sha256(new TextEncoder().encode(partial))) 121 + const manifest = `${partial}\nmanifestHash=${manifestHash}` 122 + 123 + return [manifest, ...dataSegments] 124 + } 125 + 126 + /** 127 + * Decode an array of draft-post text segments back to the original value. 128 + * Throws a descriptive Error for any validation failure. 129 + * Validation order: manifestHash → segment count → bytes → sha256 → decompress → parse 130 + */ 131 + export function decode(segments: string[]): unknown { 132 + if (segments.length === 0) { 133 + throw new Error('storage-manifest: no segments') 134 + } 135 + 136 + const manifestText = segments[0] 137 + const lines = manifestText.split('\n') 138 + 139 + if (lines[0] !== 'witchsky:storage:v1') { 140 + throw new Error('storage-manifest: invalid manifest prefix') 141 + } 142 + 143 + // Last line must be the manifestHash 144 + const lastLine = lines[lines.length - 1] 145 + const hashLineMatch = lastLine.match(/^manifestHash=([0-9a-f]+)$/) 146 + if (!hashLineMatch) { 147 + throw new Error('storage-manifest: missing manifestHash field') 148 + } 149 + const manifestHashField = hashLineMatch[1] 150 + 151 + // partial = everything except the last line 152 + const partial = lines.slice(0, -1).join('\n') 153 + 154 + // 1. Verify manifestHash 155 + const expectedManifestHash = toHex( 156 + sha256(new TextEncoder().encode(partial)), 157 + ) 158 + if (expectedManifestHash !== manifestHashField) { 159 + throw new Error('storage-manifest: manifestHash mismatch') 160 + } 161 + 162 + // Parse key=value fields from lines 2.. (line 0 = header, line 1 = comment) 163 + const fields: Record<string, string> = {} 164 + for (const line of lines.slice(2, -1)) { 165 + const eq = line.indexOf('=') 166 + if (eq !== -1) fields[line.slice(0, eq)] = line.slice(eq + 1) 167 + } 168 + 169 + // 2. Codec check 170 + if (fields.codec !== 'gzip+u15') { 171 + throw new Error(`storage-manifest: unknown codec "${fields.codec}"`) 172 + } 173 + 174 + const overflowSegments = parseInt(fields.overflowSegments, 10) 175 + const bytes = parseInt(fields.bytes, 10) 176 + const sha256Hex = fields.sha256 177 + 178 + // 3. Segment count 179 + if (segments.length - 1 !== overflowSegments) { 180 + throw new Error( 181 + `storage-manifest: expected ${overflowSegments} data segments, got ${segments.length - 1}`, 182 + ) 183 + } 184 + 185 + // 4. Decode u15 → compressed bytes 186 + const encoded = segments.slice(1).join('') 187 + const decoded = u15Decode(encoded) 188 + 189 + // 5. bytes length check 190 + if (decoded.length < bytes) { 191 + throw new Error( 192 + `storage-manifest: decoded length ${decoded.length} is less than declared bytes ${bytes}`, 193 + ) 194 + } 195 + 196 + // Trim any padding byte that u15 decoding may have appended 197 + const compressed = decoded.length === bytes ? decoded : decoded.subarray(0, bytes) 198 + 199 + // 6. sha256 check 200 + const actualHash = toHex(sha256(compressed)) 201 + if (actualHash !== sha256Hex) { 202 + throw new Error('storage-manifest: sha256 mismatch') 203 + } 204 + 205 + // 7. Decompress 206 + let jsonBytes: Uint8Array 207 + try { 208 + jsonBytes = inflate(compressed) 209 + } catch (e) { 210 + throw new Error(`storage-manifest: decompression failed: ${e}`) 211 + } 212 + 213 + // 8. Parse 214 + try { 215 + return JSON.parse(new TextDecoder().decode(jsonBytes)) 216 + } catch (e) { 217 + throw new Error(`storage-manifest: JSON parse failed: ${e}`) 218 + } 219 + } 220 + 221 + /** 222 + * Return true if the given text looks like a witchsky storage manifest header. 223 + * Used to identify the storage draft among all of a user's drafts. 224 + */ 225 + export function isManifestSegment(text: string): boolean { 226 + return text.startsWith('witchsky:storage:v1\n') 227 + }
+1
src/routes.ts
··· 56 56 RunesDisplaySettings: '/settings/runes/display', 57 57 RunesInfrastructureSettings: '/settings/runes/infrastructure', 58 58 RunesExtraSettings: '/settings/runes/extra', 59 + RunesSettingsSyncSettings: '/settings/runes/settings-sync', 59 60 AppearanceSettings: '/settings/appearance', 60 61 AppearanceColorThemeSettings: '/settings/appearance/color-theme', 61 62 SavedFeeds: '/settings/saved-feeds',
+195
src/screens/Settings/RunesSettings/SettingsSyncSettings.tsx
··· 1 + import {TextInput, View} from 'react-native' 2 + import {Trans, useLingui} from '@lingui/react/macro' 3 + 4 + import { 5 + useSettingsSyncStatus, 6 + usePullFromCloud, 7 + usePushToCloud, 8 + } from '#/features/settingsSync' 9 + import { 10 + useSettingsSyncEnabled, 11 + useSetSettingsSyncEnabled, 12 + } from '#/state/preferences' 13 + import {useStorageManifestQuery} from '#/state/queries/storage-manifest' 14 + import {useSession} from '#/state/session' 15 + import * as SettingsList from '#/screens/Settings/components/SettingsList' 16 + import {atoms as a, useTheme} from '#/alf' 17 + import {Admonition} from '#/components/Admonition' 18 + import {Button, ButtonText} from '#/components/Button' 19 + import * as Toggle from '#/components/forms/Toggle' 20 + import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as CloudSyncIcon} from '#/components/icons/ArrowRotate' 21 + import {Text} from '#/components/Typography' 22 + import {IS_WEB} from '#/env' 23 + import {RunesScreenLayout} from './components/RunesScreenLayout' 24 + 25 + function formatStatusLine( 26 + status: ReturnType<typeof useSettingsSyncStatus>, 27 + ): string | null { 28 + if (status.type === 'idle') return null 29 + if (status.type === 'pushing') return 'Saving to cloud…' 30 + if (status.type === 'pulling') return 'Loading from cloud…' 31 + if (status.type === 'pushed') 32 + return `Saved to cloud at ${status.at.toLocaleTimeString()}` 33 + if (status.type === 'pulled') 34 + return `Loaded from cloud at ${status.at.toLocaleTimeString()}` 35 + if (status.type === 'error') return `Error: ${status.message}` 36 + return null 37 + } 38 + 39 + export function RunesSettingsSyncSettingsScreen() { 40 + const {t: l} = useLingui() 41 + const t = useTheme() 42 + 43 + const {hasSession} = useSession() 44 + const enabled = useSettingsSyncEnabled() 45 + const setEnabled = useSetSettingsSyncEnabled() 46 + 47 + const status = useSettingsSyncStatus() 48 + const pushToCloud = usePushToCloud() 49 + const pullFromCloud = usePullFromCloud() 50 + 51 + const manifestQuery = useStorageManifestQuery({enabled: hasSession}) 52 + const decodedJson = manifestQuery.data != null 53 + ? JSON.stringify(manifestQuery.data, null, 2) 54 + : null 55 + 56 + const isBusy = status.type === 'pushing' || status.type === 'pulling' 57 + const isError = status.type === 'error' 58 + 59 + const statusLine = formatStatusLine(status) 60 + 61 + const onToggleEnabled = (next: boolean) => { 62 + setEnabled(next) 63 + // Immediately push when the user first enables sync 64 + if (next) { 65 + pushToCloud() 66 + } 67 + } 68 + 69 + return ( 70 + <RunesScreenLayout titleText={l`Settings Sync`}> 71 + <Toggle.Item 72 + name="cloud_sync_enabled" 73 + label={l`Sync settings between devices`} 74 + value={enabled} 75 + onChange={onToggleEnabled}> 76 + <SettingsList.Item> 77 + <SettingsList.ItemIcon icon={CloudSyncIcon} /> 78 + <SettingsList.ItemText> 79 + <Trans>Sync settings between devices</Trans> 80 + </SettingsList.ItemText> 81 + <Toggle.Platform /> 82 + </SettingsList.Item> 83 + </Toggle.Item> 84 + 85 + <SettingsList.Item> 86 + <Admonition type="info" style={[a.flex_1]}> 87 + <Trans> 88 + Settings are encoded and stored in a hidden draft post on your 89 + account. This lets you sync your Witchsky preferences across 90 + devices without any external service. Your session credentials are 91 + never included. 92 + </Trans> 93 + </Admonition> 94 + </SettingsList.Item> 95 + 96 + {enabled && ( 97 + <> 98 + <SettingsList.Divider /> 99 + 100 + <SettingsList.Item> 101 + <View style={[a.flex_1, a.gap_md, IS_WEB && a.flex_row]}> 102 + <Button 103 + label={l`Push settings to cloud`} 104 + size="small" 105 + color="primary" 106 + disabled={isBusy} 107 + onPress={pushToCloud} 108 + style={IS_WEB ? undefined : [a.flex_1]}> 109 + <ButtonText> 110 + <Trans>Push</Trans> 111 + </ButtonText> 112 + </Button> 113 + <Button 114 + label={l`Load settings from cloud`} 115 + size="small" 116 + color="secondary" 117 + disabled={isBusy} 118 + onPress={pullFromCloud} 119 + style={IS_WEB ? undefined : [a.flex_1]}> 120 + <ButtonText> 121 + <Trans>Load</Trans> 122 + </ButtonText> 123 + </Button> 124 + </View> 125 + </SettingsList.Item> 126 + 127 + {statusLine && ( 128 + <SettingsList.Item> 129 + <Text 130 + style={[ 131 + a.text_sm, 132 + a.flex_1, 133 + isError 134 + ? {color: t.palette.negative_500} 135 + : t.atoms.text_contrast_medium, 136 + ]}> 137 + {statusLine} 138 + </Text> 139 + </SettingsList.Item> 140 + )} 141 + 142 + <SettingsList.Item> 143 + <Admonition type="warning" style={[a.flex_1]}> 144 + <Trans> 145 + Loading from cloud will overwrite your local settings. Push 146 + first if you want to preserve changes made on this device. 147 + </Trans> 148 + </Admonition> 149 + </SettingsList.Item> 150 + </> 151 + )} 152 + 153 + {hasSession && ( 154 + <> 155 + <SettingsList.Divider /> 156 + <SettingsList.Item> 157 + <View style={[a.flex_1, a.gap_xs]}> 158 + <Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}> 159 + <Trans>Cloud data</Trans> 160 + </Text> 161 + {manifestQuery.isLoading ? ( 162 + <Text style={[a.text_sm, t.atoms.text_contrast_low]}> 163 + <Trans>Loading…</Trans> 164 + </Text> 165 + ) : manifestQuery.isError ? ( 166 + <Text style={[a.text_sm, {color: t.palette.negative_500}]}> 167 + {String(manifestQuery.error)} 168 + </Text> 169 + ) : decodedJson == null ? ( 170 + <Text style={[a.text_sm, t.atoms.text_contrast_low]}> 171 + <Trans>No cloud data found.</Trans> 172 + </Text> 173 + ) : ( 174 + <TextInput 175 + value={decodedJson} 176 + editable={false} 177 + multiline 178 + scrollEnabled={false} 179 + style={[ 180 + a.text_xs, 181 + a.p_sm, 182 + a.rounded_sm, 183 + t.atoms.text, 184 + t.atoms.bg_contrast_25, 185 + {fontFamily: 'monospace', minHeight: 300}, 186 + ]} 187 + /> 188 + )} 189 + </View> 190 + </SettingsList.Item> 191 + </> 192 + )} 193 + </RunesScreenLayout> 194 + ) 195 + }
+9
src/screens/Settings/RunesSettings/index.tsx
··· 4 4 import {type CommonNavigatorParams} from '#/lib/routes/types' 5 5 import * as SettingsList from '#/screens/Settings/components/SettingsList' 6 6 import {Atom_Stroke2_Corner0_Rounded as AtomIcon} from '#/components/icons/Atom' 7 + import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as CloudSyncIcon} from '#/components/icons/ArrowRotate' 7 8 import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 8 9 import {Eye_Stroke2_Corner0_Rounded as VisibilityIcon} from '#/components/icons/Eye' 9 10 import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' ··· 59 60 <SettingsList.ItemIcon icon={EarthIcon} /> 60 61 <SettingsList.ItemText> 61 62 <Trans>Infrastructure</Trans> 63 + </SettingsList.ItemText> 64 + </SettingsList.LinkItem> 65 + <SettingsList.LinkItem 66 + to="/settings/runes/settings-sync" 67 + label={l`Cloud sync`}> 68 + <SettingsList.ItemIcon icon={CloudSyncIcon} /> 69 + <SettingsList.ItemText> 70 + <Trans>Settings Sync</Trans> 62 71 </SettingsList.ItemText> 63 72 </SettingsList.LinkItem> 64 73 <SettingsList.LinkItem
+8 -3
src/state/persisted/index.ts
··· 1 1 import AsyncStorage from '@react-native-async-storage/async-storage' 2 + import {EventEmitter} from 'eventemitter3' 2 3 3 4 import {logger} from '#/logger' 4 5 import { ··· 17 18 const BSKY_STORAGE = 'BSKY_STORAGE' 18 19 19 20 let _state: Schema = defaults 21 + const _emitter = new EventEmitter() 20 22 21 23 export async function init() { 22 24 const stored = await readFromStorage() ··· 40 42 [key]: value, 41 43 }) 42 44 await writeToStorage(_state) 45 + _emitter.emit('update:' + key) 43 46 } 44 47 write satisfies PersistedApi['write'] 45 48 46 49 export function onUpdate<K extends keyof Schema>( 47 - _key: K, 48 - _cb: (v: Schema[K]) => void, 50 + key: K, 51 + cb: (v: Schema[K]) => void, 49 52 ): () => void { 50 - return () => {} 53 + const listener = () => cb(get(key)) 54 + _emitter.addListener('update:' + key, listener) 55 + return () => _emitter.removeListener('update:' + key, listener) 51 56 } 52 57 onUpdate satisfies PersistedApi['onUpdate'] 53 58
+4
src/state/persisted/index.web.ts
··· 68 68 writeToStorage(_state) 69 69 broadcast.postMessage({event: {type: UPDATE_EVENT, key}}) 70 70 broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading 71 + // Also notify listeners in the current tab directly — BroadcastChannel 72 + // only reaches other tabs, so without this, onUpdate() is silent for the 73 + // tab that made the write (which breaks bulk writes like settings sync pull). 74 + _emitter.emit('update:' + key) 71 75 } 72 76 write satisfies PersistedApi['write'] 73 77
+7
src/state/persisted/schema.ts
··· 241 241 242 242 autoLikeOnRepost: z.boolean().optional(), 243 243 omitViaField: z.boolean().optional(), 244 + 245 + // settings sync 246 + settingsSyncEnabled: z.boolean().optional(), 247 + settingsSyncDraftId: z.string().optional(), 244 248 }) 245 249 export type Schema = z.infer<typeof schema> 246 250 ··· 379 383 380 384 autoLikeOnRepost: false, 381 385 omitViaField: false, 386 + 387 + settingsSyncEnabled: false, 388 + settingsSyncDraftId: undefined, 382 389 } 383 390 384 391 export function tryParse(rawData: string): Schema | undefined {
+4
src/state/preferences/index.tsx
··· 3 3 import {Provider as AlsoLikedFeedProvider} from './also-liked-feed-enabled' 4 4 import {Provider as AltTextRequiredProvider} from './alt-text-required' 5 5 import {Provider as AutoLikeOnRepostProvider} from './auto-like-on-repost' 6 + import {Provider as SettingsSyncProvider} from './settings-sync' 6 7 import {Provider as AutoplayProvider} from './autoplay' 7 8 import {Provider as ConstellationProvider} from './constellation-enabled' 8 9 import {Provider as ConstellationInstanceProvider} from './constellation-instance' ··· 66 67 useRequireAltTextEnabled, 67 68 useSetRequireAltTextEnabled, 68 69 } from './alt-text-required' 70 + export {useSettingsSyncEnabled, useSetSettingsSyncEnabled} from './settings-sync' 69 71 export {useAutoplayDisabled, useSetAutoplayDisabled} from './autoplay' 70 72 export { 71 73 useDisableComposerPrompt, ··· 119 121 120 122 export function Provider({children}: PropsWithChildren<{}>) { 121 123 return ( 124 + <SettingsSyncProvider> 122 125 <LanguagesProvider> 123 126 <AltTextRequiredProvider> 124 127 <AutoLikeOnRepostProvider> ··· 236 239 </AutoLikeOnRepostProvider> 237 240 </AltTextRequiredProvider> 238 241 </LanguagesProvider> 242 + </SettingsSyncProvider> 239 243 ) 240 244 }
+145
src/state/preferences/settings-sync.tsx
··· 1 + import { 2 + createContext, 3 + useCallback, 4 + useContext, 5 + useEffect, 6 + useState, 7 + } from 'react' 8 + import type {PropsWithChildren} from 'react' 9 + 10 + import * as persisted from '#/state/persisted' 11 + import type {Schema} from '#/state/persisted/schema' 12 + 13 + // --------------------------------------------------------------------------- 14 + // Synced keys allowlist 15 + // --------------------------------------------------------------------------- 16 + // Everything the storage manifest will include. Deliberately excludes: 17 + // session, invites, reminders, onboarding, pdsAddressHistory, 18 + // hasCheckedForStarterPack, mutedThreads (deprecated), 19 + // lastSelectedHomeFeed (deprecated UI state), 20 + // settingsSyncEnabled, settingsSyncDraftId (the mechanism, not the content) 21 + 22 + export const SYNCED_PREFS_KEYS = [ 23 + 'colorMode', 24 + 'darkTheme', 25 + 'colorScheme', 26 + 'hue', 27 + 'material3Accent', 28 + 'material3Style', 29 + 'languagePrefs', 30 + 'requireAltTextEnabled', 31 + 'largeAltBadgeEnabled', 32 + 'externalEmbeds', 33 + 'hiddenPosts', 34 + 'useInAppBrowser', 35 + 'disableHaptics', 36 + 'disableAutoplay', 37 + 'kawaii', 38 + 'subtitlesEnabled', 39 + 'goLinksEnabled', 40 + 'constellationEnabled', 41 + 'directFetchRecords', 42 + 'noAppLabelers', 43 + 'noDiscoverFallback', 44 + 'repostCarouselEnabled', 45 + 'alsoLikedFeedEnabled', 46 + 'constellationInstance', 47 + 'showLinkInHandle', 48 + 'showLinkInHandleOnlyOnWorkingLinks', 49 + 'hideFeedsPromoTab', 50 + 'disableViaRepostNotification', 51 + 'disableComposerPrompt', 52 + 'disableTopOfFeedButton', 53 + 'disableLikesMetrics', 54 + 'disableRepostsMetrics', 55 + 'disableQuotesMetrics', 56 + 'disableSavesMetrics', 57 + 'disableReplyMetrics', 58 + 'disableFollowersMetrics', 59 + 'disableFollowingMetrics', 60 + 'disableFollowedByMetrics', 61 + 'disablePostsMetrics', 62 + 'showFollowsYouBadge', 63 + 'hideSimilarAccountsRecomm', 64 + 'hideScaryFollowButtons', 65 + 'discoverContextEnabled', 66 + 'enableSquareAvatars', 67 + 'enableSquareButtons', 68 + 'disableVerifyEmailReminder', 69 + 'showViaClient', 70 + 'deerVerification', 71 + 'highQualityImages', 72 + 'imageCdnHost', 73 + 'plcDirectory', 74 + 'hideUnreplyablePosts', 75 + 'pdsLabel', 76 + 'faviconService', 77 + 'postReplacement', 78 + 'showExternalShareButtons', 79 + 'translationServicePreference', 80 + 'libreTranslateInstance', 81 + 'openRouterApiKey', 82 + 'openRouterModel', 83 + 'openRouterPrompt', 84 + 'useHandleInLinks', 85 + 'trendingDisabled', 86 + 'trendingVideoDisabled', 87 + 'autoLikeOnRepost', 88 + 'omitViaField', 89 + ] as const satisfies readonly (keyof Schema)[] 90 + 91 + export type SyncedPrefsKey = (typeof SYNCED_PREFS_KEYS)[number] 92 + 93 + // --------------------------------------------------------------------------- 94 + // Context 95 + // --------------------------------------------------------------------------- 96 + 97 + type StateContext = boolean 98 + type SetContext = (v: boolean) => void 99 + 100 + const stateContext = createContext<StateContext>( 101 + Boolean(persisted.defaults.settingsSyncEnabled), 102 + ) 103 + stateContext.displayName = 'CloudSyncStateContext' 104 + 105 + const setContext = createContext<SetContext>((_: boolean) => {}) 106 + setContext.displayName = 'CloudSyncSetContext' 107 + 108 + // --------------------------------------------------------------------------- 109 + // Provider 110 + // --------------------------------------------------------------------------- 111 + 112 + export function Provider({children}: PropsWithChildren<{}>) { 113 + const [state, setState] = useState( 114 + Boolean(persisted.get('settingsSyncEnabled')), 115 + ) 116 + 117 + const setStateWrapped = useCallback( 118 + (value: boolean) => { 119 + setState(value) 120 + persisted.write('settingsSyncEnabled', value) 121 + }, 122 + [], 123 + ) 124 + 125 + useEffect(() => { 126 + return persisted.onUpdate('settingsSyncEnabled', next => { 127 + setState(Boolean(next)) 128 + }) 129 + }, []) 130 + 131 + return ( 132 + <stateContext.Provider value={state}> 133 + <setContext.Provider value={setStateWrapped}> 134 + {children} 135 + </setContext.Provider> 136 + </stateContext.Provider> 137 + ) 138 + } 139 + 140 + // --------------------------------------------------------------------------- 141 + // Hooks 142 + // --------------------------------------------------------------------------- 143 + 144 + export const useSettingsSyncEnabled = () => useContext(stateContext) 145 + export const useSetSettingsSyncEnabled = () => useContext(setContext)
+217
src/state/queries/storage-manifest.ts
··· 1 + /** 2 + * TanStack Query hooks for reading and writing the Witchsky storage manifest 3 + * draft. The storage manifest is a single draft whose posts encode the 4 + * user's synced preferences as a gzip+u15 blob (see 5 + * src/lib/storage-manifest/codec.ts). 6 + */ 7 + 8 + import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 9 + 10 + import {decode, encode, isManifestSegment} from '#/lib/storage-manifest/codec' 11 + import {logger} from '#/logger' 12 + import * as persisted from '#/state/persisted' 13 + import {SYNCED_PREFS_KEYS} from '#/state/preferences/settings-sync' 14 + import {useAgent, useSession} from '#/state/session' 15 + 16 + // Keyed by DID so switching accounts never serves a stale result. 17 + // Invalidations use the base key so they match any DID. 18 + const STORAGE_MANIFEST_BASE_KEY = ['witchsky-storage-manifest'] as const 19 + const storageManifestQueryKey = (did: string | undefined) => 20 + [...STORAGE_MANIFEST_BASE_KEY, did] as const 21 + 22 + // --------------------------------------------------------------------------- 23 + // Finding the storage draft 24 + // --------------------------------------------------------------------------- 25 + 26 + /** 27 + * Page through the user's drafts looking for the storage manifest draft. 28 + * On each page we check the cached draft ID first (O(1) string compare) then 29 + * fall back to inspecting the first post's text. Both checks happen inside 30 + * the same pagination loop so no extra round-trips are wasted regardless of 31 + * which page the draft lives on. 32 + */ 33 + async function findStorageDraft( 34 + agent: ReturnType<typeof useAgent>, 35 + ): Promise<{id: string; segments: string[]} | null> { 36 + const cachedId = persisted.get('settingsSyncDraftId') 37 + 38 + let cursor: string | undefined 39 + do { 40 + const res = await agent.app.bsky.draft.getDrafts({cursor}) 41 + for (const draft of res.data.drafts) { 42 + const first = draft.draft.posts[0]?.text ?? '' 43 + 44 + // ID match: we already know this draft — just verify it's still a 45 + // storage draft (the user could have deleted and re-created one). 46 + if (cachedId && draft.id === cachedId) { 47 + if (isManifestSegment(first)) { 48 + return { 49 + id: draft.id, 50 + segments: draft.draft.posts.map(p => p.text ?? ''), 51 + } 52 + } 53 + // Stale cache — the draft no longer looks like a manifest. 54 + // Clear it and keep scanning for a manifest by text. 55 + await persisted.write('settingsSyncDraftId', undefined) 56 + } 57 + 58 + // Text match: first post starts with 'witchsky:storage\n' 59 + if (isManifestSegment(first)) { 60 + await persisted.write('settingsSyncDraftId', draft.id) 61 + return { 62 + id: draft.id, 63 + segments: draft.draft.posts.map(p => p.text ?? ''), 64 + } 65 + } 66 + } 67 + cursor = res.data.cursor 68 + } while (cursor) 69 + 70 + return null 71 + } 72 + 73 + // --------------------------------------------------------------------------- 74 + // Query: read and decode the storage draft 75 + // --------------------------------------------------------------------------- 76 + 77 + /** 78 + * Fetches and decodes the storage manifest draft. 79 + * Returns the decoded object, or null if no storage draft exists. 80 + * Only runs when the user has a session. 81 + */ 82 + export function useStorageManifestQuery({enabled = true}: {enabled?: boolean} = {}) { 83 + const agent = useAgent() 84 + const {currentAccount} = useSession() 85 + 86 + return useQuery({ 87 + queryKey: storageManifestQueryKey(currentAccount?.did), 88 + queryFn: async () => { 89 + const found = await findStorageDraft(agent) 90 + if (!found) return null 91 + try { 92 + return decode(found.segments) 93 + } catch (e) { 94 + logger.error('storage-manifest: decode failed', {safeMessage: String(e)}) 95 + throw e 96 + } 97 + }, 98 + enabled, 99 + // Don't cache stale cloud data for too long; re-check on focus 100 + staleTime: 1000 * 60 * 5, // 5 minutes 101 + retry: 1, 102 + }) 103 + } 104 + 105 + // --------------------------------------------------------------------------- 106 + // Mutation: push current prefs to cloud 107 + // --------------------------------------------------------------------------- 108 + 109 + /** 110 + * Reads the current persisted preferences, encodes them, and writes them to 111 + * the storage draft (creating it if it doesn't exist). 112 + */ 113 + export function usePushStorageManifestMutation() { 114 + const agent = useAgent() 115 + const queryClient = useQueryClient() 116 + 117 + return useMutation({ 118 + mutationFn: async () => { 119 + // Collect all synced preference values 120 + const prefs: Record<string, unknown> = {} 121 + for (const key of SYNCED_PREFS_KEYS) { 122 + prefs[key] = persisted.get(key) 123 + } 124 + 125 + const segments = encode(prefs) 126 + logger.debug('storage-manifest: pushing', { 127 + segments: segments.length, 128 + keys: SYNCED_PREFS_KEYS.length, 129 + }) 130 + 131 + // Build a draft whose posts are the segments 132 + const posts = segments.map(text => ({ 133 + $type: 'app.bsky.draft.defs#draftPost' as const, 134 + text, 135 + })) 136 + const draft = { 137 + $type: 'app.bsky.draft.defs#draft' as const, 138 + posts, 139 + } 140 + 141 + // Re-use findStorageDraft so we get the same paginated lookup and 142 + // stale-cache handling that the read path uses. 143 + const existing = await findStorageDraft(agent) 144 + 145 + if (existing) { 146 + await agent.app.bsky.draft.updateDraft({ 147 + draft: {id: existing.id, draft}, 148 + }) 149 + return existing.id 150 + } else { 151 + const res = await agent.app.bsky.draft.createDraft({draft}) 152 + return res.data.id 153 + } 154 + }, 155 + onSuccess: async (draftId: string) => { 156 + await persisted.write('settingsSyncDraftId', draftId) 157 + queryClient.invalidateQueries({queryKey: STORAGE_MANIFEST_BASE_KEY}) 158 + logger.debug('storage-manifest: push succeeded', {draftId}) 159 + }, 160 + onError: e => { 161 + logger.error('storage-manifest: push failed', {safeMessage: String(e)}) 162 + }, 163 + }) 164 + } 165 + 166 + // --------------------------------------------------------------------------- 167 + // Mutation: pull cloud prefs and apply to local 168 + // --------------------------------------------------------------------------- 169 + 170 + /** 171 + * Reads the storage draft from the cloud and applies the decoded preferences 172 + * to the local persisted store. Only keys present in the decoded object are 173 + * written; missing keys (e.g. from an older manifest) are left untouched so 174 + * new preferences added in later versions aren't reset. 175 + */ 176 + export function usePullStorageManifestMutation() { 177 + const agent = useAgent() 178 + const queryClient = useQueryClient() 179 + 180 + return useMutation({ 181 + mutationFn: async () => { 182 + const found = await findStorageDraft(agent) 183 + if (!found) return null 184 + 185 + const decoded = decode(found.segments) 186 + if (typeof decoded !== 'object' || decoded === null) { 187 + throw new Error('storage-manifest: decoded value is not an object') 188 + } 189 + return decoded as Record<string, unknown> 190 + }, 191 + onSuccess: async (decoded: Record<string, unknown> | null) => { 192 + if (!decoded) { 193 + logger.debug('storage-manifest: pull found no storage draft') 194 + return 195 + } 196 + 197 + let applied = 0 198 + for (const key of SYNCED_PREFS_KEYS) { 199 + if (Object.prototype.hasOwnProperty.call(decoded, key)) { 200 + // persisted.write is typed per-key; use the cast the same way 201 + // the persisted module itself does in normalizeData/tryParse 202 + await persisted.write( 203 + key, 204 + decoded[key] as persisted.Schema[typeof key], 205 + ) 206 + applied++ 207 + } 208 + } 209 + 210 + logger.debug('storage-manifest: pull applied', {applied}) 211 + queryClient.invalidateQueries({queryKey: STORAGE_MANIFEST_BASE_KEY}) 212 + }, 213 + onError: e => { 214 + logger.error('storage-manifest: pull failed', {safeMessage: String(e)}) 215 + }, 216 + }) 217 + }
+18 -6
src/view/com/composer/drafts/state/queries.ts
··· 5 5 useQueryClient, 6 6 } from '@tanstack/react-query' 7 7 8 + import {decode} from '#/lib/storage-manifest/codec' 8 9 import {isNetworkError} from '#/lib/strings/errors' 9 10 import {useAgent} from '#/state/session' 10 11 import {type ComposerState} from '#/view/com/composer/state/composer' ··· 31 32 const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam}) 32 33 return { 33 34 cursor: res.data.cursor, 34 - drafts: res.data.drafts.map(view => 35 - draftViewToSummary({ 36 - view, 37 - analytics: ax, 38 - }), 39 - ), 35 + drafts: res.data.drafts 36 + .filter(view => { 37 + const firstText = view.draft.posts[0]?.text ?? '' 38 + if (!/^witchsky:storage/.test(firstText)) return true 39 + try { 40 + decode(view.draft.posts.map(p => p.text ?? '')) 41 + return false // valid manifest — hide it 42 + } catch { 43 + return true // corrupt/partial — show it so the user can clean it up 44 + } 45 + }) 46 + .map(view => 47 + draftViewToSummary({ 48 + view, 49 + analytics: ax, 50 + }), 51 + ), 40 52 } 41 53 }, 42 54 initialPageParam: undefined as string | undefined,