an appview-less Bluesky client using Constellation and PDS Queries - https://reddwarf.app/

Compare changes

Choose any two refs to compare.

+2 -1
.gitignore
··· 7 7 .env 8 8 .nitro 9 9 .tanstack 10 - public/client-metadata.json 10 + public/client-metadata.json 11 + public/resolvers.json
+9
README.md
··· 15 15 16 16 run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder) 17 17 18 + 19 + 20 + you probably dont need to change these 21 + ```ts 22 + const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party" 23 + const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social" 24 + ``` 25 + if you do want to change these, i recommend changing both of these to your own PDS url. i separate the prod and dev urls so that you can change it as needed. here i separated it because if the prod resolver and prod url shares the same domain itll error and prevent logins 26 + 18 27 ## useQuery 19 28 Red Dwarf has been upgraded from its original bespoke caching system to Tanstack Query (react query). this migration was done to achieve a more robust and maintainable approach to data fetching and caching and state synchronization. ive seen serious performance gains from this switch! 20 29
+62 -30
oauthdev.mts
··· 1 - import fs from 'fs'; 2 - import path from 'path'; 1 + import fs from "fs"; 2 + import path from "path"; 3 3 //import { generateClientMetadata } from './src/helpers/oauthClient' 4 4 export const generateClientMetadata = (appOrigin: string) => { 5 - const callbackPath = '/callback'; 5 + const callbackPath = "/callback"; 6 6 7 7 return { 8 - "client_id": `${appOrigin}/client-metadata.json`, 9 - "client_name": "ForumTest", 10 - "client_uri": appOrigin, 11 - "logo_uri": `${appOrigin}/logo192.png`, 12 - "tos_uri": `${appOrigin}/terms-of-service`, 13 - "policy_uri": `${appOrigin}/privacy-policy`, 14 - "redirect_uris": [`${appOrigin}${callbackPath}`] as [string, ...string[]], 15 - "scope": "atproto transition:generic", 16 - "grant_types": ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"], 17 - "response_types": ["code"] as ["code"], 18 - "token_endpoint_auth_method": "none" as "none", 19 - "application_type": "web" as "web", 20 - "dpop_bound_access_tokens": true 21 - }; 22 - } 23 - 8 + client_id: `${appOrigin}/client-metadata.json`, 9 + client_name: "ForumTest", 10 + client_uri: appOrigin, 11 + logo_uri: `${appOrigin}/logo192.png`, 12 + tos_uri: `${appOrigin}/terms-of-service`, 13 + policy_uri: `${appOrigin}/privacy-policy`, 14 + redirect_uris: [`${appOrigin}${callbackPath}`] as [string, ...string[]], 15 + scope: "atproto transition:generic", 16 + grant_types: ["authorization_code", "refresh_token"] as [ 17 + "authorization_code", 18 + "refresh_token", 19 + ], 20 + response_types: ["code"] as ["code"], 21 + token_endpoint_auth_method: "none" as "none", 22 + application_type: "web" as "web", 23 + dpop_bound_access_tokens: true, 24 + }; 25 + }; 24 26 25 - export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) { 27 + export function generateMetadataPlugin({ 28 + prod, 29 + dev, 30 + prodResolver = "https://bsky.social", 31 + devResolver = prodResolver, 32 + }: { 33 + prod: string; 34 + dev: string; 35 + prodResolver?: string; 36 + devResolver?: string; 37 + }) { 26 38 return { 27 - name: 'vite-plugin-generate-metadata', 39 + name: "vite-plugin-generate-metadata", 28 40 config(_config: any, { mode }: any) { 29 - let appOrigin; 30 - if (mode === 'production') { 31 - appOrigin = prod 32 - if (!appOrigin || !appOrigin.startsWith('https://')) { 33 - throw new Error('VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build.'); 41 + console.log('๐Ÿ’ก vite mode =', mode) 42 + let appOrigin, resolver; 43 + if (mode === "production") { 44 + appOrigin = prod; 45 + resolver = prodResolver; 46 + if (!appOrigin || !appOrigin.startsWith("https://")) { 47 + throw new Error( 48 + "VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build." 49 + ); 34 50 } 35 51 } else { 36 52 appOrigin = dev; 53 + resolver = devResolver; 37 54 } 38 - 39 - 55 + 40 56 const metadata = generateClientMetadata(appOrigin); 41 - const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json'); 57 + const outputPath = path.resolve( 58 + process.cwd(), 59 + "public", 60 + "client-metadata.json" 61 + ); 42 62 43 63 fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2)); 44 64 65 + const resolvers = { 66 + resolver: resolver, 67 + }; 68 + const resolverOutPath = path.resolve( 69 + process.cwd(), 70 + "public", 71 + "resolvers.json" 72 + ); 73 + 74 + fs.writeFileSync(resolverOutPath, JSON.stringify(resolvers, null, 2)); 75 + 76 + 45 77 // /*mass comment*/ console.log(`โœ… Generated client-metadata.json for ${appOrigin}`); 46 78 }, 47 79 }; 48 - } 80 + }
+41 -72
package-lock.json
··· 376 376 "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", 377 377 "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", 378 378 "license": "MIT", 379 + "peer": true, 379 380 "dependencies": { 380 381 "@ampproject/remapping": "^2.2.0", 381 382 "@babel/code-frame": "^7.27.1", ··· 883 884 } 884 885 ], 885 886 "license": "MIT", 887 + "peer": true, 886 888 "engines": { 887 889 "node": ">=18" 888 890 }, ··· 906 908 } 907 909 ], 908 910 "license": "MIT", 911 + "peer": true, 909 912 "engines": { 910 913 "node": ">=18" 911 914 } ··· 1494 1497 "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", 1495 1498 "dev": true, 1496 1499 "license": "Apache-2.0", 1497 - "peer": true, 1498 1500 "dependencies": { 1499 1501 "@eslint/object-schema": "^2.1.6", 1500 1502 "debug": "^4.3.1", ··· 1510 1512 "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", 1511 1513 "dev": true, 1512 1514 "license": "Apache-2.0", 1513 - "peer": true, 1514 1515 "dependencies": { 1515 1516 "@eslint/core": "^0.16.0" 1516 1517 }, ··· 1524 1525 "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", 1525 1526 "dev": true, 1526 1527 "license": "Apache-2.0", 1527 - "peer": true, 1528 1528 "dependencies": { 1529 1529 "@types/json-schema": "^7.0.15" 1530 1530 }, ··· 1538 1538 "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", 1539 1539 "dev": true, 1540 1540 "license": "MIT", 1541 - "peer": true, 1542 1541 "dependencies": { 1543 1542 "ajv": "^6.12.4", 1544 1543 "debug": "^4.3.2", ··· 1563 1562 "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", 1564 1563 "dev": true, 1565 1564 "license": "MIT", 1566 - "peer": true, 1567 1565 "engines": { 1568 1566 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 1569 1567 }, ··· 1577 1575 "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", 1578 1576 "dev": true, 1579 1577 "license": "Apache-2.0", 1580 - "peer": true, 1581 1578 "engines": { 1582 1579 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 1583 1580 } ··· 1588 1585 "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", 1589 1586 "dev": true, 1590 1587 "license": "Apache-2.0", 1591 - "peer": true, 1592 1588 "dependencies": { 1593 1589 "@eslint/core": "^0.16.0", 1594 1590 "levn": "^0.4.1" ··· 1637 1633 "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", 1638 1634 "dev": true, 1639 1635 "license": "Apache-2.0", 1640 - "peer": true, 1641 1636 "engines": { 1642 1637 "node": ">=18.18.0" 1643 1638 } ··· 1648 1643 "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", 1649 1644 "dev": true, 1650 1645 "license": "Apache-2.0", 1651 - "peer": true, 1652 1646 "dependencies": { 1653 1647 "@humanfs/core": "^0.19.1", 1654 1648 "@humanwhocodes/retry": "^0.4.0" ··· 1663 1657 "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", 1664 1658 "dev": true, 1665 1659 "license": "Apache-2.0", 1666 - "peer": true, 1667 1660 "engines": { 1668 1661 "node": ">=12.22" 1669 1662 }, ··· 1678 1671 "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", 1679 1672 "dev": true, 1680 1673 "license": "Apache-2.0", 1681 - "peer": true, 1682 1674 "engines": { 1683 1675 "node": ">=18.18" 1684 1676 }, ··· 3856 3848 "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", 3857 3849 "dev": true, 3858 3850 "license": "MIT", 3851 + "peer": true, 3859 3852 "dependencies": { 3860 3853 "@babel/core": "^7.21.3", 3861 3854 "@svgr/babel-preset": "8.1.0", ··· 4330 4323 "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.6.tgz", 4331 4324 "integrity": "sha512-VUAag4ERjh+qlmg0wNivQIVCZUrYndqYu3/wPCVZd4r0E+1IqotbeyGTc+ICroL/PqbpSaGZg02zSWYfcvxbdA==", 4332 4325 "license": "MIT", 4326 + "peer": true, 4333 4327 "dependencies": { 4334 4328 "@tanstack/query-core": "5.85.6" 4335 4329 }, ··· 4363 4357 "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.131.28.tgz", 4364 4358 "integrity": "sha512-vWExhrqHJuT9v+6/2DCQ4pVvPaYoLazMNw8WXiLNuzBXh1FuEoIGaW3jw3DEP0OJCmMiWtTi34NzQnakkQZlQg==", 4365 4359 "license": "MIT", 4360 + "peer": true, 4366 4361 "dependencies": { 4367 4362 "@tanstack/history": "1.131.2", 4368 4363 "@tanstack/react-store": "^0.7.0", ··· 4427 4422 "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.131.28.tgz", 4428 4423 "integrity": "sha512-f+vdfr3WKSS/BcqgI5s4vZg9xYb7NkvIolkaMELrbz3l+khkw1aTjx8wqCHRY4dqwIAxq+iZBZtMWXA7pztGJg==", 4429 4424 "license": "MIT", 4425 + "peer": true, 4430 4426 "dependencies": { 4431 4427 "@tanstack/history": "1.131.2", 4432 4428 "@tanstack/store": "^0.7.0", ··· 4599 4595 "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", 4600 4596 "dev": true, 4601 4597 "license": "MIT", 4598 + "peer": true, 4602 4599 "dependencies": { 4603 4600 "@babel/code-frame": "^7.10.4", 4604 4601 "@babel/runtime": "^7.12.5", ··· 4721 4718 "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", 4722 4719 "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", 4723 4720 "dev": true, 4724 - "license": "MIT", 4725 - "peer": true 4721 + "license": "MIT" 4726 4722 }, 4727 4723 "node_modules/@types/node": { 4728 4724 "version": "24.3.0", ··· 4730 4726 "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", 4731 4727 "devOptional": true, 4732 4728 "license": "MIT", 4729 + "peer": true, 4733 4730 "dependencies": { 4734 4731 "undici-types": "~7.10.0" 4735 4732 } ··· 4739 4736 "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", 4740 4737 "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", 4741 4738 "license": "MIT", 4739 + "peer": true, 4742 4740 "dependencies": { 4743 4741 "csstype": "^3.0.2" 4744 4742 } ··· 4748 4746 "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", 4749 4747 "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", 4750 4748 "license": "MIT", 4749 + "peer": true, 4751 4750 "peerDependencies": { 4752 4751 "@types/react": "^19.0.0" 4753 4752 } ··· 4765 4764 "integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==", 4766 4765 "dev": true, 4767 4766 "license": "MIT", 4767 + "peer": true, 4768 4768 "dependencies": { 4769 4769 "@eslint-community/regexpp": "^4.10.0", 4770 4770 "@typescript-eslint/scope-manager": "8.46.1", ··· 4805 4805 "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", 4806 4806 "dev": true, 4807 4807 "license": "MIT", 4808 + "peer": true, 4808 4809 "dependencies": { 4809 4810 "@typescript-eslint/scope-manager": "8.46.1", 4810 4811 "@typescript-eslint/types": "8.46.1", ··· 5187 5188 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", 5188 5189 "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", 5189 5190 "license": "MIT", 5191 + "peer": true, 5190 5192 "bin": { 5191 5193 "acorn": "bin/acorn" 5192 5194 }, ··· 5200 5202 "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", 5201 5203 "dev": true, 5202 5204 "license": "MIT", 5203 - "peer": true, 5204 5205 "peerDependencies": { 5205 5206 "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" 5206 5207 } ··· 5221 5222 "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", 5222 5223 "dev": true, 5223 5224 "license": "MIT", 5224 - "peer": true, 5225 5225 "dependencies": { 5226 5226 "fast-deep-equal": "^3.1.1", 5227 5227 "fast-json-stable-stringify": "^2.0.0", ··· 5627 5627 } 5628 5628 ], 5629 5629 "license": "MIT", 5630 + "peer": true, 5630 5631 "dependencies": { 5631 5632 "caniuse-lite": "^1.0.30001737", 5632 5633 "electron-to-chromium": "^1.5.211", ··· 5784 5785 "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 5785 5786 "dev": true, 5786 5787 "license": "MIT", 5787 - "peer": true, 5788 5788 "dependencies": { 5789 5789 "ansi-styles": "^4.1.0", 5790 5790 "supports-color": "^7.1.0" ··· 5802 5802 "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 5803 5803 "dev": true, 5804 5804 "license": "MIT", 5805 - "peer": true, 5806 5805 "dependencies": { 5807 5806 "color-convert": "^2.0.1" 5808 5807 }, ··· 5883 5882 "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 5884 5883 "dev": true, 5885 5884 "license": "MIT", 5886 - "peer": true, 5887 5885 "dependencies": { 5888 5886 "color-name": "~1.1.4" 5889 5887 }, ··· 5896 5894 "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 5897 5895 "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 5898 5896 "dev": true, 5899 - "license": "MIT", 5900 - "peer": true 5897 + "license": "MIT" 5901 5898 }, 5902 5899 "node_modules/compare-versions": { 5903 5900 "version": "6.1.1", ··· 5976 5973 "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 5977 5974 "dev": true, 5978 5975 "license": "MIT", 5979 - "peer": true, 5980 5976 "dependencies": { 5981 5977 "path-key": "^3.1.0", 5982 5978 "shebang-command": "^2.0.0", ··· 6004 6000 "version": "3.1.3", 6005 6001 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", 6006 6002 "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", 6007 - "license": "MIT" 6003 + "license": "MIT", 6004 + "peer": true 6008 6005 }, 6009 6006 "node_modules/custom-media-element": { 6010 6007 "version": "1.4.5", ··· 6147 6144 "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", 6148 6145 "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", 6149 6146 "dev": true, 6150 - "license": "MIT", 6151 - "peer": true 6147 + "license": "MIT" 6152 6148 }, 6153 6149 "node_modules/define-data-property": { 6154 6150 "version": "1.1.4", ··· 6556 6552 "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", 6557 6553 "dev": true, 6558 6554 "license": "MIT", 6559 - "peer": true, 6560 6555 "engines": { 6561 6556 "node": ">=10" 6562 6557 }, ··· 6848 6843 "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", 6849 6844 "dev": true, 6850 6845 "license": "BSD-2-Clause", 6851 - "peer": true, 6852 6846 "dependencies": { 6853 6847 "esrecurse": "^4.3.0", 6854 6848 "estraverse": "^5.2.0" ··· 6879 6873 "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", 6880 6874 "dev": true, 6881 6875 "license": "ISC", 6882 - "peer": true, 6883 6876 "dependencies": { 6884 6877 "is-glob": "^4.0.3" 6885 6878 }, ··· 6893 6886 "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", 6894 6887 "dev": true, 6895 6888 "license": "BSD-2-Clause", 6896 - "peer": true, 6897 6889 "dependencies": { 6898 6890 "acorn": "^8.15.0", 6899 6891 "acorn-jsx": "^5.3.2", ··· 6925 6917 "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", 6926 6918 "dev": true, 6927 6919 "license": "BSD-3-Clause", 6928 - "peer": true, 6929 6920 "dependencies": { 6930 6921 "estraverse": "^5.1.0" 6931 6922 }, ··· 6939 6930 "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", 6940 6931 "dev": true, 6941 6932 "license": "BSD-2-Clause", 6942 - "peer": true, 6943 6933 "dependencies": { 6944 6934 "estraverse": "^5.2.0" 6945 6935 }, ··· 7028 7018 "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", 7029 7019 "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", 7030 7020 "dev": true, 7031 - "license": "MIT", 7032 - "peer": true 7021 + "license": "MIT" 7033 7022 }, 7034 7023 "node_modules/fast-levenshtein": { 7035 7024 "version": "2.0.6", 7036 7025 "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", 7037 7026 "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", 7038 7027 "dev": true, 7039 - "license": "MIT", 7040 - "peer": true 7028 + "license": "MIT" 7041 7029 }, 7042 7030 "node_modules/fastq": { 7043 7031 "version": "1.19.1", ··· 7055 7043 "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", 7056 7044 "dev": true, 7057 7045 "license": "MIT", 7058 - "peer": true, 7059 7046 "dependencies": { 7060 7047 "flat-cache": "^4.0.0" 7061 7048 }, ··· 7081 7068 "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", 7082 7069 "dev": true, 7083 7070 "license": "MIT", 7084 - "peer": true, 7085 7071 "dependencies": { 7086 7072 "locate-path": "^6.0.0", 7087 7073 "path-exists": "^4.0.0" ··· 7099 7085 "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", 7100 7086 "dev": true, 7101 7087 "license": "MIT", 7102 - "peer": true, 7103 7088 "dependencies": { 7104 7089 "flatted": "^3.2.9", 7105 7090 "keyv": "^4.5.4" ··· 7113 7098 "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", 7114 7099 "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", 7115 7100 "dev": true, 7116 - "license": "ISC", 7117 - "peer": true 7101 + "license": "ISC" 7118 7102 }, 7119 7103 "node_modules/for-each": { 7120 7104 "version": "0.3.5", ··· 7301 7285 "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", 7302 7286 "dev": true, 7303 7287 "license": "MIT", 7304 - "peer": true, 7305 7288 "engines": { 7306 7289 "node": ">=18" 7307 7290 }, ··· 7379 7362 "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 7380 7363 "dev": true, 7381 7364 "license": "MIT", 7382 - "peer": true, 7383 7365 "engines": { 7384 7366 "node": ">=8" 7385 7367 } ··· 7592 7574 "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", 7593 7575 "dev": true, 7594 7576 "license": "MIT", 7595 - "peer": true, 7596 7577 "engines": { 7597 7578 "node": ">= 4" 7598 7579 } ··· 7635 7616 "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", 7636 7617 "dev": true, 7637 7618 "license": "MIT", 7638 - "peer": true, 7639 7619 "engines": { 7640 7620 "node": ">=0.8.19" 7641 7621 } ··· 8141 8121 "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 8142 8122 "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", 8143 8123 "dev": true, 8144 - "license": "ISC", 8145 - "peer": true 8124 + "license": "ISC" 8146 8125 }, 8147 8126 "node_modules/iso-datestring-validator": { 8148 8127 "version": "2.2.2", ··· 8240 8219 "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", 8241 8220 "dev": true, 8242 8221 "license": "MIT", 8222 + "peer": true, 8243 8223 "dependencies": { 8244 8224 "cssstyle": "^4.2.1", 8245 8225 "data-urls": "^5.0.0", ··· 8291 8271 "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", 8292 8272 "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", 8293 8273 "dev": true, 8294 - "license": "MIT", 8295 - "peer": true 8274 + "license": "MIT" 8296 8275 }, 8297 8276 "node_modules/json-parse-even-better-errors": { 8298 8277 "version": "2.3.1", ··· 8306 8285 "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", 8307 8286 "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", 8308 8287 "dev": true, 8309 - "license": "MIT", 8310 - "peer": true 8288 + "license": "MIT" 8311 8289 }, 8312 8290 "node_modules/json-stable-stringify-without-jsonify": { 8313 8291 "version": "1.0.1", 8314 8292 "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", 8315 8293 "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", 8316 8294 "dev": true, 8317 - "license": "MIT", 8318 - "peer": true 8295 + "license": "MIT" 8319 8296 }, 8320 8297 "node_modules/json5": { 8321 8298 "version": "2.2.3", ··· 8351 8328 "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", 8352 8329 "dev": true, 8353 8330 "license": "MIT", 8354 - "peer": true, 8355 8331 "dependencies": { 8356 8332 "json-buffer": "3.0.1" 8357 8333 } ··· 8369 8345 "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", 8370 8346 "dev": true, 8371 8347 "license": "MIT", 8372 - "peer": true, 8373 8348 "dependencies": { 8374 8349 "prelude-ls": "^1.2.1", 8375 8350 "type-check": "~0.4.0" ··· 8655 8630 "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", 8656 8631 "dev": true, 8657 8632 "license": "MIT", 8658 - "peer": true, 8659 8633 "dependencies": { 8660 8634 "p-locate": "^5.0.0" 8661 8635 }, ··· 8677 8651 "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", 8678 8652 "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", 8679 8653 "dev": true, 8680 - "license": "MIT", 8681 - "peer": true 8654 + "license": "MIT" 8682 8655 }, 8683 8656 "node_modules/loose-envify": { 8684 8657 "version": "1.4.0", ··· 11138 11111 "version": "4.0.3", 11139 11112 "inBundle": true, 11140 11113 "license": "MIT", 11114 + "peer": true, 11141 11115 "engines": { 11142 11116 "node": ">=12" 11143 11117 }, ··· 11471 11445 "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", 11472 11446 "dev": true, 11473 11447 "license": "MIT", 11474 - "peer": true, 11475 11448 "dependencies": { 11476 11449 "deep-is": "^0.1.3", 11477 11450 "fast-levenshtein": "^2.0.6", ··· 11508 11481 "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", 11509 11482 "dev": true, 11510 11483 "license": "MIT", 11511 - "peer": true, 11512 11484 "dependencies": { 11513 11485 "yocto-queue": "^0.1.0" 11514 11486 }, ··· 11525 11497 "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", 11526 11498 "dev": true, 11527 11499 "license": "MIT", 11528 - "peer": true, 11529 11500 "dependencies": { 11530 11501 "p-limit": "^3.0.2" 11531 11502 }, ··· 11600 11571 "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", 11601 11572 "dev": true, 11602 11573 "license": "MIT", 11603 - "peer": true, 11604 11574 "engines": { 11605 11575 "node": ">=8" 11606 11576 } ··· 11611 11581 "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 11612 11582 "dev": true, 11613 11583 "license": "MIT", 11614 - "peer": true, 11615 11584 "engines": { 11616 11585 "node": ">=8" 11617 11586 } ··· 11740 11709 "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", 11741 11710 "dev": true, 11742 11711 "license": "MIT", 11743 - "peer": true, 11744 11712 "engines": { 11745 11713 "node": ">= 0.8.0" 11746 11714 } ··· 11921 11889 "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", 11922 11890 "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", 11923 11891 "license": "MIT", 11892 + "peer": true, 11924 11893 "engines": { 11925 11894 "node": ">=0.10.0" 11926 11895 } ··· 11930 11899 "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", 11931 11900 "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", 11932 11901 "license": "MIT", 11902 + "peer": true, 11933 11903 "dependencies": { 11934 11904 "scheduler": "^0.26.0" 11935 11905 }, ··· 12350 12320 "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", 12351 12321 "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", 12352 12322 "license": "MIT", 12323 + "peer": true, 12353 12324 "engines": { 12354 12325 "node": ">=10" 12355 12326 } ··· 12421 12392 "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 12422 12393 "dev": true, 12423 12394 "license": "MIT", 12424 - "peer": true, 12425 12395 "dependencies": { 12426 12396 "shebang-regex": "^3.0.0" 12427 12397 }, ··· 12435 12405 "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 12436 12406 "dev": true, 12437 12407 "license": "MIT", 12438 - "peer": true, 12439 12408 "engines": { 12440 12409 "node": ">=8" 12441 12410 } ··· 12539 12508 "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.9.tgz", 12540 12509 "integrity": "sha512-A0ZBPJQldAeGCTW0YRYJmt7RCeh5rbFfPZ2aOttgYnctHE7HgKeHCBB/PVc2P7eOfmNXqMFFFoYYdm3S4dcbkA==", 12541 12510 "license": "MIT", 12511 + "peer": true, 12542 12512 "dependencies": { 12543 12513 "csstype": "^3.1.0", 12544 12514 "seroval": "~1.3.0", ··· 12708 12678 "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", 12709 12679 "dev": true, 12710 12680 "license": "MIT", 12711 - "peer": true, 12712 12681 "engines": { 12713 12682 "node": ">=8" 12714 12683 }, ··· 12748 12717 "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 12749 12718 "dev": true, 12750 12719 "license": "MIT", 12751 - "peer": true, 12752 12720 "dependencies": { 12753 12721 "has-flag": "^4.0.0" 12754 12722 }, ··· 12848 12816 "version": "1.3.3", 12849 12817 "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", 12850 12818 "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", 12851 - "license": "MIT" 12819 + "license": "MIT", 12820 + "peer": true 12852 12821 }, 12853 12822 "node_modules/tiny-warning": { 12854 12823 "version": "1.0.3", ··· 12908 12877 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 12909 12878 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 12910 12879 "license": "MIT", 12880 + "peer": true, 12911 12881 "engines": { 12912 12882 "node": ">=12" 12913 12883 }, ··· 13105 13075 "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", 13106 13076 "dev": true, 13107 13077 "license": "MIT", 13108 - "peer": true, 13109 13078 "dependencies": { 13110 13079 "prelude-ls": "^1.2.1" 13111 13080 }, ··· 13197 13166 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 13198 13167 "dev": true, 13199 13168 "license": "Apache-2.0", 13169 + "peer": true, 13200 13170 "bin": { 13201 13171 "tsc": "bin/tsc", 13202 13172 "tsserver": "bin/tsserver" ··· 13533 13503 "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", 13534 13504 "dev": true, 13535 13505 "license": "BSD-2-Clause", 13536 - "peer": true, 13537 13506 "dependencies": { 13538 13507 "punycode": "^2.1.0" 13539 13508 } ··· 13602 13571 "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", 13603 13572 "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", 13604 13573 "license": "MIT", 13574 + "peer": true, 13605 13575 "dependencies": { 13606 13576 "esbuild": "^0.25.0", 13607 13577 "fdir": "^6.4.4", ··· 13716 13686 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 13717 13687 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 13718 13688 "license": "MIT", 13689 + "peer": true, 13719 13690 "engines": { 13720 13691 "node": ">=12" 13721 13692 }, ··· 13897 13868 "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 13898 13869 "dev": true, 13899 13870 "license": "ISC", 13900 - "peer": true, 13901 13871 "dependencies": { 13902 13872 "isexe": "^2.0.0" 13903 13873 }, ··· 14029 13999 "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", 14030 14000 "dev": true, 14031 14001 "license": "MIT", 14032 - "peer": true, 14033 14002 "engines": { 14034 14003 "node": ">=0.10.0" 14035 14004 } ··· 14084 14053 "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", 14085 14054 "dev": true, 14086 14055 "license": "MIT", 14087 - "peer": true, 14088 14056 "engines": { 14089 14057 "node": ">=10" 14090 14058 }, ··· 14103 14071 "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 14104 14072 "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 14105 14073 "license": "MIT", 14074 + "peer": true, 14106 14075 "funding": { 14107 14076 "url": "https://github.com/sponsors/colinhacks" 14108 14077 }
+3
src/auto-imports.d.ts
··· 18 18 const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default 19 19 const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default 20 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 + const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 + const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default 23 + const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 21 24 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 22 25 }
+117 -164
src/components/Composer.tsx
··· 8 8 import { useQueryPost } from "~/utils/useQuery"; 9 9 10 10 import { ProfileThing } from "./Login"; 11 - import { Button } from "./radix-m3-rd/Button"; 12 11 import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 13 12 14 13 const MAX_POST_LENGTH = 300; 15 14 16 15 export function Composer() { 17 16 const [composerState, setComposerState] = useAtom(composerAtom); 18 - const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false); 19 17 const { agent } = useAuth(); 20 18 21 19 const [postText, setPostText] = useState(""); ··· 114 112 setPosting(false); 115 113 } 116 114 } 115 + // if (composerState.kind === "closed") { 116 + // return null; 117 + // } 117 118 118 119 const getPlaceholder = () => { 119 120 switch (composerState.kind) { ··· 131 132 const isPostButtonDisabled = 132 133 posting || !postText.trim() || isParentLoading || charsLeft < 0; 133 134 134 - function handleAttemptClose() { 135 - if (postText.trim() && !posting) { 136 - setCloseConfirmState(true); 137 - } else { 138 - setComposerState({ kind: "closed" }); 139 - } 140 - } 141 - 142 - function handleConfirmClose() { 143 - setComposerState({ kind: "closed" }); 144 - setCloseConfirmState(false); 145 - setPostText(""); 146 - } 147 - 148 135 return ( 149 - <> 150 - <Dialog.Root 151 - open={composerState.kind !== "closed"} 152 - onOpenChange={(open) => { 153 - if (!open) handleAttemptClose(); 154 - }} 155 - > 156 - <Dialog.Portal> 157 - <Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 136 + <Dialog.Root 137 + open={composerState.kind !== "closed"} 138 + onOpenChange={(open) => { 139 + if (!open) setComposerState({ kind: "closed" }); 140 + }} 141 + > 142 + <Dialog.Portal> 143 + <Dialog.Overlay className="fixed disablegutter inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 158 144 159 - <Dialog.Content className="fixed overflow-y-auto gutter inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]"> 160 - <div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4"> 161 - <div className="flex flex-row justify-between p-2"> 162 - <Dialog.Close asChild> 163 - <button 164 - className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800" 165 - disabled={posting} 166 - aria-label="Close" 167 - onClick={handleAttemptClose} 145 + <Dialog.Content className="fixed gutter overflow-y-scroll inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]"> 146 + <div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4"> 147 + <div className="flex flex-row justify-between p-2"> 148 + <Dialog.Close asChild> 149 + <button 150 + className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800" 151 + disabled={posting} 152 + aria-label="Close" 153 + > 154 + <svg 155 + xmlns="http://www.w3.org/2000/svg" 156 + width="20" 157 + height="20" 158 + viewBox="0 0 24 24" 159 + fill="none" 160 + stroke="currentColor" 161 + strokeWidth="2.5" 162 + strokeLinecap="round" 163 + strokeLinejoin="round" 168 164 > 169 - <svg 170 - xmlns="http://www.w3.org/2000/svg" 171 - width="20" 172 - height="20" 173 - viewBox="0 0 24 24" 174 - fill="none" 175 - stroke="currentColor" 176 - strokeWidth="2.5" 177 - strokeLinecap="round" 178 - strokeLinejoin="round" 179 - > 180 - <line x1="18" y1="6" x2="6" y2="18"></line> 181 - <line x1="6" y1="6" x2="18" y2="18"></line> 182 - </svg> 183 - </button> 184 - </Dialog.Close> 165 + <line x1="18" y1="6" x2="6" y2="18"></line> 166 + <line x1="6" y1="6" x2="18" y2="18"></line> 167 + </svg> 168 + </button> 169 + </Dialog.Close> 185 170 186 - <div className="flex-1" /> 187 - <div className="flex items-center gap-4"> 188 - <span 189 - className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`} 190 - > 191 - {charsLeft} 192 - </span> 193 - <Button 194 - onClick={handlePost} 195 - disabled={isPostButtonDisabled} 196 - > 197 - {posting ? "Posting..." : "Post"} 198 - </Button> 199 - </div> 171 + <div className="flex-1" /> 172 + <div className="flex items-center gap-4"> 173 + <span 174 + className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`} 175 + > 176 + {charsLeft} 177 + </span> 178 + <button 179 + className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 180 + onClick={handlePost} 181 + disabled={isPostButtonDisabled} 182 + > 183 + {posting ? "Posting..." : "Post"} 184 + </button> 200 185 </div> 186 + </div> 201 187 202 - {postSuccess ? ( 203 - <div className="flex flex-col items-center justify-center py-16"> 204 - <span className="text-gray-500 text-6xl mb-4">โœ“</span> 205 - <span className="text-xl font-bold text-black dark:text-white"> 206 - Posted! 207 - </span> 208 - </div> 209 - ) : ( 210 - <div className="px-4"> 211 - {composerState.kind === "reply" && ( 212 - <div className="mb-1 -mx-4"> 213 - {isParentLoading ? ( 214 - <div className="text-sm text-gray-500 animate-pulse"> 215 - Loading parent post... 216 - </div> 217 - ) : parentUri ? ( 218 - <UniversalPostRendererATURILoader 219 - atUri={parentUri} 220 - bottomReplyLine 221 - bottomBorder={false} 222 - /> 223 - ) : ( 224 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 225 - Could not load parent post. 226 - </div> 227 - )} 228 - </div> 229 - )} 230 - 231 - <div className="flex w-full gap-1 flex-col"> 232 - <ProfileThing agent={agent} large /> 233 - <div className="flex pl-[50px]"> 234 - <AutoGrowTextarea 235 - className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 236 - rows={5} 237 - placeholder={getPlaceholder()} 238 - value={postText} 239 - onChange={(e) => setPostText(e.target.value)} 240 - disabled={posting} 241 - autoFocus 188 + {postSuccess ? ( 189 + <div className="flex flex-col items-center justify-center py-16"> 190 + <span className="text-gray-500 text-6xl mb-4">โœ“</span> 191 + <span className="text-xl font-bold text-black dark:text-white"> 192 + Posted! 193 + </span> 194 + </div> 195 + ) : ( 196 + <div className="px-4"> 197 + {composerState.kind === "reply" && ( 198 + <div className="mb-1 -mx-4"> 199 + {isParentLoading ? ( 200 + <div className="text-sm text-gray-500 animate-pulse"> 201 + Loading parent post... 202 + </div> 203 + ) : parentUri ? ( 204 + <UniversalPostRendererATURILoader 205 + atUri={parentUri} 206 + bottomReplyLine 207 + bottomBorder={false} 242 208 /> 243 - </div> 209 + ) : ( 210 + <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 211 + Could not load parent post. 212 + </div> 213 + )} 244 214 </div> 215 + )} 245 216 246 - {composerState.kind === "quote" && ( 247 - <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 248 - {isParentLoading ? ( 249 - <div className="text-sm text-gray-500 animate-pulse"> 250 - Loading parent post... 251 - </div> 252 - ) : parentUri ? ( 253 - <UniversalPostRendererATURILoader 254 - atUri={parentUri} 255 - isQuote 256 - /> 257 - ) : ( 258 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 259 - Could not load parent post. 260 - </div> 261 - )} 262 - </div> 263 - )} 264 - 265 - {postError && ( 266 - <div className="text-red-500 text-sm my-2 text-center"> 267 - {postError} 268 - </div> 269 - )} 217 + <div className="flex w-full gap-1 flex-col"> 218 + <ProfileThing agent={agent} large /> 219 + <div className="flex pl-[50px]"> 220 + <AutoGrowTextarea 221 + className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 222 + rows={5} 223 + placeholder={getPlaceholder()} 224 + value={postText} 225 + onChange={(e) => setPostText(e.target.value)} 226 + disabled={posting} 227 + autoFocus 228 + /> 229 + </div> 270 230 </div> 271 - )} 272 - </div> 273 - </Dialog.Content> 274 - </Dialog.Portal> 275 - </Dialog.Root> 276 231 277 - {/* Close confirmation dialog */} 278 - <Dialog.Root open={closeConfirmState} onOpenChange={setCloseConfirmState}> 279 - <Dialog.Portal> 280 - 281 - <Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 232 + {composerState.kind === "quote" && ( 233 + <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 234 + {isParentLoading ? ( 235 + <div className="text-sm text-gray-500 animate-pulse"> 236 + Loading parent post... 237 + </div> 238 + ) : parentUri ? ( 239 + <UniversalPostRendererATURILoader 240 + atUri={parentUri} 241 + isQuote 242 + /> 243 + ) : ( 244 + <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 245 + Could not load parent post. 246 + </div> 247 + )} 248 + </div> 249 + )} 282 250 283 - <Dialog.Content className="fixed gutter inset-0 z-50 flex items-start justify-center pt-30 sm:pt-40"> 284 - <div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-md relative mx-4 py-6"> 285 - <div className="text-xl mb-4 text-center"> 286 - Discard your post? 287 - </div> 288 - <div className="text-md mb-4 text-center"> 289 - You will lose your draft 251 + {postError && ( 252 + <div className="text-red-500 text-sm my-2 text-center"> 253 + {postError} 254 + </div> 255 + )} 290 256 </div> 291 - <div className="flex justify-end gap-2 px-6"> 292 - <Button 293 - onClick={handleConfirmClose} 294 - > 295 - Discard 296 - </Button> 297 - <Button 298 - variant={"outlined"} 299 - onClick={() => setCloseConfirmState(false)} 300 - > 301 - Cancel 302 - </Button> 303 - </div> 304 - </div> 305 - </Dialog.Content> 306 - </Dialog.Portal> 307 - </Dialog.Root> 308 - </> 257 + )} 258 + </div> 259 + </Dialog.Content> 260 + </Dialog.Portal> 261 + </Dialog.Root> 309 262 ); 310 263 } 311 264
+4 -2
src/components/Header.tsx
··· 5 5 6 6 export function Header({ 7 7 backButtonCallback, 8 - title 8 + title, 9 + bottomBorderDisabled, 9 10 }: { 10 11 backButtonCallback?: () => void; 11 12 title?: string; 13 + bottomBorderDisabled?: boolean; 12 14 }) { 13 15 const router = useRouter(); 14 16 const [isAtTop] = useAtom(isAtTopAtom); 15 17 //const what = router.history. 16 18 return ( 17 - <div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}> 19 + <div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 ${!bottomBorderDisabled && "sm:border-b"} ${!isAtTop && !bottomBorderDisabled && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}> 18 20 {backButtonCallback ? (<Link 19 21 to=".." 20 22 //className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
+6 -1
src/components/InfiniteCustomFeed.tsx
··· 14 14 feedUri: string; 15 15 pdsUrl?: string; 16 16 feedServiceDid?: string; 17 + authedOverride?: boolean; 18 + unauthedfeedurl?: string; 17 19 } 18 20 19 21 export function InfiniteCustomFeed({ 20 22 feedUri, 21 23 pdsUrl, 22 24 feedServiceDid, 25 + authedOverride, 26 + unauthedfeedurl, 23 27 }: InfiniteCustomFeedProps) { 24 28 const { agent } = useAuth(); 25 - const authed = !!agent?.did; 29 + const authed = authedOverride || !!agent?.did; 26 30 27 31 // const identityresultmaybe = useQueryIdentity(agent?.did); 28 32 // const identity = identityresultmaybe?.data; ··· 45 49 isAuthed: authed ?? false, 46 50 pdsUrl: pdsUrl, 47 51 feedServiceDid: feedServiceDid, 52 + unauthedfeedurl: unauthedfeedurl, 48 53 }); 49 54 const queryClient = useQueryClient(); 50 55
+3 -5
src/components/Login.tsx
··· 7 7 import { imgCDNAtom } from "~/utils/atoms"; 8 8 import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery"; 9 9 10 - import { Button } from "./radix-m3-rd/Button"; 11 - 12 10 // --- 1. The Main Component (Orchestrator with `compact` prop) --- 13 11 export default function Login({ 14 12 compact = false, ··· 51 49 You are logged in! 52 50 </p> 53 51 <ProfileThing agent={agent} large /> 54 - <Button 52 + <button 55 53 onClick={logout} 56 - className="mt-4" 54 + className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded-full px-6 py-2 font-semibold text-base transition-colors" 57 55 > 58 56 Log out 59 - </Button> 57 + </button> 60 58 </div> 61 59 </div> 62 60 );
+124
src/components/ReusableTabRoute.tsx
··· 1 + import * as TabsPrimitive from "@radix-ui/react-tabs"; 2 + import { useAtom } from "jotai"; 3 + import { useEffect, useLayoutEffect } from "react"; 4 + 5 + import { isAtTopAtom, reusableTabRouteScrollAtom } from "~/utils/atoms"; 6 + 7 + /** 8 + * Please wrap your Route in a div, do not return a top-level fragment, 9 + * it will break navigation scroll restoration 10 + */ 11 + export function ReusableTabRoute({ 12 + route, 13 + tabs, 14 + }: { 15 + route: string; 16 + tabs: Record<string, React.ReactNode>; 17 + }) { 18 + const [reusableTabState, setReusableTabState] = useAtom( 19 + reusableTabRouteScrollAtom 20 + ); 21 + const [isAtTop] = useAtom(isAtTopAtom); 22 + 23 + const routeState = reusableTabState?.[route] ?? { 24 + activeTab: Object.keys(tabs)[0], 25 + scrollPositions: {}, 26 + }; 27 + const activeTab = routeState.activeTab; 28 + 29 + const handleValueChange = (newTab: string) => { 30 + setReusableTabState((prev) => { 31 + const current = prev?.[route] ?? routeState; 32 + return { 33 + ...prev, 34 + [route]: { 35 + ...current, 36 + scrollPositions: { 37 + ...current.scrollPositions, 38 + [current.activeTab]: window.scrollY, 39 + }, 40 + activeTab: newTab, 41 + }, 42 + }; 43 + }); 44 + }; 45 + 46 + // // todo, warning experimental, usually this doesnt work, 47 + // // like at all, and i usually do this for each tab 48 + // useLayoutEffect(() => { 49 + // const savedScroll = routeState.scrollPositions[activeTab] ?? 0; 50 + // window.scrollTo({ top: savedScroll }); 51 + // // eslint-disable-next-line react-hooks/exhaustive-deps 52 + // }, [activeTab, route]); 53 + 54 + useLayoutEffect(() => { 55 + return () => { 56 + setReusableTabState((prev) => { 57 + const current = prev?.[route] ?? routeState; 58 + return { 59 + ...prev, 60 + [route]: { 61 + ...current, 62 + scrollPositions: { 63 + ...current.scrollPositions, 64 + [current.activeTab]: window.scrollY, 65 + }, 66 + }, 67 + }; 68 + }); 69 + }; 70 + // eslint-disable-next-line react-hooks/exhaustive-deps 71 + }, []); 72 + 73 + return ( 74 + <TabsPrimitive.Root 75 + value={activeTab} 76 + onValueChange={handleValueChange} 77 + className={`w-full`} 78 + > 79 + <TabsPrimitive.List 80 + className={`flex sticky top-[52px] bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-[9] border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`} 81 + > 82 + {Object.entries(tabs).map(([key]) => ( 83 + <TabsPrimitive.Trigger key={key} value={key} className="m3tab"> 84 + {key} 85 + </TabsPrimitive.Trigger> 86 + ))} 87 + </TabsPrimitive.List> 88 + 89 + {Object.entries(tabs).map(([key, node]) => ( 90 + <TabsPrimitive.Content key={key} value={key} className="flex-1 min-h-[80dvh]"> 91 + {activeTab === key && node} 92 + </TabsPrimitive.Content> 93 + ))} 94 + </TabsPrimitive.Root> 95 + ); 96 + } 97 + 98 + export function useReusableTabScrollRestore(route: string) { 99 + const [reusableTabState] = useAtom( 100 + reusableTabRouteScrollAtom 101 + ); 102 + 103 + const routeState = reusableTabState?.[route]; 104 + const activeTab = routeState?.activeTab; 105 + 106 + useEffect(() => { 107 + const savedScroll = activeTab ? routeState?.scrollPositions[activeTab] ?? 0 : 0; 108 + //window.scrollTo(0, savedScroll); 109 + window.scrollTo({ top: savedScroll }); 110 + // eslint-disable-next-line react-hooks/exhaustive-deps 111 + }, []); 112 + } 113 + 114 + 115 + /* 116 + 117 + const [notifState] = useAtom(notificationsScrollAtom); 118 + const activeTab = notifState.activeTab; 119 + useEffect(() => { 120 + const savedY = notifState.scrollPositions[activeTab] ?? 0; 121 + window.scrollTo(0, savedY); 122 + }, [activeTab, notifState.scrollPositions]); 123 + 124 + */
+183 -54
src/components/UniversalPostRenderer.tsx
··· 1 + import * as ATPAPI from "@atproto/api"; 1 2 import { useNavigate } from "@tanstack/react-router"; 2 3 import DOMPurify from "dompurify"; 3 4 import { useAtom } from "jotai"; ··· 9 10 import { 10 11 composerAtom, 11 12 constellationURLAtom, 13 + enableBridgyTextAtom, 14 + enableWafrnTextAtom, 12 15 imgCDNAtom, 13 - likedPostsAtom, 14 16 } from "~/utils/atoms"; 15 17 import { useHydratedEmbed } from "~/utils/useHydrated"; 16 18 import { ··· 38 40 feedviewpost?: boolean; 39 41 repostedby?: string; 40 42 style?: React.CSSProperties; 41 - ref?: React.Ref<HTMLDivElement>; 43 + ref?: React.RefObject<HTMLDivElement>; 42 44 dataIndexPropPass?: number; 43 45 nopics?: boolean; 46 + concise?: boolean; 44 47 lightboxCallback?: (d: LightboxProps) => void; 45 48 maxReplies?: number; 46 49 isQuote?: boolean; 50 + filterNoReplies?: boolean; 51 + filterMustHaveMedia?: boolean; 52 + filterMustBeReply?: boolean; 47 53 } 48 54 49 55 // export async function cachedGetRecord({ ··· 152 158 ref, 153 159 dataIndexPropPass, 154 160 nopics, 161 + concise, 155 162 lightboxCallback, 156 163 maxReplies, 157 164 isQuote, 165 + filterNoReplies, 166 + filterMustHaveMedia, 167 + filterMustBeReply, 158 168 }: UniversalPostRendererATURILoaderProps) { 159 169 // todo remove this once tree rendering is implemented, use a prop like isTree 160 170 const TEMPLINEAR = true; ··· 518 528 ? true 519 529 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 520 530 ? false 521 - : (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine 531 + : maxReplies === 0 && (!replies || (!!replies && replies === 0)) 532 + ? false 533 + : bottomReplyLine 522 534 } 523 535 topReplyLine={topReplyLine} 524 536 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 536 548 ref={ref} 537 549 dataIndexPropPass={dataIndexPropPass} 538 550 nopics={nopics} 551 + concise={concise} 539 552 lightboxCallback={lightboxCallback} 540 553 maxReplies={maxReplies} 541 554 isQuote={isQuote} 555 + filterNoReplies={filterNoReplies} 556 + filterMustHaveMedia={filterMustHaveMedia} 557 + filterMustBeReply={filterMustBeReply} 542 558 /> 543 559 <> 544 - {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( 560 + {maxReplies && maxReplies === 0 && replies && replies > 0 ? ( 545 561 <> 546 - {/* <div>hello</div> */} 547 - <MoreReplies atUri={atUri} /> 562 + {/* <div>hello</div> */} 563 + <MoreReplies atUri={atUri} /> 548 564 </> 549 - ) : (<></>)} 565 + ) : ( 566 + <></> 567 + )} 550 568 </> 551 569 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 552 570 <> ··· 567 585 ref={ref} 568 586 dataIndexPropPass={dataIndexPropPass} 569 587 nopics={nopics} 588 + concise={concise} 570 589 lightboxCallback={lightboxCallback} 571 590 maxReplies={ 572 591 maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined ··· 636 655 ref, 637 656 dataIndexPropPass, 638 657 nopics, 658 + concise, 639 659 lightboxCallback, 640 660 maxReplies, 641 661 isQuote, 662 + filterNoReplies, 663 + filterMustHaveMedia, 664 + filterMustBeReply, 642 665 }: { 643 666 postRecord: any; 644 667 profileRecord: any; ··· 654 677 feedviewpost?: boolean; 655 678 repostedby?: string; 656 679 style?: React.CSSProperties; 657 - ref?: React.Ref<HTMLDivElement>; 680 + ref?: React.RefObject<HTMLDivElement>; 658 681 dataIndexPropPass?: number; 659 682 nopics?: boolean; 683 + concise?: boolean; 660 684 lightboxCallback?: (d: LightboxProps) => void; 661 685 maxReplies?: number; 662 686 isQuote?: boolean; 687 + filterNoReplies?: boolean; 688 + filterMustHaveMedia?: boolean; 689 + filterMustBeReply?: boolean; 663 690 }) { 664 691 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 665 692 const navigate = useNavigate(); ··· 729 756 730 757 // run(); 731 758 // }, [postRecord, resolved?.did]); 759 + 760 + const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed; 761 + const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; 762 + const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; 763 + const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia"; 764 + const isQuotewithImages = 765 + isquotewithmedia && 766 + (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 767 + "app.bsky.embed.images"; 768 + const isQuotewithVideo = 769 + isquotewithmedia && 770 + (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 771 + "app.bsky.embed.video"; 772 + 773 + const hasMedia = 774 + hasEmbed && 775 + (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo); 732 776 733 777 const { 734 778 data: hydratedEmbed, ··· 824 868 // }, [fakepost, get, set]); 825 869 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 826 870 ?.uri; 827 - const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; 871 + const feedviewpostreplydid = 872 + thereply && !filterNoReplies ? new AtUri(thereply).host : undefined; 828 873 const replyhookvalue = useQueryIdentity( 829 874 feedviewpost ? feedviewpostreplydid : undefined 830 875 ); ··· 835 880 repostedby ? aturirepostbydid : undefined 836 881 ); 837 882 const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle; 883 + 884 + if (filterNoReplies && thereply) return null; 885 + 886 + if (filterMustHaveMedia && !hasMedia) return null; 887 + 888 + if (filterMustBeReply && !thereply) return null; 889 + 838 890 return ( 839 891 <> 840 892 {/* <p> 841 893 {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} 842 894 </p> */} 895 + {/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span> 896 + <span>thereply is {thereply ? "true" : "false"}</span> */} 843 897 <UniversalPostRenderer 844 898 expanded={detailed} 845 899 onPostClick={() => ··· 874 928 ref={ref} 875 929 dataIndexPropPass={dataIndexPropPass} 876 930 nopics={nopics} 931 + concise={concise} 877 932 lightboxCallback={lightboxCallback} 878 933 maxReplies={maxReplies} 879 934 isQuote={isQuote} ··· 1197 1252 1198 1253 import defaultpfp from "~/../public/favicon.png"; 1199 1254 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1200 - import { FollowButton, Mutual } from "~/routes/profile.$did"; 1255 + import { 1256 + FeedItemRenderAturiLoader, 1257 + FollowButton, 1258 + Mutual, 1259 + } from "~/routes/profile.$did"; 1201 1260 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1261 + import { useFastLike } from "~/utils/likeMutationQueue"; 1202 1262 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 1203 1263 // import type { 1204 1264 // ViewRecord, ··· 1327 1387 ref, 1328 1388 dataIndexPropPass, 1329 1389 nopics, 1390 + concise, 1330 1391 lightboxCallback, 1331 1392 maxReplies, 1332 1393 }: { ··· 1350 1411 depth?: number; 1351 1412 repostedby?: string; 1352 1413 style?: React.CSSProperties; 1353 - ref?: React.Ref<HTMLDivElement>; 1414 + ref?: React.RefObject<HTMLDivElement>; 1354 1415 dataIndexPropPass?: number; 1355 1416 nopics?: boolean; 1417 + concise?: boolean; 1356 1418 lightboxCallback?: (d: LightboxProps) => void; 1357 1419 maxReplies?: number; 1358 1420 }) { 1359 1421 const parsed = new AtUri(post.uri); 1360 1422 const navigate = useNavigate(); 1361 - const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom); 1362 1423 const [hasRetweeted, setHasRetweeted] = useState<boolean>( 1363 1424 post.viewer?.repost ? true : false 1364 1425 ); 1365 - const [hasLiked, setHasLiked] = useState<boolean>( 1366 - post.uri in likedPosts || post.viewer?.like ? true : false 1367 - ); 1368 1426 const [, setComposerPost] = useAtom(composerAtom); 1369 1427 const { agent } = useAuth(); 1370 - const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like); 1371 1428 const [retweetUri, setRetweetUri] = useState<string | undefined>( 1372 1429 post.viewer?.repost 1373 1430 ); 1374 - 1375 - const likeOrUnlikePost = async () => { 1376 - const newLikedPosts = { ...likedPosts }; 1377 - if (!agent) { 1378 - console.error("Agent is null or undefined"); 1379 - return; 1380 - } 1381 - if (hasLiked) { 1382 - if (post.uri in likedPosts) { 1383 - const likeUri = likedPosts[post.uri]; 1384 - setLikeUri(likeUri); 1385 - } 1386 - if (likeUri) { 1387 - await agent.deleteLike(likeUri); 1388 - setHasLiked(false); 1389 - delete newLikedPosts[post.uri]; 1390 - } 1391 - } else { 1392 - const { uri } = await agent.like(post.uri, post.cid); 1393 - setLikeUri(uri); 1394 - setHasLiked(true); 1395 - newLikedPosts[post.uri] = uri; 1396 - } 1397 - setLikedPosts(newLikedPosts); 1398 - }; 1431 + const { liked, toggle, backfill } = useFastLike(post.uri, post.cid); 1432 + // const bovref = useBackfillOnView(post.uri, post.cid); 1433 + // React.useLayoutEffect(()=>{ 1434 + // if (expanded && !isQuote) { 1435 + // backfill(); 1436 + // } 1437 + // },[backfill, expanded, isQuote]) 1399 1438 1400 1439 const repostOrUnrepostPost = async () => { 1401 1440 if (!agent) { ··· 1426 1465 : undefined; 1427 1466 1428 1467 const emergencySalt = randomString(); 1429 - const fedi = (post.record as { bridgyOriginalText?: string }) 1468 + 1469 + const [showBridgyText] = useAtom(enableBridgyTextAtom); 1470 + const [showWafrnText] = useAtom(enableWafrnTextAtom); 1471 + 1472 + const unfedibridgy = (post.record as { bridgyOriginalText?: string }) 1430 1473 .bridgyOriginalText; 1474 + const unfediwafrnPartial = (post.record as { fullText?: string }).fullText; 1475 + const unfediwafrnTags = (post.record as { fullTags?: string }).fullTags; 1476 + const unfediwafrnUnHost = (post.record as { fediverseId?: string }) 1477 + .fediverseId; 1478 + 1479 + const undfediwafrnHost = unfediwafrnUnHost 1480 + ? new URL(unfediwafrnUnHost).hostname 1481 + : undefined; 1482 + 1483 + const tags = unfediwafrnTags 1484 + ? unfediwafrnTags 1485 + .split("\n") 1486 + .map((t) => t.trim()) 1487 + .filter(Boolean) 1488 + : undefined; 1489 + 1490 + const links = tags 1491 + ? tags 1492 + .map((tag) => { 1493 + const encoded = encodeURIComponent(tag); 1494 + return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(' ','-')}</a>`; 1495 + }) 1496 + .join("<br>") 1497 + : ""; 1498 + 1499 + const unfediwafrn = unfediwafrnPartial 1500 + ? unfediwafrnPartial + (links ? `<br>${links}` : "") 1501 + : undefined; 1502 + 1503 + const fedi = 1504 + (showBridgyText ? unfedibridgy : undefined) ?? 1505 + (showWafrnText ? unfediwafrn : undefined); 1431 1506 1432 1507 /* fuck you */ 1433 1508 const isMainItem = false; 1434 1509 const setMainItem = (any: any) => {}; 1435 1510 // eslint-disable-next-line react-hooks/refs 1436 - console.log("Received ref in UniversalPostRenderer:", ref); 1511 + //console.log("Received ref in UniversalPostRenderer:", usedref); 1437 1512 return ( 1438 1513 <div ref={ref} style={style} data-index={dataIndexPropPass}> 1439 1514 <div ··· 1557 1632 className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1558 1633 /> 1559 1634 <div className=" flex-1 flex flex-row align-middle justify-end"> 1560 - <div className=" flex flex-col justify-start"> 1561 1635 <FollowButton targetdidorhandle={post.author.did} /> 1562 - </div> 1563 1636 </div> 1564 1637 </div> 1565 1638 <div className="flex flex-col gap-3"> ··· 1568 1641 {post.author.displayName || post.author.handle}{" "} 1569 1642 </div> 1570 1643 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1571 - <Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "} 1644 + <Mutual targetdidorhandle={post.author.did} />@ 1645 + {post.author.handle}{" "} 1572 1646 </div> 1573 1647 </div> 1574 1648 {uprrrsauthor?.description && ( ··· 1761 1835 <div 1762 1836 style={{ 1763 1837 fontSize: 16, 1764 - marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8, 1838 + marginBottom: !post.embed || concise ? 0 : 8, 1765 1839 whiteSpace: "pre-wrap", 1766 1840 textAlign: "left", 1767 1841 overflowWrap: "anywhere", 1768 1842 wordBreak: "break-word", 1769 - //color: theme.text, 1843 + ...(concise && { 1844 + display: "-webkit-box", 1845 + WebkitBoxOrient: "vertical", 1846 + WebkitLineClamp: 2, 1847 + overflow: "hidden", 1848 + }), 1770 1849 }} 1771 1850 className="text-gray-900 dark:text-gray-100" 1772 1851 > ··· 1789 1868 </> 1790 1869 )} 1791 1870 </div> 1792 - {post.embed && depth < 1 ? ( 1871 + {post.embed && depth < 1 && !concise ? ( 1793 1872 <PostEmbeds 1794 1873 embed={post.embed} 1795 1874 //moderation={moderation} ··· 1811 1890 </div> 1812 1891 </> 1813 1892 )} 1814 - <div style={{ paddingTop: post.embed && depth < 1 ? 4 : 0 }}> 1893 + <div 1894 + style={{ 1895 + paddingTop: post.embed && !concise && depth < 1 ? 4 : 0, 1896 + }} 1897 + > 1815 1898 <> 1816 1899 {expanded && ( 1817 1900 <div ··· 1907 1990 </DropdownMenu.Root> 1908 1991 <HitSlopButton 1909 1992 onClick={() => { 1910 - likeOrUnlikePost(); 1993 + toggle(); 1911 1994 }} 1912 1995 style={{ 1913 1996 ...btnstyle, 1914 - ...(hasLiked ? { color: "#EC4899" } : {}), 1997 + ...(liked ? { color: "#EC4899" } : {}), 1915 1998 }} 1916 1999 > 1917 - {hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 1918 - {(post.likeCount || 0) + (hasLiked ? 1 : 0)} 2000 + {liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 2001 + {(post.likeCount || 0) + (liked ? 1 : 0)} 1919 2002 </HitSlopButton> 1920 2003 <div style={{ display: "flex", gap: 8 }}> 1921 2004 <HitSlopButton ··· 2167 2250 } 2168 2251 2169 2252 if (AppBskyEmbedRecord.isView(embed)) { 2253 + // hey im really lazy and im gonna do it the bad way 2254 + const reallybaduri = (embed?.record as any)?.uri as string | undefined; 2255 + const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined; 2256 + 2170 2257 // custom feed embed (i.e. generator view) 2171 2258 if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 2172 2259 // stopgap sorry ··· 2176 2263 // <MaybeFeedCard view={embed.record} /> 2177 2264 // </div> 2178 2265 // ) 2266 + } else if ( 2267 + !!reallybaduri && 2268 + !!reallybadaturi && 2269 + reallybadaturi.collection === "app.bsky.feed.generator" 2270 + ) { 2271 + return ( 2272 + <div className="rounded-xl border"> 2273 + <FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder /> 2274 + </div> 2275 + ); 2179 2276 } 2180 2277 2181 2278 // list embed ··· 2187 2284 // <MaybeListCard view={embed.record} /> 2188 2285 // </div> 2189 2286 // ) 2287 + } else if ( 2288 + !!reallybaduri && 2289 + !!reallybadaturi && 2290 + reallybadaturi.collection === "app.bsky.graph.list" 2291 + ) { 2292 + return ( 2293 + <div className="rounded-xl border"> 2294 + <FeedItemRenderAturiLoader 2295 + aturi={reallybaduri} 2296 + disableBottomBorder 2297 + listmode 2298 + disablePropagation 2299 + /> 2300 + </div> 2301 + ); 2190 2302 } 2191 2303 2192 2304 // starter pack embed ··· 2198 2310 // <StarterPackCard starterPack={embed.record} /> 2199 2311 // </div> 2200 2312 // ) 2313 + } else if ( 2314 + !!reallybaduri && 2315 + !!reallybadaturi && 2316 + reallybadaturi.collection === "app.bsky.graph.starterpack" 2317 + ) { 2318 + return ( 2319 + <div className="rounded-xl border"> 2320 + <FeedItemRenderAturiLoader 2321 + aturi={reallybaduri} 2322 + disableBottomBorder 2323 + listmode 2324 + disablePropagation 2325 + /> 2326 + </div> 2327 + ); 2201 2328 } 2202 2329 2203 2330 // quote post ··· 2257 2384 </div> 2258 2385 ); 2259 2386 } else { 2387 + console.log("what the hell is a ", embed); 2260 2388 return <>sorry</>; 2261 2389 } 2262 2390 //return <QuotePostRenderer record={embed.record} moderation={moderation} />; ··· 2566 2694 // = 2567 2695 if (AppBskyEmbedVideo.isView(embed)) { 2568 2696 // hls playlist 2697 + if (nopics) return; 2569 2698 const playlist = embed.playlist; 2570 2699 return ( 2571 2700 <SmartHLSPlayer
+31
src/components/placeholders/TextPlaceholder.tsx
··· 1 + import type { ReactNode } from "react"; 2 + 3 + export function TextPlaceholder({ 4 + className, 5 + pulse = false, 6 + }: { 7 + className?: string; 8 + pulse?: boolean; 9 + }) { 10 + return ( 11 + <span 12 + className={`bg-gray-100 dark:bg-gray-800 rounded h-4 ${pulse ? "animate-pulse" : ""} ${className ?? ""}`} 13 + ></span> 14 + ); 15 + } 16 + 17 + export function ConditionalTextPlaceholder({ 18 + show, 19 + placeholderClassName, 20 + children, 21 + }: { 22 + show: boolean; 23 + placeholderClassName?: string; 24 + children?: ReactNode; 25 + }) { 26 + return ( 27 + <> 28 + {show ? children : <TextPlaceholder className={placeholderClassName} />} 29 + </> 30 + ); 31 + }
-59
src/components/radix-m3-rd/Button.tsx
··· 1 - import { Slot } from "@radix-ui/react-slot"; 2 - import clsx from "clsx"; 3 - import * as React from "react"; 4 - 5 - export type ButtonVariant = "filled" | "outlined" | "text" | "secondary"; 6 - export type ButtonSize = "sm" | "md" | "lg"; 7 - 8 - const variantClasses: Record<ButtonVariant, string> = { 9 - filled: 10 - "bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500", 11 - secondary: 12 - "bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500", 13 - outlined: 14 - "border border-gray-800 text-gray-800 hover:bg-gray-100 dark:border-gray-200 dark:text-gray-200 dark:hover:bg-gray-800/10", 15 - text: "bg-transparent text-gray-800 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800/10", 16 - }; 17 - 18 - const sizeClasses: Record<ButtonSize, string> = { 19 - sm: "px-3 py-1.5 text-sm", 20 - md: "px-4 py-2 text-base", 21 - lg: "px-6 py-3 text-lg", 22 - }; 23 - 24 - export function Button({ 25 - variant = "filled", 26 - size = "md", 27 - asChild = false, 28 - ref, 29 - className, 30 - children, 31 - ...props 32 - }: { 33 - variant?: ButtonVariant; 34 - size?: ButtonSize; 35 - asChild?: boolean; 36 - className?: string; 37 - children?: React.ReactNode; 38 - ref?: React.Ref<HTMLButtonElement>; 39 - } & React.ComponentPropsWithoutRef<"button">) { 40 - const Comp = asChild ? Slot : "button"; 41 - 42 - return ( 43 - <Comp 44 - ref={ref} 45 - className={clsx( 46 - //focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-gray-500 dark:focus:ring-gray-300 47 - "inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed", 48 - variantClasses[variant], 49 - sizeClasses[size], 50 - className 51 - )} 52 - {...props} 53 - > 54 - {children} 55 - </Comp> 56 - ); 57 - } 58 - 59 - Button.displayName = "Button";
+157
src/providers/LikeMutationQueueProvider.tsx
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { TID } from "@atproto/common-web"; 3 + import { useQueryClient } from "@tanstack/react-query"; 4 + import { useAtom } from "jotai"; 5 + import React, { createContext, use, useCallback, useEffect, useRef } from "react"; 6 + 7 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 + import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms"; 9 + import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery"; 10 + 11 + export type LikeRecord = { uri: string; target: string; cid: string }; 12 + export type LikeMutation = { type: 'like'; target: string; cid: string }; 13 + export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord }; 14 + export type Mutation = LikeMutation | UnlikeMutation; 15 + 16 + interface LikeMutationQueueContextType { 17 + fastState: (target: string) => LikeRecord | null | undefined; 18 + fastToggle: (target:string, cid:string) => void; 19 + backfillState: (target: string, user: string) => Promise<void>; 20 + } 21 + 22 + const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined); 23 + 24 + export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) { 25 + const { agent } = useAuth(); 26 + const queryClient = useQueryClient(); 27 + const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom); 28 + const [constellationurl] = useAtom(constellationURLAtom); 29 + 30 + const likedPostsRef = useRef(likedPosts); 31 + useEffect(() => { 32 + likedPostsRef.current = likedPosts; 33 + }, [likedPosts]); 34 + 35 + const queueRef = useRef<Mutation[]>([]); 36 + const runningRef = useRef(false); 37 + 38 + const fastState = (target: string) => likedPosts[target]; 39 + 40 + const setFastState = useCallback( 41 + (target: string, record: LikeRecord | null) => 42 + setLikedPosts((prev) => ({ ...prev, [target]: record })), 43 + [setLikedPosts] 44 + ); 45 + 46 + const enqueue = (mutation: Mutation) => queueRef.current.push(mutation); 47 + 48 + const fastToggle = useCallback((target: string, cid: string) => { 49 + const likedRecord = likedPostsRef.current[target]; 50 + 51 + if (likedRecord) { 52 + setFastState(target, null); 53 + if (likedRecord.uri !== 'pending') { 54 + enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord }); 55 + } 56 + } else { 57 + setFastState(target, { uri: "pending", target, cid }); 58 + enqueue({ type: "like", target, cid }); 59 + } 60 + }, [setFastState]); 61 + 62 + /** 63 + * 64 + * @deprecated dont use it yet, will cause infinite rerenders 65 + */ 66 + const backfillState = async (target: string, user: string) => { 67 + const query = constructConstellationQuery({ 68 + constellation: constellationurl, 69 + method: "/links", 70 + target, 71 + collection: "app.bsky.feed.like", 72 + path: ".subject.uri", 73 + dids: [user], 74 + }); 75 + const data = await queryClient.fetchQuery(query); 76 + const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? []; 77 + const found = likes.find((r) => r.did === user); 78 + if (found) { 79 + const uri = `at://${found.did}/${found.collection}/${found.rkey}`; 80 + const ciddata = await queryClient.fetchQuery( 81 + constructArbitraryQuery(uri) 82 + ); 83 + if (ciddata?.cid) 84 + setFastState(target, { uri, target, cid: ciddata?.cid }); 85 + } else { 86 + setFastState(target, null); 87 + } 88 + }; 89 + 90 + 91 + useEffect(() => { 92 + if (!agent?.did) return; 93 + 94 + const processQueue = async () => { 95 + if (runningRef.current || queueRef.current.length === 0) return; 96 + runningRef.current = true; 97 + 98 + while (queueRef.current.length > 0) { 99 + const mutation = queueRef.current.shift()!; 100 + try { 101 + if (mutation.type === "like") { 102 + const newRecord = { 103 + repo: agent.did!, 104 + collection: "app.bsky.feed.like", 105 + rkey: TID.next().toString(), 106 + record: { 107 + $type: "app.bsky.feed.like", 108 + subject: { uri: mutation.target, cid: mutation.cid }, 109 + createdAt: new Date().toISOString(), 110 + }, 111 + }; 112 + const response = await agent.com.atproto.repo.createRecord(newRecord); 113 + if (!response.success) throw new Error("createRecord failed"); 114 + 115 + const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`; 116 + setFastState(mutation.target, { 117 + uri, 118 + target: mutation.target, 119 + cid: mutation.cid, 120 + }); 121 + } else if (mutation.type === "unlike") { 122 + const aturi = new AtUri(mutation.likeRecordUri); 123 + await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey }); 124 + setFastState(mutation.target, null); 125 + } 126 + } catch (err) { 127 + console.error("Like mutation failed, reverting:", err); 128 + if (mutation.type === 'like') { 129 + setFastState(mutation.target, null); 130 + } else if (mutation.type === 'unlike') { 131 + setFastState(mutation.target, mutation.originalRecord); 132 + } 133 + } 134 + } 135 + runningRef.current = false; 136 + }; 137 + 138 + const interval = setInterval(processQueue, 1000); 139 + return () => clearInterval(interval); 140 + }, [agent, setFastState]); 141 + 142 + const value = { fastState, fastToggle, backfillState }; 143 + 144 + return ( 145 + <LikeMutationQueueContext value={value}> 146 + {children} 147 + </LikeMutationQueueContext> 148 + ); 149 + } 150 + 151 + export function useLikeMutationQueue() { 152 + const context = use(LikeMutationQueueContext); 153 + if (context === undefined) { 154 + throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider'); 155 + } 156 + return context; 157 + }
+129
src/routeTree.gen.ts
··· 18 18 import { Route as CallbackIndexRouteImport } from './routes/callback/index' 19 19 import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout' 20 20 import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index' 21 + import { Route as ProfileDidFollowsRouteImport } from './routes/profile.$did/follows' 22 + import { Route as ProfileDidFollowersRouteImport } from './routes/profile.$did/followers' 21 23 import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' 22 24 import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a' 23 25 import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey' 26 + import { Route as ProfileDidFeedRkeyRouteImport } from './routes/profile.$did/feed.$rkey' 27 + import { Route as ProfileDidPostRkeyRepostedByRouteImport } from './routes/profile.$did/post.$rkey.reposted-by' 28 + import { Route as ProfileDidPostRkeyQuotesRouteImport } from './routes/profile.$did/post.$rkey.quotes' 29 + import { Route as ProfileDidPostRkeyLikedByRouteImport } from './routes/profile.$did/post.$rkey.liked-by' 24 30 import { Route as ProfileDidPostRkeyImageIRouteImport } from './routes/profile.$did/post.$rkey.image.$i' 25 31 26 32 const SettingsRoute = SettingsRouteImport.update({ ··· 67 73 path: '/profile/$did/', 68 74 getParentRoute: () => rootRouteImport, 69 75 } as any) 76 + const ProfileDidFollowsRoute = ProfileDidFollowsRouteImport.update({ 77 + id: '/profile/$did/follows', 78 + path: '/profile/$did/follows', 79 + getParentRoute: () => rootRouteImport, 80 + } as any) 81 + const ProfileDidFollowersRoute = ProfileDidFollowersRouteImport.update({ 82 + id: '/profile/$did/followers', 83 + path: '/profile/$did/followers', 84 + getParentRoute: () => rootRouteImport, 85 + } as any) 70 86 const PathlessLayoutNestedLayoutRouteBRoute = 71 87 PathlessLayoutNestedLayoutRouteBRouteImport.update({ 72 88 id: '/route-b', ··· 84 100 path: '/profile/$did/post/$rkey', 85 101 getParentRoute: () => rootRouteImport, 86 102 } as any) 103 + const ProfileDidFeedRkeyRoute = ProfileDidFeedRkeyRouteImport.update({ 104 + id: '/profile/$did/feed/$rkey', 105 + path: '/profile/$did/feed/$rkey', 106 + getParentRoute: () => rootRouteImport, 107 + } as any) 108 + const ProfileDidPostRkeyRepostedByRoute = 109 + ProfileDidPostRkeyRepostedByRouteImport.update({ 110 + id: '/reposted-by', 111 + path: '/reposted-by', 112 + getParentRoute: () => ProfileDidPostRkeyRoute, 113 + } as any) 114 + const ProfileDidPostRkeyQuotesRoute = 115 + ProfileDidPostRkeyQuotesRouteImport.update({ 116 + id: '/quotes', 117 + path: '/quotes', 118 + getParentRoute: () => ProfileDidPostRkeyRoute, 119 + } as any) 120 + const ProfileDidPostRkeyLikedByRoute = 121 + ProfileDidPostRkeyLikedByRouteImport.update({ 122 + id: '/liked-by', 123 + path: '/liked-by', 124 + getParentRoute: () => ProfileDidPostRkeyRoute, 125 + } as any) 87 126 const ProfileDidPostRkeyImageIRoute = 88 127 ProfileDidPostRkeyImageIRouteImport.update({ 89 128 id: '/image/$i', ··· 100 139 '/callback': typeof CallbackIndexRoute 101 140 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 102 141 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 142 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 143 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 103 144 '/profile/$did': typeof ProfileDidIndexRoute 145 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 104 146 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 147 + '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 148 + '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute 149 + '/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute 105 150 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 106 151 } 107 152 export interface FileRoutesByTo { ··· 113 158 '/callback': typeof CallbackIndexRoute 114 159 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 115 160 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 161 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 162 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 116 163 '/profile/$did': typeof ProfileDidIndexRoute 164 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 117 165 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 166 + '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 167 + '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute 168 + '/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute 118 169 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 119 170 } 120 171 export interface FileRoutesById { ··· 129 180 '/callback/': typeof CallbackIndexRoute 130 181 '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 131 182 '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 183 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 184 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 132 185 '/profile/$did/': typeof ProfileDidIndexRoute 186 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 133 187 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 188 + '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 189 + '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute 190 + '/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute 134 191 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 135 192 } 136 193 export interface FileRouteTypes { ··· 144 201 | '/callback' 145 202 | '/route-a' 146 203 | '/route-b' 204 + | '/profile/$did/followers' 205 + | '/profile/$did/follows' 147 206 | '/profile/$did' 207 + | '/profile/$did/feed/$rkey' 148 208 | '/profile/$did/post/$rkey' 209 + | '/profile/$did/post/$rkey/liked-by' 210 + | '/profile/$did/post/$rkey/quotes' 211 + | '/profile/$did/post/$rkey/reposted-by' 149 212 | '/profile/$did/post/$rkey/image/$i' 150 213 fileRoutesByTo: FileRoutesByTo 151 214 to: ··· 157 220 | '/callback' 158 221 | '/route-a' 159 222 | '/route-b' 223 + | '/profile/$did/followers' 224 + | '/profile/$did/follows' 160 225 | '/profile/$did' 226 + | '/profile/$did/feed/$rkey' 161 227 | '/profile/$did/post/$rkey' 228 + | '/profile/$did/post/$rkey/liked-by' 229 + | '/profile/$did/post/$rkey/quotes' 230 + | '/profile/$did/post/$rkey/reposted-by' 162 231 | '/profile/$did/post/$rkey/image/$i' 163 232 id: 164 233 | '__root__' ··· 172 241 | '/callback/' 173 242 | '/_pathlessLayout/_nested-layout/route-a' 174 243 | '/_pathlessLayout/_nested-layout/route-b' 244 + | '/profile/$did/followers' 245 + | '/profile/$did/follows' 175 246 | '/profile/$did/' 247 + | '/profile/$did/feed/$rkey' 176 248 | '/profile/$did/post/$rkey' 249 + | '/profile/$did/post/$rkey/liked-by' 250 + | '/profile/$did/post/$rkey/quotes' 251 + | '/profile/$did/post/$rkey/reposted-by' 177 252 | '/profile/$did/post/$rkey/image/$i' 178 253 fileRoutesById: FileRoutesById 179 254 } ··· 185 260 SearchRoute: typeof SearchRoute 186 261 SettingsRoute: typeof SettingsRoute 187 262 CallbackIndexRoute: typeof CallbackIndexRoute 263 + ProfileDidFollowersRoute: typeof ProfileDidFollowersRoute 264 + ProfileDidFollowsRoute: typeof ProfileDidFollowsRoute 188 265 ProfileDidIndexRoute: typeof ProfileDidIndexRoute 266 + ProfileDidFeedRkeyRoute: typeof ProfileDidFeedRkeyRoute 189 267 ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren 190 268 } 191 269 ··· 254 332 preLoaderRoute: typeof ProfileDidIndexRouteImport 255 333 parentRoute: typeof rootRouteImport 256 334 } 335 + '/profile/$did/follows': { 336 + id: '/profile/$did/follows' 337 + path: '/profile/$did/follows' 338 + fullPath: '/profile/$did/follows' 339 + preLoaderRoute: typeof ProfileDidFollowsRouteImport 340 + parentRoute: typeof rootRouteImport 341 + } 342 + '/profile/$did/followers': { 343 + id: '/profile/$did/followers' 344 + path: '/profile/$did/followers' 345 + fullPath: '/profile/$did/followers' 346 + preLoaderRoute: typeof ProfileDidFollowersRouteImport 347 + parentRoute: typeof rootRouteImport 348 + } 257 349 '/_pathlessLayout/_nested-layout/route-b': { 258 350 id: '/_pathlessLayout/_nested-layout/route-b' 259 351 path: '/route-b' ··· 275 367 preLoaderRoute: typeof ProfileDidPostRkeyRouteImport 276 368 parentRoute: typeof rootRouteImport 277 369 } 370 + '/profile/$did/feed/$rkey': { 371 + id: '/profile/$did/feed/$rkey' 372 + path: '/profile/$did/feed/$rkey' 373 + fullPath: '/profile/$did/feed/$rkey' 374 + preLoaderRoute: typeof ProfileDidFeedRkeyRouteImport 375 + parentRoute: typeof rootRouteImport 376 + } 377 + '/profile/$did/post/$rkey/reposted-by': { 378 + id: '/profile/$did/post/$rkey/reposted-by' 379 + path: '/reposted-by' 380 + fullPath: '/profile/$did/post/$rkey/reposted-by' 381 + preLoaderRoute: typeof ProfileDidPostRkeyRepostedByRouteImport 382 + parentRoute: typeof ProfileDidPostRkeyRoute 383 + } 384 + '/profile/$did/post/$rkey/quotes': { 385 + id: '/profile/$did/post/$rkey/quotes' 386 + path: '/quotes' 387 + fullPath: '/profile/$did/post/$rkey/quotes' 388 + preLoaderRoute: typeof ProfileDidPostRkeyQuotesRouteImport 389 + parentRoute: typeof ProfileDidPostRkeyRoute 390 + } 391 + '/profile/$did/post/$rkey/liked-by': { 392 + id: '/profile/$did/post/$rkey/liked-by' 393 + path: '/liked-by' 394 + fullPath: '/profile/$did/post/$rkey/liked-by' 395 + preLoaderRoute: typeof ProfileDidPostRkeyLikedByRouteImport 396 + parentRoute: typeof ProfileDidPostRkeyRoute 397 + } 278 398 '/profile/$did/post/$rkey/image/$i': { 279 399 id: '/profile/$did/post/$rkey/image/$i' 280 400 path: '/image/$i' ··· 316 436 ) 317 437 318 438 interface ProfileDidPostRkeyRouteChildren { 439 + ProfileDidPostRkeyLikedByRoute: typeof ProfileDidPostRkeyLikedByRoute 440 + ProfileDidPostRkeyQuotesRoute: typeof ProfileDidPostRkeyQuotesRoute 441 + ProfileDidPostRkeyRepostedByRoute: typeof ProfileDidPostRkeyRepostedByRoute 319 442 ProfileDidPostRkeyImageIRoute: typeof ProfileDidPostRkeyImageIRoute 320 443 } 321 444 322 445 const ProfileDidPostRkeyRouteChildren: ProfileDidPostRkeyRouteChildren = { 446 + ProfileDidPostRkeyLikedByRoute: ProfileDidPostRkeyLikedByRoute, 447 + ProfileDidPostRkeyQuotesRoute: ProfileDidPostRkeyQuotesRoute, 448 + ProfileDidPostRkeyRepostedByRoute: ProfileDidPostRkeyRepostedByRoute, 323 449 ProfileDidPostRkeyImageIRoute: ProfileDidPostRkeyImageIRoute, 324 450 } 325 451 ··· 334 460 SearchRoute: SearchRoute, 335 461 SettingsRoute: SettingsRoute, 336 462 CallbackIndexRoute: CallbackIndexRoute, 463 + ProfileDidFollowersRoute: ProfileDidFollowersRoute, 464 + ProfileDidFollowsRoute: ProfileDidFollowsRoute, 337 465 ProfileDidIndexRoute: ProfileDidIndexRoute, 466 + ProfileDidFeedRkeyRoute: ProfileDidFeedRkeyRoute, 338 467 ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren, 339 468 } 340 469 export const routeTree = rootRouteImport
+8 -5
src/routes/__root.tsx
··· 22 22 import Login from "~/components/Login"; 23 23 import { NotFound } from "~/components/NotFound"; 24 24 import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; 25 + import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; 25 26 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 26 27 import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms"; 27 28 import { seo } from "~/utils/seo"; ··· 79 80 function RootComponent() { 80 81 return ( 81 82 <UnifiedAuthProvider> 82 - <RootDocument> 83 - <KeepAliveProvider> 84 - <KeepAliveOutlet /> 85 - </KeepAliveProvider> 86 - </RootDocument> 83 + <LikeMutationQueueProvider> 84 + <RootDocument> 85 + <KeepAliveProvider> 86 + <KeepAliveOutlet /> 87 + </KeepAliveProvider> 88 + </RootDocument> 89 + </LikeMutationQueueProvider> 87 90 </UnifiedAuthProvider> 88 91 ); 89 92 }
+44 -33
src/routes/index.tsx
··· 359 359 > 360 360 {!isAuthRestoring && savedFeeds.length > 0 ? ( 361 361 <div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}> 362 - {savedFeeds.map((item: any, idx: number) => { 363 - const label = item.value.split("/").pop() || item.value; 364 - const isActive = selectedFeed === item.value; 365 - return ( 366 - <button 367 - key={item.value || idx} 368 - className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${ 369 - isActive 370 - ? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600" 371 - : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800" 372 - // ? "bg-gray-500 text-white" 373 - // : item.pinned 374 - // ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200" 375 - // : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200" 376 - }`} 377 - onClick={() => setSelectedFeed(item.value)} 378 - title={item.value} 379 - > 380 - {label} 381 - {item.pinned && ( 382 - <span 383 - className={`ml-1 text-xs ${ 384 - isActive 385 - ? "text-gray-900 dark:text-gray-100" 386 - : "text-gray-600 dark:text-gray-400" 387 - }`} 388 - > 389 - โ˜… 390 - </span> 391 - )} 392 - </button> 393 - ); 394 - })} 362 + {savedFeeds.map((item: any, idx: number) => {return <FeedTabOnTop key={item} item={item} idx={idx} />})} 395 363 </div> 396 364 ) : ( 397 365 // <span className="text-xl font-bold ml-2">Home</span> ··· 435 403 </div> 436 404 ); 437 405 } 406 + 407 + 408 + // todo please use types this is dangerous very dangerous. 409 + // todo fix this whenever proper preferences is handled 410 + function FeedTabOnTop({item, idx}:{item: any, idx: number}) { 411 + const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom); 412 + const selectedFeed = persistentSelectedFeed 413 + const setSelectedFeed = setPersistentSelectedFeed 414 + const rkey = item.value.split("/").pop() || item.value; 415 + const isActive = selectedFeed === item.value; 416 + const { data: feedrecord } = useQueryArbitrary(item.value) 417 + const label = feedrecord?.value?.displayName || rkey 418 + return ( 419 + <button 420 + key={item.value || idx} 421 + className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${ 422 + isActive 423 + ? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600" 424 + : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800" 425 + // ? "bg-gray-500 text-white" 426 + // : item.pinned 427 + // ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200" 428 + // : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200" 429 + }`} 430 + onClick={() => setSelectedFeed(item.value)} 431 + title={item.value} 432 + > 433 + {label} 434 + {item.pinned && ( 435 + <span 436 + className={`ml-1 text-xs ${ 437 + isActive 438 + ? "text-gray-900 dark:text-gray-100" 439 + : "text-gray-600 dark:text-gray-400" 440 + }`} 441 + > 442 + โ˜… 443 + </span> 444 + )} 445 + </button> 446 + ); 447 + } 448 + 438 449 // not even used lmaooo 439 450 440 451 // export async function cachedResolveDIDWEBDOC({
+645 -161
src/routes/notifications.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 1 + import { AtUri } from "@atproto/api"; 2 + import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 2 4 import { useAtom } from "jotai"; 3 - import React, { useEffect, useRef,useState } from "react"; 5 + import * as React from "react"; 4 6 7 + import defaultpfp from "~/../public/favicon.png"; 8 + import { Header } from "~/components/Header"; 9 + import { 10 + ReusableTabRoute, 11 + useReusableTabScrollRestore, 12 + } from "~/components/ReusableTabRoute"; 13 + import { 14 + MdiCardsHeartOutline, 15 + MdiCommentOutline, 16 + MdiRepeat, 17 + UniversalPostRendererATURILoader, 18 + } from "~/components/UniversalPostRenderer"; 5 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 6 - import { constellationURLAtom } from "~/utils/atoms"; 20 + import { 21 + constellationURLAtom, 22 + enableBitesAtom, 23 + imgCDNAtom, 24 + postInteractionsFiltersAtom, 25 + } from "~/utils/atoms"; 26 + import { 27 + useInfiniteQueryAuthorFeed, 28 + useQueryConstellation, 29 + useQueryIdentity, 30 + useQueryProfile, 31 + yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 32 + } from "~/utils/useQuery"; 33 + 34 + import { FollowButton, Mutual } from "./profile.$did"; 35 + import { ConditionalTextPlaceholder } from "~/components/placeholders/TextPlaceholder"; 7 36 8 - const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 37 + export function NotificationsComponent() { 38 + return ( 39 + <div className=""> 40 + <Header 41 + title={`Notifications`} 42 + backButtonCallback={() => { 43 + if (window.history.length > 1) { 44 + window.history.back(); 45 + } else { 46 + window.location.assign("/"); 47 + } 48 + }} 49 + bottomBorderDisabled={true} 50 + /> 51 + <NotificationsTabs /> 52 + </div> 53 + ); 54 + } 9 55 10 56 export const Route = createFileRoute("/notifications")({ 11 - component: NotificationsComponent, 57 + component: NotificationsComponent, 12 58 }); 13 59 14 - function NotificationsComponent() { 15 - // /*mass comment*/ console.log("NotificationsComponent render"); 16 - const { agent, status } = useAuth(); 17 - const authed = !!agent?.did; 18 - const authLoading = status === "loading"; 19 - const [did, setDid] = useState<string | null>(null); 20 - const [resolving, setResolving] = useState(false); 21 - const [error, setError] = useState<string | null>(null); 22 - const [responses, setResponses] = useState<any[]>([null, null, null]); 23 - const [loading, setLoading] = useState(false); 24 - const inputRef = useRef<HTMLInputElement>(null); 60 + export default function NotificationsTabs() { 61 + const [bitesEnabled] = useAtom(enableBitesAtom); 62 + return ( 63 + <ReusableTabRoute 64 + route={`Notifications`} 65 + tabs={{ 66 + Mentions: <MentionsTab />, 67 + Follows: <FollowsTab />, 68 + "Post Interactions": <PostInteractionsTab />, 69 + ...(bitesEnabled 70 + ? { 71 + Bites: <BitesTab />, 72 + } 73 + : {}), 74 + }} 75 + /> 76 + ); 77 + } 78 + 79 + function MentionsTab() { 80 + const { agent } = useAuth(); 81 + const [constellationurl] = useAtom(constellationURLAtom); 82 + const infinitequeryresults = useInfiniteQuery({ 83 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 84 + { 85 + constellation: constellationurl, 86 + method: "/links", 87 + target: agent?.did, 88 + collection: "app.bsky.feed.post", 89 + path: ".facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did", 90 + } 91 + ), 92 + enabled: !!agent?.did, 93 + }); 94 + 95 + const { 96 + data: infiniteMentionsData, 97 + fetchNextPage, 98 + hasNextPage, 99 + isFetchingNextPage, 100 + isLoading, 101 + isError, 102 + error, 103 + } = infinitequeryresults; 104 + 105 + const mentionsAturis = React.useMemo(() => { 106 + // Get all replies from the standard infinite query 107 + return ( 108 + infiniteMentionsData?.pages.flatMap( 109 + (page) => 110 + page?.linking_records.map( 111 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 112 + ) ?? [] 113 + ) ?? [] 114 + ); 115 + }, [infiniteMentionsData]); 116 + 117 + useReusableTabScrollRestore("Notifications"); 118 + 119 + if (isLoading) return <LoadingState text="Loading mentions..." />; 120 + if (isError) return <ErrorState error={error} />; 121 + 122 + if (!mentionsAturis?.length) return <EmptyState text="No mentions yet." />; 123 + 124 + return ( 125 + <> 126 + {mentionsAturis.map((m) => ( 127 + <UniversalPostRendererATURILoader key={m} atUri={m} /> 128 + ))} 129 + 130 + {hasNextPage && ( 131 + <button 132 + onClick={() => fetchNextPage()} 133 + disabled={isFetchingNextPage} 134 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 135 + > 136 + {isFetchingNextPage ? "Loading..." : "Load More"} 137 + </button> 138 + )} 139 + </> 140 + ); 141 + } 142 + 143 + export function FollowsTab({ did }: { did?: string }) { 144 + const { agent } = useAuth(); 145 + const userdidunsafe = did ?? agent?.did; 146 + const { data: identity } = useQueryIdentity(userdidunsafe); 147 + const userdid = identity?.did; 148 + 149 + const [constellationurl] = useAtom(constellationURLAtom); 150 + const infinitequeryresults = useInfiniteQuery({ 151 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 152 + { 153 + constellation: constellationurl, 154 + method: "/links", 155 + target: userdid, 156 + collection: "app.bsky.graph.follow", 157 + path: ".subject", 158 + } 159 + ), 160 + enabled: !!userdid, 161 + }); 162 + 163 + const { 164 + data: infiniteFollowsData, 165 + fetchNextPage, 166 + hasNextPage, 167 + isFetchingNextPage, 168 + isLoading, 169 + isError, 170 + error, 171 + } = infinitequeryresults; 172 + 173 + const followsAturis = React.useMemo(() => { 174 + // Get all replies from the standard infinite query 175 + return ( 176 + infiniteFollowsData?.pages.flatMap( 177 + (page) => 178 + page?.linking_records.map( 179 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 180 + ) ?? [] 181 + ) ?? [] 182 + ); 183 + }, [infiniteFollowsData]); 184 + 185 + useReusableTabScrollRestore("Notifications"); 186 + 187 + if (isLoading) return <LoadingState text="Loading follows..." />; 188 + if (isError) return <ErrorState error={error} />; 189 + 190 + if (!followsAturis?.length) return <EmptyState text="No follows yet." />; 191 + 192 + return ( 193 + <> 194 + {followsAturis.map((m) => ( 195 + <NotificationItem key={m} notification={m} /> 196 + ))} 197 + 198 + {hasNextPage && ( 199 + <button 200 + onClick={() => fetchNextPage()} 201 + disabled={isFetchingNextPage} 202 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 203 + > 204 + {isFetchingNextPage ? "Loading..." : "Load More"} 205 + </button> 206 + )} 207 + </> 208 + ); 209 + } 210 + 211 + export function BitesTab({ did }: { did?: string }) { 212 + const { agent } = useAuth(); 213 + const userdidunsafe = did ?? agent?.did; 214 + const { data: identity } = useQueryIdentity(userdidunsafe); 215 + const userdid = identity?.did; 216 + 217 + const [constellationurl] = useAtom(constellationURLAtom); 218 + const infinitequeryresults = useInfiniteQuery({ 219 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 220 + { 221 + constellation: constellationurl, 222 + method: "/links", 223 + target: "at://" + userdid, 224 + collection: "net.wafrn.feed.bite", 225 + path: ".subject", 226 + staleMult: 0, // safe fun 227 + } 228 + ), 229 + enabled: !!userdid, 230 + }); 231 + 232 + const { 233 + data: infiniteFollowsData, 234 + fetchNextPage, 235 + hasNextPage, 236 + isFetchingNextPage, 237 + isLoading, 238 + isError, 239 + error, 240 + } = infinitequeryresults; 241 + 242 + const followsAturis = React.useMemo(() => { 243 + // Get all replies from the standard infinite query 244 + return ( 245 + infiniteFollowsData?.pages.flatMap( 246 + (page) => 247 + page?.linking_records.map( 248 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 249 + ) ?? [] 250 + ) ?? [] 251 + ); 252 + }, [infiniteFollowsData]); 253 + 254 + useReusableTabScrollRestore("Notifications"); 25 255 26 - useEffect(() => { 27 - if (authLoading) return; 28 - if (authed && agent && agent.assertDid) { 29 - setDid(agent.assertDid); 30 - } 31 - }, [authed, agent, authLoading]); 256 + if (isLoading) return <LoadingState text="Loading bites..." />; 257 + if (isError) return <ErrorState error={error} />; 32 258 33 - async function handleSubmit() { 34 - // /*mass comment*/ console.log("handleSubmit called"); 35 - setError(null); 36 - setResponses([null, null, null]); 37 - const value = inputRef.current?.value?.trim() || ""; 38 - if (!value) return; 39 - if (value.startsWith("did:")) { 40 - setDid(value); 41 - setError(null); 42 - return; 43 - } 44 - setResolving(true); 45 - const cacheKey = `handleDid:${value}`; 46 - const now = Date.now(); 47 - const cached = undefined // await get(cacheKey); 48 - // if ( 49 - // cached && 50 - // cached.value && 51 - // cached.time && 52 - // now - cached.time < HANDLE_DID_CACHE_TIMEOUT 53 - // ) { 54 - // try { 55 - // const data = JSON.parse(cached.value); 56 - // setDid(data.did); 57 - // setResolving(false); 58 - // return; 59 - // } catch {} 60 - // } 61 - try { 62 - const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(value)}`; 63 - const res = await fetch(url); 64 - if (!res.ok) throw new Error("Failed to resolve handle"); 65 - const data = await res.json(); 66 - //set(cacheKey, JSON.stringify(data)); 67 - setDid(data.did); 68 - } catch (e: any) { 69 - setError("Failed to resolve handle: " + (e?.message || e)); 70 - } finally { 71 - setResolving(false); 72 - } 73 - } 259 + if (!followsAturis?.length) return <EmptyState text="No bites yet." />; 74 260 75 - const [constellationURL] = useAtom(constellationURLAtom) 261 + return ( 262 + <> 263 + {followsAturis.map((m) => ( 264 + <NotificationItem key={m} notification={m} /> 265 + ))} 76 266 77 - useEffect(() => { 78 - if (!did) return; 79 - setLoading(true); 80 - setError(null); 81 - const urls = [ 82 - `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`, 83 - `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`, 84 - `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`, 85 - ]; 86 - let ignore = false; 87 - Promise.all( 88 - urls.map(async (url) => { 89 - try { 90 - const r = await fetch(url); 91 - if (!r.ok) throw new Error("Failed to fetch"); 92 - const text = await r.text(); 93 - if (!text) return null; 94 - try { 95 - return JSON.parse(text); 96 - } catch { 97 - return null; 98 - } 99 - } catch (e: any) { 100 - return { error: e?.message || String(e) }; 101 - } 102 - }) 103 - ) 104 - .then((results) => { 105 - if (!ignore) setResponses(results); 106 - }) 107 - .catch((e) => { 108 - if (!ignore) 109 - setError("Failed to fetch notifications: " + (e?.message || e)); 110 - }) 111 - .finally(() => { 112 - if (!ignore) setLoading(false); 113 - }); 114 - return () => { 115 - ignore = true; 116 - }; 117 - }, [did]); 267 + {hasNextPage && ( 268 + <button 269 + onClick={() => fetchNextPage()} 270 + disabled={isFetchingNextPage} 271 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 272 + > 273 + {isFetchingNextPage ? "Loading..." : "Load More"} 274 + </button> 275 + )} 276 + </> 277 + ); 278 + } 118 279 119 - return ( 120 - <div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800"> 121 - <div className="flex items-center gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-800"> 122 - <span className="text-xl font-bold ml-2">Notifications</span> 123 - {!authed && ( 124 - <div className="flex items-center gap-2"> 125 - <input 126 - type="text" 127 - placeholder="Enter handle or DID" 128 - ref={inputRef} 129 - className="ml-4 px-2 py-1 rounded border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100" 130 - style={{ minWidth: 220 }} 131 - disabled={resolving} 132 - /> 133 - <button 134 - type="button" 135 - className="px-3 py-1 rounded bg-blue-600 text-white font-semibold disabled:opacity-50" 136 - disabled={resolving} 137 - onClick={handleSubmit} 138 - > 139 - {resolving ? "Resolving..." : "Submit"} 140 - </button> 141 - </div> 280 + function PostInteractionsTab() { 281 + const { agent } = useAuth(); 282 + const { data: identity } = useQueryIdentity(agent?.did); 283 + const queryClient = useQueryClient(); 284 + const { 285 + data: postsData, 286 + fetchNextPage, 287 + hasNextPage, 288 + isFetchingNextPage, 289 + isLoading: arePostsLoading, 290 + } = useInfiniteQueryAuthorFeed(agent?.did, identity?.pds); 291 + 292 + React.useEffect(() => { 293 + if (postsData) { 294 + postsData.pages.forEach((page) => { 295 + page.records.forEach((record) => { 296 + if (!queryClient.getQueryData(["post", record.uri])) { 297 + queryClient.setQueryData(["post", record.uri], record); 298 + } 299 + }); 300 + }); 301 + } 302 + }, [postsData, queryClient]); 303 + 304 + const posts = React.useMemo( 305 + () => postsData?.pages.flatMap((page) => page.records) ?? [], 306 + [postsData] 307 + ); 308 + 309 + useReusableTabScrollRestore("Notifications"); 310 + 311 + const [filters] = useAtom(postInteractionsFiltersAtom); 312 + const empty = 313 + !filters.likes && !filters.quotes && !filters.replies && !filters.reposts; 314 + 315 + return ( 316 + <> 317 + <PostInteractionsFilterChipBar /> 318 + {!empty && 319 + posts.map((m) => <PostInteractionsItem key={m.uri} uri={m.uri} />)} 320 + 321 + {hasNextPage && ( 322 + <button 323 + onClick={() => fetchNextPage()} 324 + disabled={isFetchingNextPage} 325 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 326 + > 327 + {isFetchingNextPage ? "Loading..." : "Load More"} 328 + </button> 329 + )} 330 + </> 331 + ); 332 + } 333 + 334 + function PostInteractionsFilterChipBar() { 335 + const [filters, setFilters] = useAtom(postInteractionsFiltersAtom); 336 + // const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts); 337 + 338 + // useEffect(() => { 339 + // if (empty) { 340 + // setFilters((prev) => ({ 341 + // ...prev, 342 + // likes: true, 343 + // })); 344 + // } 345 + // }, [ 346 + // empty, 347 + // setFilters, 348 + // ]); 349 + 350 + const toggle = (key: keyof typeof filters) => { 351 + setFilters((prev) => ({ 352 + ...prev, 353 + [key]: !prev[key], 354 + })); 355 + }; 356 + 357 + return ( 358 + <div className="flex flex-row flex-wrap gap-2 px-4 pt-4"> 359 + <Chip 360 + state={filters.likes} 361 + text="Likes" 362 + onClick={() => toggle("likes")} 363 + /> 364 + <Chip 365 + state={filters.reposts} 366 + text="Reposts" 367 + onClick={() => toggle("reposts")} 368 + /> 369 + <Chip 370 + state={filters.replies} 371 + text="Replies" 372 + onClick={() => toggle("replies")} 373 + /> 374 + <Chip 375 + state={filters.quotes} 376 + text="Quotes" 377 + onClick={() => toggle("quotes")} 378 + /> 379 + <Chip 380 + state={filters.showAll} 381 + text="Show All Metrics" 382 + onClick={() => toggle("showAll")} 383 + /> 384 + </div> 385 + ); 386 + } 387 + 388 + export function Chip({ 389 + state, 390 + text, 391 + onClick, 392 + }: { 393 + state: boolean; 394 + text: string; 395 + onClick: React.MouseEventHandler<HTMLButtonElement>; 396 + }) { 397 + return ( 398 + <button 399 + onClick={onClick} 400 + className={`relative inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-all 401 + ${ 402 + state 403 + ? "bg-primary/20 text-primary bg-gray-200 dark:bg-gray-800 border border-transparent" 404 + : "bg-surface-container-low text-on-surface-variant border border-outline" 405 + } 406 + hover:bg-primary/30 active:scale-[0.97] 407 + dark:border-outline-variant 408 + `} 409 + > 410 + {state && ( 411 + <IconMdiCheck 412 + className="mr-1.5 inline-block w-4 h-4 rounded-full bg-primary" 413 + aria-hidden 414 + /> 415 + )} 416 + {text} 417 + </button> 418 + ); 419 + } 420 + 421 + function PostInteractionsItem({ uri }: { uri: string }) { 422 + const [filters] = useAtom(postInteractionsFiltersAtom); 423 + const { data: links } = useQueryConstellation({ 424 + method: "/links/all", 425 + target: uri, 426 + }); 427 + 428 + const likes = 429 + links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0; 430 + const replies = 431 + links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0; 432 + const reposts = 433 + links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0; 434 + const quotes1 = 435 + links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0; 436 + const quotes2 = 437 + links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"] 438 + ?.records || 0; 439 + const quotes = quotes1 + quotes2; 440 + 441 + const all = likes + replies + reposts + quotes; 442 + 443 + //const failLikes = filters.likes && likes < 1; 444 + //const failReposts = filters.reposts && reposts < 1; 445 + //const failReplies = filters.replies && replies < 1; 446 + //const failQuotes = filters.quotes && quotes < 1; 447 + 448 + const showLikes = filters.showAll || filters.likes; 449 + const showReposts = filters.showAll || filters.reposts; 450 + const showReplies = filters.showAll || filters.replies; 451 + const showQuotes = filters.showAll || filters.quotes; 452 + 453 + //const showNone = !showLikes && !showReposts && !showReplies && !showQuotes; 454 + 455 + //const fail = failLikes || failReposts || failReplies || failQuotes || showNone; 456 + 457 + const matchesLikes = filters.likes && likes > 0; 458 + const matchesReposts = filters.reposts && reposts > 0; 459 + const matchesReplies = filters.replies && replies > 0; 460 + const matchesQuotes = filters.quotes && quotes > 0; 461 + 462 + const matchesAnything = 463 + // filters.showAll || 464 + matchesLikes || matchesReposts || matchesReplies || matchesQuotes; 465 + 466 + if (!matchesAnything) return null; 467 + 468 + //if (fail) return; 469 + 470 + return ( 471 + <div className="flex flex-col"> 472 + {/* <span>fail likes {failLikes ? "true" : "false"}</span> 473 + <span>fail repost {failReposts ? "true" : "false"}</span> 474 + <span>fail reply {failReplies ? "true" : "false"}</span> 475 + <span>fail qupte {failQuotes ? "true" : "false"}</span> */} 476 + <div className="border rounded-xl mx-4 mt-4 overflow-hidden"> 477 + <UniversalPostRendererATURILoader 478 + isQuote 479 + key={uri} 480 + atUri={uri} 481 + nopics={true} 482 + concise={true} 483 + /> 484 + <div className="flex flex-col divide-x"> 485 + {showLikes && ( 486 + <InteractionsButton type={"like"} uri={uri} count={likes} /> 487 + )} 488 + {showReposts && ( 489 + <InteractionsButton type={"repost"} uri={uri} count={reposts} /> 490 + )} 491 + {showReplies && ( 492 + <InteractionsButton type={"reply"} uri={uri} count={replies} /> 493 + )} 494 + {showQuotes && ( 495 + <InteractionsButton type={"quote"} uri={uri} count={quotes} /> 496 + )} 497 + {!all && ( 498 + <div className="text-center text-gray-500 dark:text-gray-400 pb-3 pt-2 border-t"> 499 + No interactions yet. 500 + </div> 501 + )} 502 + </div> 503 + </div> 504 + </div> 505 + ); 506 + } 507 + 508 + function InteractionsButton({ 509 + type, 510 + uri, 511 + count, 512 + }: { 513 + type: "reply" | "repost" | "like" | "quote"; 514 + uri: string; 515 + count: number; 516 + }) { 517 + if (!count) return <></>; 518 + const aturi = new AtUri(uri); 519 + return ( 520 + <Link 521 + to={ 522 + `/profile/$did/post/$rkey` + 523 + (type === "like" 524 + ? "/liked-by" 525 + : type === "repost" 526 + ? "/reposted-by" 527 + : type === "quote" 528 + ? "/quotes" 529 + : "") 530 + } 531 + params={{ 532 + did: aturi.host, 533 + rkey: aturi.rkey, 534 + }} 535 + className="flex-1 border-t py-2 px-4 flex flex-row items-center gap-2 transition-colors hover:bg-gray-100 hover:dark:bg-gray-800" 536 + > 537 + {type === "like" ? ( 538 + <MdiCardsHeartOutline height={22} width={22} /> 539 + ) : type === "repost" ? ( 540 + <MdiRepeat height={22} width={22} /> 541 + ) : type === "reply" ? ( 542 + <MdiCommentOutline height={22} width={22} /> 543 + ) : type === "quote" ? ( 544 + <IconMdiMessageReplyTextOutline 545 + height={22} 546 + width={22} 547 + className=" text-gray-400" 548 + /> 549 + ) : ( 550 + <></> 551 + )} 552 + {type === "like" 553 + ? "likes" 554 + : type === "reply" 555 + ? "replies" 556 + : type === "quote" 557 + ? "quotes" 558 + : type === "repost" 559 + ? "reposts" 560 + : ""} 561 + <div className="flex-1" /> {count} 562 + </Link> 563 + ); 564 + } 565 + 566 + export function NotificationItem({ notification }: { notification: string }) { 567 + const aturi = new AtUri(notification); 568 + const bite = aturi.collection === "net.wafrn.feed.bite"; 569 + const navigate = useNavigate(); 570 + const { data: identity } = useQueryIdentity(aturi.host); 571 + const resolvedDid = identity?.did; 572 + const profileUri = resolvedDid 573 + ? `at://${resolvedDid}/app.bsky.actor.profile/self` 574 + : undefined; 575 + const { data: profileRecord } = useQueryProfile(profileUri); 576 + const profile = profileRecord?.value; 577 + 578 + const [imgcdn] = useAtom(imgCDNAtom); 579 + 580 + function getAvatarUrl(p: typeof profile) { 581 + const link = p?.avatar?.ref?.["$link"]; 582 + if (!link || !resolvedDid) return null; 583 + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 584 + } 585 + 586 + const avatar = getAvatarUrl(profile); 587 + 588 + return ( 589 + <div 590 + className="flex items-center p-4 cursor-pointer gap-3 justify-around border-b flex-row" 591 + onClick={() => 592 + aturi && 593 + navigate({ 594 + to: "/profile/$did", 595 + params: { did: aturi.host }, 596 + }) 597 + } 598 + > 599 + {/* <div> 600 + {aturi.collection === "app.bsky.graph.follow" ? ( 601 + <IconMdiAccountPlus /> 602 + ) : aturi.collection === "app.bsky.feed.like" ? ( 603 + <MdiCardsHeart /> 604 + ) : ( 605 + <></> 142 606 )} 143 - </div> 144 - {error && <div className="p-4 text-red-500">{error}</div>} 145 - {loading && ( 146 - <div className="p-4 text-gray-500">Loading notifications...</div> 147 - )} 148 - {!loading && 149 - !error && 150 - responses.map((resp, i) => ( 151 - <div key={i} className="p-4"> 152 - <div className="font-bold mb-2">Query {i + 1}</div> 153 - {!resp || 154 - (typeof resp === "object" && Object.keys(resp).length === 0) || 155 - (Array.isArray(resp) && resp.length === 0) ? ( 156 - <div className="text-gray-500">No notifications found.</div> 157 - ) : ( 158 - <pre 159 - style={{ 160 - background: "#222", 161 - color: "#eee", 162 - borderRadius: 8, 163 - padding: 12, 164 - fontSize: 13, 165 - overflowX: "auto", 166 - }} 167 - > 168 - {JSON.stringify(resp, null, 2)} 169 - </pre> 170 - )} 171 - </div> 172 - ))} 173 - {/* <div className="p-4"> yo this project sucks, ill remake it some other time, like cmon inputting anything into the textbox makes it break. ive warned you</div> */} 174 - </div> 175 - ); 607 + </div> */} 608 + {profile ? ( 609 + <img 610 + src={avatar || defaultpfp} 611 + alt={identity?.handle} 612 + className="w-10 h-10 rounded-full" 613 + /> 614 + ) : ( 615 + <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" /> 616 + )} 617 + <div className="flex flex-col min-w-0 flex-grow"> 618 + <div className="flex flex-row gap-2 items-center overflow-hidden text-ellipsis whitespace-nowrap min-w-0"> 619 + <ConditionalTextPlaceholder 620 + show={profile != undefined} 621 + placeholderClassName="flex-grow" 622 + > 623 + <span className="font-medium text-gray-900 dark:text-gray-100 truncate"> 624 + {profile?.displayName || identity?.handle} 625 + </span> 626 + </ConditionalTextPlaceholder> 627 + <span className="text-gray-700 dark:text-gray-400 truncate"> 628 + @{identity?.handle} 629 + </span> 630 + </div> 631 + <div className="flex flex-row gap-2"> 632 + {identity?.did && <Mutual targetdidorhandle={identity?.did} />} 633 + {/* <span className="text-sm text-gray-600 dark:text-gray-400"> 634 + followed you 635 + </span> */} 636 + </div> 637 + </div> 638 + <div className="flex-1" /> 639 + {identity?.did && <FollowButton targetdidorhandle={identity?.did} />} 640 + </div> 641 + ); 176 642 } 643 + 644 + export const EmptyState = ({ text }: { text: string }) => ( 645 + <div className="py-10 text-center text-gray-500 dark:text-gray-400"> 646 + {text} 647 + </div> 648 + ); 649 + 650 + export const LoadingState = ({ text }: { text: string }) => ( 651 + <div className="py-10 text-center text-gray-500 dark:text-gray-400 italic"> 652 + {text} 653 + </div> 654 + ); 655 + 656 + export const ErrorState = ({ error }: { error: unknown }) => ( 657 + <div className="py-10 text-center text-red-600 dark:text-red-400"> 658 + Error: {(error as Error)?.message || "Something went wrong."} 659 + </div> 660 + );
+91
src/routes/profile.$did/feed.$rkey.tsx
··· 1 + import * as ATPAPI from "@atproto/api"; 2 + import { AtUri } from "@atproto/api"; 3 + import { createFileRoute } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 5 + 6 + import { Header } from "~/components/Header"; 7 + import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed"; 8 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 + import { quickAuthAtom } from "~/utils/atoms"; 10 + import { useQueryArbitrary, useQueryIdentity } from "~/utils/useQuery"; 11 + 12 + export const Route = createFileRoute("/profile/$did/feed/$rkey")({ 13 + component: FeedRoute, 14 + }); 15 + 16 + // todo: scroll restoration 17 + function FeedRoute() { 18 + const { did, rkey } = Route.useParams(); 19 + const { agent, status } = useAuth(); 20 + const { data: identitydata } = useQueryIdentity(did); 21 + const { data: identity } = useQueryIdentity(agent?.did); 22 + const uri = `at://${identitydata?.did || did}/app.bsky.feed.generator/${rkey}`; 23 + const aturi = new AtUri(uri); 24 + const { data: feeddata } = useQueryArbitrary(uri); 25 + 26 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 27 + const isAuthRestoring = quickAuth ? status === "loading" : false; 28 + 29 + const authed = status === "signedIn"; 30 + 31 + const feedServiceDid = !isAuthRestoring 32 + ? ((feeddata?.value as any)?.did as string | undefined) 33 + : undefined; 34 + 35 + // const { 36 + // data: feedData, 37 + // isLoading: isFeedLoading, 38 + // error: feedError, 39 + // } = useQueryFeedSkeleton({ 40 + // feedUri: selectedFeed!, 41 + // agent: agent ?? undefined, 42 + // isAuthed: authed ?? false, 43 + // pdsUrl: identity?.pds, 44 + // feedServiceDid: feedServiceDid, 45 + // }); 46 + 47 + // const feed = feedData?.feed || []; 48 + 49 + const isReadyForAuthedFeed = 50 + !isAuthRestoring && authed && agent && identity?.pds && feedServiceDid; 51 + const isReadyForUnauthedFeed = !isAuthRestoring && !authed; 52 + 53 + const feed: ATPAPI.AppBskyFeedGenerator.Record | undefined = feeddata?.value; 54 + 55 + const web = feedServiceDid?.replace(/^did:web:/, "") || ""; 56 + 57 + return ( 58 + <> 59 + <Header 60 + title={feed?.displayName || aturi.rkey} 61 + backButtonCallback={() => { 62 + if (window.history.length > 1) { 63 + window.history.back(); 64 + } else { 65 + window.location.assign("/"); 66 + } 67 + }} 68 + /> 69 + 70 + {isAuthRestoring || 71 + (authed && (!identity?.pds || !feedServiceDid) && ( 72 + <div className="p-4 text-center text-gray-500"> 73 + Preparing your feed... 74 + </div> 75 + ))} 76 + 77 + {!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? ( 78 + <InfiniteCustomFeed 79 + key={uri} 80 + feedUri={uri} 81 + pdsUrl={identity?.pds} 82 + feedServiceDid={feedServiceDid} 83 + authedOverride={!authed && true || undefined} 84 + unauthedfeedurl={!authed && web || undefined} 85 + /> 86 + ) : ( 87 + <div className="p-4 text-center text-gray-500">Loading.......</div> 88 + )} 89 + </> 90 + ); 91 + }
+30
src/routes/profile.$did/followers.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + 3 + import { Header } from "~/components/Header"; 4 + 5 + import { FollowsTab } from "../notifications"; 6 + 7 + export const Route = createFileRoute("/profile/$did/followers")({ 8 + component: RouteComponent, 9 + }); 10 + 11 + // todo: scroll restoration 12 + function RouteComponent() { 13 + const params = Route.useParams(); 14 + 15 + return ( 16 + <div> 17 + <Header 18 + title={"Followers"} 19 + backButtonCallback={() => { 20 + if (window.history.length > 1) { 21 + window.history.back(); 22 + } else { 23 + window.location.assign("/"); 24 + } 25 + }} 26 + /> 27 + <FollowsTab did={params.did} /> 28 + </div> 29 + ); 30 + }
+79
src/routes/profile.$did/follows.tsx
··· 1 + import * as ATPAPI from "@atproto/api" 2 + import { createFileRoute } from '@tanstack/react-router' 3 + import React from 'react'; 4 + 5 + import { Header } from '~/components/Header'; 6 + import { useReusableTabScrollRestore } from '~/components/ReusableTabRoute'; 7 + import { useInfiniteQueryAuthorFeed, useQueryIdentity } from '~/utils/useQuery'; 8 + 9 + import { EmptyState, ErrorState, LoadingState, NotificationItem } from '../notifications'; 10 + 11 + export const Route = createFileRoute('/profile/$did/follows')({ 12 + component: RouteComponent, 13 + }) 14 + 15 + // todo: scroll restoration 16 + function RouteComponent() { 17 + const params = Route.useParams(); 18 + return ( 19 + <div> 20 + <Header 21 + title={"Follows"} 22 + backButtonCallback={() => { 23 + if (window.history.length > 1) { 24 + window.history.back(); 25 + } else { 26 + window.location.assign("/"); 27 + } 28 + }} 29 + /> 30 + <Follows did={params.did}/> 31 + </div> 32 + ); 33 + } 34 + 35 + function Follows({did}:{did:string}) { 36 + const {data: identity} = useQueryIdentity(did); 37 + const infinitequeryresults = useInfiniteQueryAuthorFeed(identity?.did, identity?.pds, "app.bsky.graph.follow"); 38 + 39 + const { 40 + data: infiniteFollowsData, 41 + fetchNextPage, 42 + hasNextPage, 43 + isFetchingNextPage, 44 + isLoading, 45 + isError, 46 + error, 47 + } = infinitequeryresults; 48 + 49 + const followsAturis = React.useMemo( 50 + () => infiniteFollowsData?.pages.flatMap((page) => page.records) ?? [], 51 + [infiniteFollowsData] 52 + ); 53 + 54 + useReusableTabScrollRestore("Notifications"); 55 + 56 + if (isLoading) return <LoadingState text="Loading follows..." />; 57 + if (isError) return <ErrorState error={error} />; 58 + 59 + if (!followsAturis?.length) return <EmptyState text="No follows yet." />; 60 + 61 + return ( 62 + <> 63 + {followsAturis.map((m) => { 64 + const record = m.value as unknown as ATPAPI.AppBskyGraphFollow.Record; 65 + return <NotificationItem key={record.subject} notification={record.subject} /> 66 + })} 67 + 68 + {hasNextPage && ( 69 + <button 70 + onClick={() => fetchNextPage()} 71 + disabled={isFetchingNextPage} 72 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 73 + > 74 + {isFetchingNextPage ? "Loading..." : "Load More"} 75 + </button> 76 + )} 77 + </> 78 + ); 79 + }
+663 -81
src/routes/profile.$did/index.tsx
··· 1 - import { RichText } from "@atproto/api"; 1 + import { Agent, RichText } from "@atproto/api"; 2 + import * as ATPAPI from "@atproto/api"; 3 + import { TID } from "@atproto/common-web"; 2 4 import { useQueryClient } from "@tanstack/react-query"; 3 - import { createFileRoute, useNavigate } from "@tanstack/react-router"; 5 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 4 6 import { useAtom } from "jotai"; 5 7 import React, { type ReactNode, useEffect, useState } from "react"; 6 8 9 + import defaultpfp from "~/../public/favicon.png"; 7 10 import { Header } from "~/components/Header"; 8 - import { Button } from "~/components/radix-m3-rd/Button"; 11 + import { 12 + ReusableTabRoute, 13 + useReusableTabScrollRestore, 14 + } from "~/components/ReusableTabRoute"; 9 15 import { 10 16 renderTextWithFacets, 11 17 UniversalPostRendererATURILoader, 12 18 } from "~/components/UniversalPostRenderer"; 13 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 14 - import { imgCDNAtom } from "~/utils/atoms"; 20 + import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 15 21 import { 16 22 toggleFollow, 17 23 useGetFollowState, 18 24 useGetOneToOneState, 19 25 } from "~/utils/followState"; 26 + import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue"; 20 27 import { 21 28 useInfiniteQueryAuthorFeed, 29 + useQueryArbitrary, 30 + useQueryConstellation, 31 + useQueryConstellationLinksCountDistinctDids, 22 32 useQueryIdentity, 23 33 useQueryProfile, 24 34 } from "~/utils/useQuery"; 25 35 36 + import { Chip } from "../notifications"; 37 + 26 38 export const Route = createFileRoute("/profile/$did/")({ 27 39 component: ProfileComponent, 28 40 }); ··· 30 42 function ProfileComponent() { 31 43 // booo bad this is not always the did it might be a handle, use identity.did instead 32 44 const { did } = Route.useParams(); 45 + const { agent } = useAuth(); 33 46 const navigate = useNavigate(); 34 47 const queryClient = useQueryClient(); 35 48 const { ··· 48 61 const { data: profileRecord } = useQueryProfile(profileUri); 49 62 const profile = profileRecord?.value; 50 63 51 - const { 52 - data: postsData, 53 - fetchNextPage, 54 - hasNextPage, 55 - isFetchingNextPage, 56 - isLoading: arePostsLoading, 57 - } = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl); 58 - 59 - React.useEffect(() => { 60 - if (postsData) { 61 - postsData.pages.forEach((page) => { 62 - page.records.forEach((record) => { 63 - if (!queryClient.getQueryData(["post", record.uri])) { 64 - queryClient.setQueryData(["post", record.uri], record); 65 - } 66 - }); 67 - }); 68 - } 69 - }, [postsData, queryClient]); 70 - 71 - const posts = React.useMemo( 72 - () => postsData?.pages.flatMap((page) => page.records) ?? [], 73 - [postsData] 74 - ); 75 - 76 64 const [imgcdn] = useAtom(imgCDNAtom); 77 65 78 66 function getAvatarUrl(p: typeof profile) { ··· 91 79 const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did; 92 80 const description = profile?.description || ""; 93 81 94 - if (isIdentityLoading) { 95 - return ( 96 - <div className="p-4 text-center text-gray-500">Resolving profile...</div> 97 - ); 98 - } 82 + const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; 99 83 100 - if (identityError) { 101 - return ( 102 - <div className="p-4 text-center text-red-500"> 103 - Error: {identityError.message} 104 - </div> 105 - ); 106 - } 84 + const resultwhateversure = useQueryConstellationLinksCountDistinctDids(resolvedDid ? { 85 + method: "/links/count/distinct-dids", 86 + collection: "app.bsky.graph.follow", 87 + target: resolvedDid, 88 + path: ".subject" 89 + } : undefined) 107 90 108 - if (!resolvedDid) { 109 - return ( 110 - <div className="p-4 text-center text-gray-500">Profile not found.</div> 111 - ); 112 - } 91 + const followercount = resultwhateversure?.data?.total; 113 92 114 93 return ( 115 - <> 94 + <div className=""> 116 95 <Header 117 96 title={`Profile`} 118 97 backButtonCallback={() => { ··· 122 101 window.location.assign("/"); 123 102 } 124 103 }} 104 + bottomBorderDisabled={true} 125 105 /> 126 106 {/* <div className="flex gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700"> 127 107 <Link ··· 164 144 </div> 165 145 166 146 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> 147 + <BiteButton targetdidorhandle={did} /> 167 148 {/* 168 149 todo: full follow and unfollow backfill (along with partial likes backfill, 169 150 just enough for it to be useful) ··· 171 152 also save it persistently 172 153 */} 173 154 <FollowButton targetdidorhandle={did} /> 174 - <Button className="rounded-full" variant={"secondary"}> 155 + <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 175 156 ... {/* todo: icon */} 176 - </Button> 157 + </button> 177 158 </div> 178 159 179 160 {/* Info Card */} ··· 183 164 <Mutual targetdidorhandle={did} /> 184 165 {handle} 185 166 </div> 167 + <div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2"> 168 + <Link to="/profile/$did/followers" params={{did: did}}>{followercount && (<span className="mr-1 text-gray-900 dark:text-gray-200 font-medium">{followercount}</span>)}Followers</Link> 169 + - 170 + <Link to="/profile/$did/follows" params={{did: did}}>Follows</Link> 171 + </div> 186 172 {description && ( 187 173 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 188 174 {/* {description} */} ··· 192 178 </div> 193 179 </div> 194 180 195 - {/* Posts Section */} 196 - <div className="max-w-2xl mx-auto"> 197 - <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 198 - Posts 181 + {/* this should not be rendered until its ready (the top profile layout is stable) */} 182 + {isReady ? ( 183 + <ReusableTabRoute 184 + route={`Profile` + did} 185 + tabs={{ 186 + Posts: <PostsTab did={did} />, 187 + Reposts: <RepostsTab did={did} />, 188 + Feeds: <FeedsTab did={did} />, 189 + Lists: <ListsTab did={did} />, 190 + ...(identity?.did === agent?.did 191 + ? { Likes: <SelfLikesTab did={did} /> } 192 + : {}), 193 + }} 194 + /> 195 + ) : isIdentityLoading ? ( 196 + <div className="p-4 text-center text-gray-500"> 197 + Resolving profile... 199 198 </div> 200 - <div> 201 - {posts.map((post) => ( 199 + ) : identityError ? ( 200 + <div className="p-4 text-center text-red-500"> 201 + Error: {identityError.message} 202 + </div> 203 + ) : !resolvedDid ? ( 204 + <div className="p-4 text-center text-gray-500">Profile not found.</div> 205 + ) : ( 206 + <div className="p-4 text-center text-gray-500"> 207 + Loading profile content... 208 + </div> 209 + )} 210 + </div> 211 + ); 212 + } 213 + 214 + export type ProfilePostsFilter = { 215 + posts: boolean, 216 + replies: boolean, 217 + mediaOnly: boolean, 218 + } 219 + export const defaultProfilePostsFilter: ProfilePostsFilter = { 220 + posts: true, 221 + replies: true, 222 + mediaOnly: false, 223 + } 224 + 225 + function ProfilePostsFilterChipBar({filters, toggle}:{filters: ProfilePostsFilter | null, toggle: (key: keyof ProfilePostsFilter) => void}) { 226 + const empty = (!filters?.replies && !filters?.posts); 227 + const almostEmpty = (!filters?.replies && filters?.posts); 228 + 229 + useEffect(() => { 230 + if (empty) { 231 + toggle("posts") 232 + } 233 + }, [empty, toggle]); 234 + 235 + return ( 236 + <div className="flex flex-row flex-wrap gap-2 px-4 pt-4"> 237 + <Chip 238 + state={filters?.posts ?? true} 239 + text="Posts" 240 + onClick={() => almostEmpty ? null : toggle("posts")} 241 + /> 242 + <Chip 243 + state={filters?.replies ?? true} 244 + text="Replies" 245 + onClick={() => toggle("replies")} 246 + /> 247 + <Chip 248 + state={filters?.mediaOnly ?? false} 249 + text="Media Only" 250 + onClick={() => toggle("mediaOnly")} 251 + /> 252 + </div> 253 + ); 254 + } 255 + 256 + function PostsTab({ did }: { did: string }) { 257 + // todo: this needs to be a (non-persisted is fine) atom to survive navigation 258 + const [filterses, setFilterses] = useAtom(profileChipsAtom); 259 + const filters = filterses?.[did]; 260 + const setFilters = (obj: ProfilePostsFilter) => { 261 + setFilterses((prev)=>{ 262 + return{ 263 + ...prev, 264 + [did]: obj 265 + } 266 + }) 267 + } 268 + useEffect(()=>{ 269 + if (!filters) { 270 + setFilters(defaultProfilePostsFilter); 271 + } 272 + }) 273 + useReusableTabScrollRestore(`Profile` + did); 274 + const queryClient = useQueryClient(); 275 + const { 276 + data: identity, 277 + isLoading: isIdentityLoading, 278 + error: identityError, 279 + } = useQueryIdentity(did); 280 + 281 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 282 + 283 + const { 284 + data: postsData, 285 + fetchNextPage, 286 + hasNextPage, 287 + isFetchingNextPage, 288 + isLoading: arePostsLoading, 289 + } = useInfiniteQueryAuthorFeed(resolvedDid, identity?.pds); 290 + 291 + React.useEffect(() => { 292 + if (postsData) { 293 + postsData.pages.forEach((page) => { 294 + page.records.forEach((record) => { 295 + if (!queryClient.getQueryData(["post", record.uri])) { 296 + queryClient.setQueryData(["post", record.uri], record); 297 + } 298 + }); 299 + }); 300 + } 301 + }, [postsData, queryClient]); 302 + 303 + const posts = React.useMemo( 304 + () => postsData?.pages.flatMap((page) => page.records) ?? [], 305 + [postsData] 306 + ); 307 + 308 + const toggle = (key: keyof ProfilePostsFilter) => { 309 + setFilterses(prev => { 310 + const existing = prev[did] ?? { posts: false, replies: false, mediaOnly: false }; // default 311 + 312 + return { 313 + ...prev, 314 + [did]: { 315 + ...existing, 316 + [key]: !existing[key], // safely negate 317 + }, 318 + }; 319 + }); 320 + }; 321 + 322 + return ( 323 + <> 324 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 325 + Posts 326 + </div> */} 327 + <ProfilePostsFilterChipBar filters={filters} toggle={toggle} /> 328 + <div> 329 + {posts.map((post) => ( 330 + <UniversalPostRendererATURILoader 331 + key={post.uri} 332 + atUri={post.uri} 333 + feedviewpost={true} 334 + filterNoReplies={!filters?.replies} 335 + filterMustHaveMedia={filters?.mediaOnly} 336 + filterMustBeReply={!filters?.posts} 337 + /> 338 + ))} 339 + </div> 340 + 341 + {/* Loading and "Load More" states */} 342 + {arePostsLoading && posts.length === 0 && ( 343 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 344 + )} 345 + {isFetchingNextPage && ( 346 + <div className="p-4 text-center text-gray-500">Loading more...</div> 347 + )} 348 + {hasNextPage && !isFetchingNextPage && ( 349 + <button 350 + onClick={() => fetchNextPage()} 351 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 352 + > 353 + Load More Posts 354 + </button> 355 + )} 356 + {posts.length === 0 && !arePostsLoading && ( 357 + <div className="p-4 text-center text-gray-500">No posts found.</div> 358 + )} 359 + </> 360 + ); 361 + } 362 + 363 + function RepostsTab({ did }: { did: string }) { 364 + useReusableTabScrollRestore(`Profile` + did); 365 + const { 366 + data: identity, 367 + isLoading: isIdentityLoading, 368 + error: identityError, 369 + } = useQueryIdentity(did); 370 + 371 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 372 + 373 + const { 374 + data: repostsData, 375 + fetchNextPage, 376 + hasNextPage, 377 + isFetchingNextPage, 378 + isLoading: arePostsLoading, 379 + } = useInfiniteQueryAuthorFeed( 380 + resolvedDid, 381 + identity?.pds, 382 + "app.bsky.feed.repost" 383 + ); 384 + 385 + const reposts = React.useMemo( 386 + () => repostsData?.pages.flatMap((page) => page.records) ?? [], 387 + [repostsData] 388 + ); 389 + 390 + return ( 391 + <> 392 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 393 + Reposts 394 + </div> 395 + <div> 396 + {reposts.map((repost) => { 397 + if ( 398 + !repost || 399 + !repost?.value || 400 + !repost?.value?.subject || 401 + // @ts-expect-error blehhhhh 402 + !repost?.value?.subject?.uri 403 + ) 404 + return; 405 + const repostRecord = 406 + repost.value as unknown as ATPAPI.AppBskyFeedRepost.Record; 407 + return ( 202 408 <UniversalPostRendererATURILoader 203 - key={post.uri} 204 - atUri={post.uri} 409 + key={repostRecord.subject.uri} 410 + atUri={repostRecord.subject.uri} 205 411 feedviewpost={true} 412 + repostedby={repost.uri} 206 413 /> 207 - ))} 414 + ); 415 + })} 416 + </div> 417 + 418 + {/* Loading and "Load More" states */} 419 + {arePostsLoading && reposts.length === 0 && ( 420 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 421 + )} 422 + {isFetchingNextPage && ( 423 + <div className="p-4 text-center text-gray-500">Loading more...</div> 424 + )} 425 + {hasNextPage && !isFetchingNextPage && ( 426 + <button 427 + onClick={() => fetchNextPage()} 428 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 429 + > 430 + Load More Posts 431 + </button> 432 + )} 433 + {reposts.length === 0 && !arePostsLoading && ( 434 + <div className="p-4 text-center text-gray-500">No posts found.</div> 435 + )} 436 + </> 437 + ); 438 + } 439 + 440 + function FeedsTab({ did }: { did: string }) { 441 + useReusableTabScrollRestore(`Profile` + did); 442 + const { 443 + data: identity, 444 + isLoading: isIdentityLoading, 445 + error: identityError, 446 + } = useQueryIdentity(did); 447 + 448 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 449 + 450 + const { 451 + data: feedsData, 452 + fetchNextPage, 453 + hasNextPage, 454 + isFetchingNextPage, 455 + isLoading: arePostsLoading, 456 + } = useInfiniteQueryAuthorFeed( 457 + resolvedDid, 458 + identity?.pds, 459 + "app.bsky.feed.generator" 460 + ); 461 + 462 + const feeds = React.useMemo( 463 + () => feedsData?.pages.flatMap((page) => page.records) ?? [], 464 + [feedsData] 465 + ); 466 + 467 + return ( 468 + <> 469 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 470 + Feeds 471 + </div> 472 + <div> 473 + {feeds.map((feed) => { 474 + if (!feed || !feed?.value) return; 475 + const feedGenRecord = 476 + feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record; 477 + return <FeedItemRender feed={feed as any} key={feed.uri} />; 478 + })} 479 + </div> 480 + 481 + {/* Loading and "Load More" states */} 482 + {arePostsLoading && feeds.length === 0 && ( 483 + <div className="p-4 text-center text-gray-500">Loading feeds...</div> 484 + )} 485 + {isFetchingNextPage && ( 486 + <div className="p-4 text-center text-gray-500">Loading more...</div> 487 + )} 488 + {hasNextPage && !isFetchingNextPage && ( 489 + <button 490 + onClick={() => fetchNextPage()} 491 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 492 + > 493 + Load More Feeds 494 + </button> 495 + )} 496 + {feeds.length === 0 && !arePostsLoading && ( 497 + <div className="p-4 text-center text-gray-500">No feeds found.</div> 498 + )} 499 + </> 500 + ); 501 + } 502 + 503 + export function FeedItemRenderAturiLoader({ 504 + aturi, 505 + listmode, 506 + disableBottomBorder, 507 + disablePropagation, 508 + }: { 509 + aturi: string; 510 + listmode?: boolean; 511 + disableBottomBorder?: boolean; 512 + disablePropagation?: boolean; 513 + }) { 514 + const { data: record } = useQueryArbitrary(aturi); 515 + 516 + if (!record) return; 517 + return ( 518 + <FeedItemRender 519 + listmode={listmode} 520 + feed={record} 521 + disableBottomBorder={disableBottomBorder} 522 + disablePropagation={disablePropagation} 523 + /> 524 + ); 525 + } 526 + 527 + export function FeedItemRender({ 528 + feed, 529 + listmode, 530 + disableBottomBorder, 531 + disablePropagation, 532 + }: { 533 + feed: { uri: string; cid: string; value: any }; 534 + listmode?: boolean; 535 + disableBottomBorder?: boolean; 536 + disablePropagation?: boolean; 537 + }) { 538 + const name = listmode 539 + ? (feed.value?.name as string) 540 + : (feed.value?.displayName as string); 541 + const aturi = new ATPAPI.AtUri(feed.uri); 542 + const { data: identity } = useQueryIdentity(aturi.host); 543 + const resolvedDid = identity?.did; 544 + const [imgcdn] = useAtom(imgCDNAtom); 545 + 546 + function getAvatarThumbnailUrl(f: typeof feed) { 547 + const link = f?.value.avatar?.ref?.["$link"]; 548 + if (!link || !resolvedDid) return null; 549 + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 550 + } 551 + 552 + const { data: likes } = useQueryConstellation( 553 + // @ts-expect-error overloads sucks 554 + !listmode 555 + ? { 556 + target: feed.uri, 557 + method: "/links/count", 558 + collection: "app.bsky.feed.like", 559 + path: ".subject.uri", 560 + } 561 + : undefined 562 + ); 563 + 564 + return ( 565 + <Link 566 + className={`px-4 py-4 ${!disableBottomBorder && "border-b"} flex flex-col gap-1`} 567 + to="/profile/$did/feed/$rkey" 568 + params={{ did: aturi.host, rkey: aturi.rkey }} 569 + onClick={(e) => { 570 + e.stopPropagation(); 571 + }} 572 + > 573 + <div className="flex flex-row gap-3"> 574 + <div className="min-w-10 min-h-10"> 575 + <img 576 + src={getAvatarThumbnailUrl(feed) || defaultpfp} 577 + className="h-10 w-10 rounded border" 578 + /> 208 579 </div> 580 + <div className="flex flex-col"> 581 + <span className="">{name}</span> 582 + <span className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center"> 583 + {feed.value.did || aturi.rkey} 584 + </span> 585 + </div> 586 + <div className="flex-1" /> 587 + {/* <div className="button bg-red-500 rounded-full min-w-[60px]" /> */} 588 + </div> 589 + <span className=" text-sm">{feed.value?.description}</span> 590 + {!listmode && ( 591 + <span className=" text-sm dark:text-gray-400 text-gray-500"> 592 + Liked by {((likes as unknown as any)?.total as number) || 0} users 593 + </span> 594 + )} 595 + </Link> 596 + ); 597 + } 209 598 210 - {/* Loading and "Load More" states */} 211 - {arePostsLoading && posts.length === 0 && ( 212 - <div className="p-4 text-center text-gray-500">Loading posts...</div> 213 - )} 214 - {isFetchingNextPage && ( 215 - <div className="p-4 text-center text-gray-500">Loading more...</div> 216 - )} 217 - {hasNextPage && !isFetchingNextPage && ( 218 - <button 219 - onClick={() => fetchNextPage()} 220 - className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 221 - > 222 - Load More Posts 223 - </button> 224 - )} 225 - {posts.length === 0 && !arePostsLoading && ( 226 - <div className="p-4 text-center text-gray-500">No posts found.</div> 227 - )} 599 + function ListsTab({ did }: { did: string }) { 600 + useReusableTabScrollRestore(`Profile` + did); 601 + const { 602 + data: identity, 603 + isLoading: isIdentityLoading, 604 + error: identityError, 605 + } = useQueryIdentity(did); 606 + 607 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 608 + 609 + const { 610 + data: feedsData, 611 + fetchNextPage, 612 + hasNextPage, 613 + isFetchingNextPage, 614 + isLoading: arePostsLoading, 615 + } = useInfiniteQueryAuthorFeed( 616 + resolvedDid, 617 + identity?.pds, 618 + "app.bsky.graph.list" 619 + ); 620 + 621 + const feeds = React.useMemo( 622 + () => feedsData?.pages.flatMap((page) => page.records) ?? [], 623 + [feedsData] 624 + ); 625 + 626 + return ( 627 + <> 628 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 629 + Feeds 228 630 </div> 631 + <div> 632 + {feeds.map((feed) => { 633 + if (!feed || !feed?.value) return; 634 + const feedGenRecord = 635 + feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record; 636 + return ( 637 + <FeedItemRender listmode={true} feed={feed as any} key={feed.uri} /> 638 + ); 639 + })} 640 + </div> 641 + 642 + {/* Loading and "Load More" states */} 643 + {arePostsLoading && feeds.length === 0 && ( 644 + <div className="p-4 text-center text-gray-500">Loading lists...</div> 645 + )} 646 + {isFetchingNextPage && ( 647 + <div className="p-4 text-center text-gray-500">Loading more...</div> 648 + )} 649 + {hasNextPage && !isFetchingNextPage && ( 650 + <button 651 + onClick={() => fetchNextPage()} 652 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 653 + > 654 + Load More Lists 655 + </button> 656 + )} 657 + {feeds.length === 0 && !arePostsLoading && ( 658 + <div className="p-4 text-center text-gray-500">No lists found.</div> 659 + )} 660 + </> 661 + ); 662 + } 663 + 664 + function SelfLikesTab({ did }: { did: string }) { 665 + useReusableTabScrollRestore(`Profile` + did); 666 + const { 667 + data: identity, 668 + isLoading: isIdentityLoading, 669 + error: identityError, 670 + } = useQueryIdentity(did); 671 + 672 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 673 + 674 + const { 675 + data: likesData, 676 + fetchNextPage, 677 + hasNextPage, 678 + isFetchingNextPage, 679 + isLoading: arePostsLoading, 680 + } = useInfiniteQueryAuthorFeed( 681 + resolvedDid, 682 + identity?.pds, 683 + "app.bsky.feed.like" 684 + ); 685 + 686 + const likes = React.useMemo( 687 + () => likesData?.pages.flatMap((page) => page.records) ?? [], 688 + [likesData] 689 + ); 690 + 691 + const { setFastState } = useFastSetLikesFromFeed(); 692 + const seededRef = React.useRef(new Set<string>()); 693 + 694 + useEffect(() => { 695 + for (const like of likes) { 696 + if (!seededRef.current.has(like.uri)) { 697 + seededRef.current.add(like.uri); 698 + const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record; 699 + setFastState(record.subject.uri, { 700 + target: record.subject.uri, 701 + uri: like.uri, 702 + cid: like.cid, 703 + }); 704 + } 705 + } 706 + }, [likes, setFastState]); 707 + 708 + return ( 709 + <> 710 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 711 + Likes 712 + </div> 713 + <div> 714 + {likes.map((like) => { 715 + if ( 716 + !like || 717 + !like?.value || 718 + !like?.value?.subject || 719 + // @ts-expect-error blehhhhh 720 + !like?.value?.subject?.uri 721 + ) 722 + return; 723 + const likeRecord = 724 + like.value as unknown as ATPAPI.AppBskyFeedLike.Record; 725 + return ( 726 + <UniversalPostRendererATURILoader 727 + key={likeRecord.subject.uri} 728 + atUri={likeRecord.subject.uri} 729 + feedviewpost={true} 730 + /> 731 + ); 732 + })} 733 + </div> 734 + 735 + {/* Loading and "Load More" states */} 736 + {arePostsLoading && likes.length === 0 && ( 737 + <div className="p-4 text-center text-gray-500">Loading likes...</div> 738 + )} 739 + {isFetchingNextPage && ( 740 + <div className="p-4 text-center text-gray-500">Loading more...</div> 741 + )} 742 + {hasNextPage && !isFetchingNextPage && ( 743 + <button 744 + onClick={() => fetchNextPage()} 745 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 746 + > 747 + Load More Likes 748 + </button> 749 + )} 750 + {likes.length === 0 && !arePostsLoading && ( 751 + <div className="p-4 text-center text-gray-500">No likes found.</div> 752 + )} 229 753 </> 230 754 ); 231 755 } ··· 249 773 {identity?.did !== agent?.did ? ( 250 774 <> 251 775 {!(followRecords?.length && followRecords?.length > 0) ? ( 252 - <Button 776 + <button 253 777 onClick={(e) => { 254 778 e.stopPropagation(); 255 779 toggleFollow({ ··· 259 783 queryClient: queryClient, 260 784 }); 261 785 }} 786 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 262 787 > 263 788 Follow 264 - </Button> 789 + </button> 265 790 ) : ( 266 - <Button 791 + <button 267 792 onClick={(e) => { 268 793 e.stopPropagation(); 269 794 toggleFollow({ ··· 273 798 queryClient: queryClient, 274 799 }); 275 800 }} 801 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 276 802 > 277 803 Unfollow 278 - </Button> 804 + </button> 279 805 )} 280 806 </> 281 807 ) : ( 282 - <Button variant={"secondary"}>Edit Profile</Button> 808 + <button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"> 809 + Edit Profile 810 + </button> 283 811 )} 284 812 </> 285 813 ); 286 814 } 815 + 816 + export function BiteButton({ 817 + targetdidorhandle, 818 + }: { 819 + targetdidorhandle: string; 820 + }) { 821 + const { agent } = useAuth(); 822 + const { data: identity } = useQueryIdentity(targetdidorhandle); 823 + const [show] = useAtom(enableBitesAtom); 824 + 825 + if (!show) return 826 + 827 + return ( 828 + <> 829 + <button 830 + onClick={(e) => { 831 + e.stopPropagation(); 832 + sendBite({ 833 + agent: agent || undefined, 834 + targetDid: identity?.did, 835 + }); 836 + }} 837 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 838 + > 839 + Bite 840 + </button> 841 + </> 842 + ); 843 + } 844 + 845 + function sendBite({ 846 + agent, 847 + targetDid, 848 + }: { 849 + agent?: Agent; 850 + targetDid?: string; 851 + }) { 852 + if (!agent?.did || !targetDid) return; 853 + const newRecord = { 854 + repo: agent.did, 855 + collection: "net.wafrn.feed.bite", 856 + rkey: TID.next().toString(), 857 + record: { 858 + $type: "net.wafrn.feed.bite", 859 + subject: "at://"+targetDid, 860 + createdAt: new Date().toISOString(), 861 + }, 862 + }; 863 + 864 + agent.com.atproto.repo.createRecord(newRecord).catch((err) => { 865 + console.error("Bite failed:", err); 866 + }); 867 + } 868 + 287 869 288 870 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 289 871 const { agent } = useAuth();
+100
src/routes/profile.$did/post.$rkey.liked-by.tsx
··· 1 + import { useInfiniteQuery } from "@tanstack/react-query"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 + import React from "react"; 5 + 6 + import { Header } from "~/components/Header"; 7 + import { constellationURLAtom } from "~/utils/atoms"; 8 + import { useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery"; 9 + 10 + import { 11 + EmptyState, 12 + ErrorState, 13 + LoadingState, 14 + NotificationItem, 15 + } from "../notifications"; 16 + 17 + export const Route = createFileRoute("/profile/$did/post/$rkey/liked-by")({ 18 + component: RouteComponent, 19 + }); 20 + 21 + function RouteComponent() { 22 + const { did, rkey } = Route.useParams(); 23 + const { data: identity } = useQueryIdentity(did); 24 + const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : ''; 25 + 26 + const [constellationurl] = useAtom(constellationURLAtom); 27 + const infinitequeryresults = useInfiniteQuery({ 28 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 29 + { 30 + constellation: constellationurl, 31 + method: "/links", 32 + target: atUri, 33 + collection: "app.bsky.feed.like", 34 + path: ".subject.uri", 35 + } 36 + ), 37 + enabled: !!atUri, 38 + }); 39 + 40 + const { 41 + data: infiniteLikesData, 42 + fetchNextPage, 43 + hasNextPage, 44 + isFetchingNextPage, 45 + isLoading, 46 + isError, 47 + error, 48 + } = infinitequeryresults; 49 + 50 + const likesAturis = React.useMemo(() => { 51 + // Get all replies from the standard infinite query 52 + return ( 53 + infiniteLikesData?.pages.flatMap( 54 + (page) => 55 + page?.linking_records.map( 56 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 57 + ) ?? [] 58 + ) ?? [] 59 + ); 60 + }, [infiniteLikesData]); 61 + 62 + return ( 63 + <> 64 + <Header 65 + title={`Liked By`} 66 + backButtonCallback={() => { 67 + if (window.history.length > 1) { 68 + window.history.back(); 69 + } else { 70 + window.location.assign("/"); 71 + } 72 + }} 73 + /> 74 + 75 + <> 76 + {(() => { 77 + if (isLoading) return <LoadingState text="Loading likes..." />; 78 + if (isError) return <ErrorState error={error} />; 79 + 80 + if (!likesAturis?.length) 81 + return <EmptyState text="No likes yet." />; 82 + })()} 83 + </> 84 + 85 + {likesAturis.map((m) => ( 86 + <NotificationItem key={m} notification={m} /> 87 + ))} 88 + 89 + {hasNextPage && ( 90 + <button 91 + onClick={() => fetchNextPage()} 92 + disabled={isFetchingNextPage} 93 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 94 + > 95 + {isFetchingNextPage ? "Loading..." : "Load More"} 96 + </button> 97 + )} 98 + </> 99 + ); 100 + }
+141
src/routes/profile.$did/post.$rkey.quotes.tsx
··· 1 + import { useInfiniteQuery } from "@tanstack/react-query"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 + import React from "react"; 5 + 6 + import { Header } from "~/components/Header"; 7 + import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 8 + import { constellationURLAtom } from "~/utils/atoms"; 9 + import { type linksRecord,useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery"; 10 + 11 + import { 12 + EmptyState, 13 + ErrorState, 14 + LoadingState, 15 + } from "../notifications"; 16 + 17 + export const Route = createFileRoute("/profile/$did/post/$rkey/quotes")({ 18 + component: RouteComponent, 19 + }); 20 + 21 + function RouteComponent() { 22 + const { did, rkey } = Route.useParams(); 23 + const { data: identity } = useQueryIdentity(did); 24 + const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : ''; 25 + 26 + const [constellationurl] = useAtom(constellationURLAtom); 27 + const infinitequeryresultsWithoutMedia = useInfiniteQuery({ 28 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 29 + { 30 + constellation: constellationurl, 31 + method: "/links", 32 + target: atUri, 33 + collection: "app.bsky.feed.post", 34 + path: ".embed.record.uri", // embed.record.record.uri and embed.record.uri 35 + } 36 + ), 37 + enabled: !!atUri, 38 + }); 39 + const infinitequeryresultsWithMedia = useInfiniteQuery({ 40 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 41 + { 42 + constellation: constellationurl, 43 + method: "/links", 44 + target: atUri, 45 + collection: "app.bsky.feed.post", 46 + path: ".embed.record.record.uri", // embed.record.record.uri and embed.record.uri 47 + } 48 + ), 49 + enabled: !!atUri, 50 + }); 51 + 52 + const { 53 + data: infiniteQuotesDataWithoutMedia, 54 + fetchNextPage: fetchNextPageWithoutMedia, 55 + hasNextPage: hasNextPageWithoutMedia, 56 + isFetchingNextPage: isFetchingNextPageWithoutMedia, 57 + isLoading: isLoadingWithoutMedia, 58 + isError: isErrorWithoutMedia, 59 + error: errorWithoutMedia, 60 + } = infinitequeryresultsWithoutMedia; 61 + const { 62 + data: infiniteQuotesDataWithMedia, 63 + fetchNextPage: fetchNextPageWithMedia, 64 + hasNextPage: hasNextPageWithMedia, 65 + isFetchingNextPage: isFetchingNextPageWithMedia, 66 + isLoading: isLoadingWithMedia, 67 + isError: isErrorWithMedia, 68 + error: errorWithMedia, 69 + } = infinitequeryresultsWithMedia; 70 + 71 + const fetchNextPage = async () => { 72 + await Promise.all([ 73 + hasNextPageWithMedia && fetchNextPageWithMedia(), 74 + hasNextPageWithoutMedia && fetchNextPageWithoutMedia(), 75 + ]); 76 + }; 77 + 78 + const hasNextPage = hasNextPageWithMedia || hasNextPageWithoutMedia; 79 + const isFetchingNextPage = isFetchingNextPageWithMedia || isFetchingNextPageWithoutMedia; 80 + const isLoading = isLoadingWithMedia || isLoadingWithoutMedia; 81 + 82 + const allQuotes = React.useMemo(() => { 83 + const withPages = infiniteQuotesDataWithMedia?.pages ?? []; 84 + const withoutPages = infiniteQuotesDataWithoutMedia?.pages ?? []; 85 + const maxLen = Math.max(withPages.length, withoutPages.length); 86 + const merged: linksRecord[] = []; 87 + 88 + for (let i = 0; i < maxLen; i++) { 89 + const a = withPages[i]?.linking_records ?? []; 90 + const b = withoutPages[i]?.linking_records ?? []; 91 + const mergedPage = [...a, ...b].sort((b, a) => a.rkey.localeCompare(b.rkey)); 92 + merged.push(...mergedPage); 93 + } 94 + 95 + return merged; 96 + }, [infiniteQuotesDataWithMedia?.pages, infiniteQuotesDataWithoutMedia?.pages]); 97 + 98 + const quotesAturis = React.useMemo(() => { 99 + return allQuotes.flatMap((r) => `at://${r.did}/${r.collection}/${r.rkey}`); 100 + }, [allQuotes]); 101 + 102 + return ( 103 + <> 104 + <Header 105 + title={`Quotes`} 106 + backButtonCallback={() => { 107 + if (window.history.length > 1) { 108 + window.history.back(); 109 + } else { 110 + window.location.assign("/"); 111 + } 112 + }} 113 + /> 114 + 115 + <> 116 + {(() => { 117 + if (isLoading) return <LoadingState text="Loading quotes..." />; 118 + if (isErrorWithMedia) return <ErrorState error={errorWithMedia} />; 119 + if (isErrorWithoutMedia) return <ErrorState error={errorWithoutMedia} />; 120 + 121 + if (!quotesAturis?.length) 122 + return <EmptyState text="No quotes yet." />; 123 + })()} 124 + </> 125 + 126 + {quotesAturis.map((m) => ( 127 + <UniversalPostRendererATURILoader key={m} atUri={m} /> 128 + ))} 129 + 130 + {hasNextPage && ( 131 + <button 132 + onClick={() => fetchNextPage()} 133 + disabled={isFetchingNextPage} 134 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 135 + > 136 + {isFetchingNextPage ? "Loading..." : "Load More"} 137 + </button> 138 + )} 139 + </> 140 + ); 141 + }
+100
src/routes/profile.$did/post.$rkey.reposted-by.tsx
··· 1 + import { useInfiniteQuery } from "@tanstack/react-query"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 + import React from "react"; 5 + 6 + import { Header } from "~/components/Header"; 7 + import { constellationURLAtom } from "~/utils/atoms"; 8 + import { useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery"; 9 + 10 + import { 11 + EmptyState, 12 + ErrorState, 13 + LoadingState, 14 + NotificationItem, 15 + } from "../notifications"; 16 + 17 + export const Route = createFileRoute("/profile/$did/post/$rkey/reposted-by")({ 18 + component: RouteComponent, 19 + }); 20 + 21 + function RouteComponent() { 22 + const { did, rkey } = Route.useParams(); 23 + const { data: identity } = useQueryIdentity(did); 24 + const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : ''; 25 + 26 + const [constellationurl] = useAtom(constellationURLAtom); 27 + const infinitequeryresults = useInfiniteQuery({ 28 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 29 + { 30 + constellation: constellationurl, 31 + method: "/links", 32 + target: atUri, 33 + collection: "app.bsky.feed.repost", 34 + path: ".subject.uri", 35 + } 36 + ), 37 + enabled: !!atUri, 38 + }); 39 + 40 + const { 41 + data: infiniteRepostsData, 42 + fetchNextPage, 43 + hasNextPage, 44 + isFetchingNextPage, 45 + isLoading, 46 + isError, 47 + error, 48 + } = infinitequeryresults; 49 + 50 + const repostsAturis = React.useMemo(() => { 51 + // Get all replies from the standard infinite query 52 + return ( 53 + infiniteRepostsData?.pages.flatMap( 54 + (page) => 55 + page?.linking_records.map( 56 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 57 + ) ?? [] 58 + ) ?? [] 59 + ); 60 + }, [infiniteRepostsData]); 61 + 62 + return ( 63 + <> 64 + <Header 65 + title={`Reposted By`} 66 + backButtonCallback={() => { 67 + if (window.history.length > 1) { 68 + window.history.back(); 69 + } else { 70 + window.location.assign("/"); 71 + } 72 + }} 73 + /> 74 + 75 + <> 76 + {(() => { 77 + if (isLoading) return <LoadingState text="Loading reposts..." />; 78 + if (isError) return <ErrorState error={error} />; 79 + 80 + if (!repostsAturis?.length) 81 + return <EmptyState text="No reposts yet." />; 82 + })()} 83 + </> 84 + 85 + {repostsAturis.map((m) => ( 86 + <NotificationItem key={m} notification={m} /> 87 + ))} 88 + 89 + {hasNextPage && ( 90 + <button 91 + onClick={() => fetchNextPage()} 92 + disabled={isFetchingNextPage} 93 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 94 + > 95 + {isFetchingNextPage ? "Loading..." : "Load More"} 96 + </button> 97 + )} 98 + </> 99 + ); 100 + }
+98 -92
src/routes/profile.$did/post.$rkey.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 2 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 - import { createFileRoute, Outlet } from "@tanstack/react-router"; 3 + import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router"; 4 4 import { useAtom } from "jotai"; 5 5 import React, { useLayoutEffect } from "react"; 6 6 ··· 52 52 nopics?: boolean; 53 53 lightboxCallback?: (d: LightboxProps) => void; 54 54 }) { 55 + const matchRoute = useMatchRoute() 56 + const showMainPostRoute = !!matchRoute({ to: '/profile/$did/post/$rkey' }) || !!matchRoute({ to: '/profile/$did/post/$rkey/image/$i' }) 57 + 55 58 //const { get, set } = usePersistentStore(); 56 59 const queryClient = useQueryClient(); 57 60 // const [resolvedDid, setResolvedDid] = React.useState<string | null>(null); ··· 190 193 data: identity, 191 194 isLoading: isIdentityLoading, 192 195 error: identityError, 193 - } = useQueryIdentity(did); 196 + } = useQueryIdentity(showMainPostRoute ? did : undefined); 194 197 195 198 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 196 199 197 200 const atUri = React.useMemo( 198 201 () => 199 - resolvedDid 202 + resolvedDid && showMainPostRoute 200 203 ? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}` 201 204 : undefined, 202 - [resolvedDid, rkey] 205 + [resolvedDid, rkey, showMainPostRoute] 203 206 ); 204 207 205 - const { data: mainPost } = useQueryPost(atUri); 208 + const { data: mainPost } = useQueryPost(showMainPostRoute ? atUri : undefined); 206 209 207 210 console.log("atUri",atUri) 208 211 ··· 215 218 ); 216 219 217 220 // @ts-expect-error i hate overloads 218 - const { data: links } = useQueryConstellation(atUri?{ 221 + const { data: links } = useQueryConstellation(atUri&&showMainPostRoute?{ 219 222 method: "/links/all", 220 223 target: atUri, 221 224 } : { ··· 248 251 }, [links]); 249 252 250 253 const { data: opreplies } = useQueryConstellation( 251 - !!opdid && replyCount && replyCount >= 25 254 + showMainPostRoute && !!opdid && replyCount && replyCount >= 25 252 255 ? { 253 256 method: "/links", 254 257 target: atUri, ··· 289 292 path: ".reply.parent.uri", 290 293 } 291 294 ), 292 - enabled: !!atUri, 295 + enabled: !!atUri && showMainPostRoute, 293 296 }); 294 297 295 298 const { ··· 371 374 const [layoutReady, setLayoutReady] = React.useState(false); 372 375 373 376 useLayoutEffect(() => { 377 + if (!showMainPostRoute) return 374 378 if (parents.length > 0 && !layoutReady && mainPostRef.current) { 375 379 const mainPostElement = mainPostRef.current; 376 380 ··· 389 393 // eslint-disable-next-line react-hooks/set-state-in-effect 390 394 setLayoutReady(true); 391 395 } 392 - }, [parents, layoutReady]); 396 + }, [parents, layoutReady, showMainPostRoute]); 393 397 394 398 395 399 const [slingshoturl] = useAtom(slingshotURLAtom) 396 400 397 401 React.useEffect(() => { 398 - if (parentsLoading) { 402 + if (parentsLoading || !showMainPostRoute) { 399 403 setLayoutReady(false); 400 404 } 401 405 ··· 403 407 setLayoutReady(true); 404 408 hasPerformedInitialLayout.current = true; 405 409 } 406 - }, [parentsLoading, mainPost]); 410 + }, [parentsLoading, mainPost, showMainPostRoute]); 407 411 408 412 React.useEffect(() => { 409 413 if (!mainPost?.value?.reply?.parent?.uri) { ··· 444 448 return () => { 445 449 ignore = true; 446 450 }; 447 - }, [mainPost, queryClient]); 451 + }, [mainPost, queryClient, slingshoturl]); 448 452 449 - if (!did || !rkey) return <div>Invalid post URI</div>; 450 - if (isIdentityLoading) return <div>Resolving handle...</div>; 451 - if (identityError) 453 + if ((!did || !rkey) && showMainPostRoute) return <div>Invalid post URI</div>; 454 + if (isIdentityLoading && showMainPostRoute) return <div>Resolving handle...</div>; 455 + if (identityError && showMainPostRoute) 452 456 return <div style={{ color: "red" }}>{identityError.message}</div>; 453 - if (!atUri) return <div>Could not construct post URI.</div>; 457 + if (!atUri && showMainPostRoute) return <div>Could not construct post URI.</div>; 454 458 455 459 return ( 456 460 <> 457 461 <Outlet /> 458 - <Header 459 - title={`Post`} 460 - backButtonCallback={() => { 461 - if (window.history.length > 1) { 462 - window.history.back(); 463 - } else { 464 - window.location.assign("/"); 465 - } 466 - }} 467 - /> 462 + {showMainPostRoute && (<> 463 + <Header 464 + title={`Post`} 465 + backButtonCallback={() => { 466 + if (window.history.length > 1) { 467 + window.history.back(); 468 + } else { 469 + window.location.assign("/"); 470 + } 471 + }} 472 + /> 468 473 469 - {parentsLoading && ( 470 - <div className="text-center text-gray-500 dark:text-gray-400 flex flex-row"> 471 - <div className="ml-4 w-[42px] flex justify-center"> 472 - <div 473 - style={{ width: 2, height: "100%", opacity: 0.5 }} 474 - className="bg-gray-500 dark:bg-gray-400" 475 - ></div> 474 + {parentsLoading && ( 475 + <div className="text-center text-gray-500 dark:text-gray-400 flex flex-row"> 476 + <div className="ml-4 w-[42px] flex justify-center"> 477 + <div 478 + style={{ width: 2, height: "100%", opacity: 0.5 }} 479 + className="bg-gray-500 dark:bg-gray-400" 480 + ></div> 481 + </div> 482 + Loading conversation... 476 483 </div> 477 - Loading conversation... 484 + )} 485 + 486 + {/* we should use the reply lines here thats provided by UPR*/} 487 + <div style={{ maxWidth: 600, padding: 0 }}> 488 + {parents.map((parent, index) => ( 489 + <UniversalPostRendererATURILoader 490 + key={parent.uri} 491 + atUri={parent.uri} 492 + topReplyLine={index > 0} 493 + bottomReplyLine={true} 494 + bottomBorder={false} 495 + /> 496 + ))} 478 497 </div> 479 - )} 480 - 481 - {/* we should use the reply lines here thats provided by UPR*/} 482 - <div style={{ maxWidth: 600, padding: 0 }}> 483 - {parents.map((parent, index) => ( 498 + <div ref={mainPostRef}> 484 499 <UniversalPostRendererATURILoader 485 - key={parent.uri} 486 - atUri={parent.uri} 487 - topReplyLine={index > 0} 488 - bottomReplyLine={true} 489 - bottomBorder={false} 500 + atUri={atUri!} 501 + detailed={true} 502 + topReplyLine={parentsLoading || parents.length > 0} 503 + nopics={!!nopics} 504 + lightboxCallback={lightboxCallback} 490 505 /> 491 - ))} 492 - </div> 493 - <div ref={mainPostRef}> 494 - <UniversalPostRendererATURILoader 495 - atUri={atUri} 496 - detailed={true} 497 - topReplyLine={parentsLoading || parents.length > 0} 498 - nopics={!!nopics} 499 - lightboxCallback={lightboxCallback} 500 - /> 501 - </div> 502 - <div 503 - style={{ 504 - maxWidth: 600, 505 - //margin: "0px auto 0", 506 - padding: 0, 507 - minHeight: "80dvh", 508 - paddingBottom: "20dvh", 509 - }} 510 - > 506 + </div> 511 507 <div 512 - className="text-gray-500 dark:text-gray-400 text-sm font-bold" 513 508 style={{ 514 - fontSize: 18, 515 - margin: "12px 16px 12px 16px", 516 - fontWeight: 600, 509 + maxWidth: 600, 510 + //margin: "0px auto 0", 511 + padding: 0, 512 + minHeight: "80dvh", 513 + paddingBottom: "20dvh", 517 514 }} 518 515 > 519 - Replies 516 + <div 517 + className="text-gray-500 dark:text-gray-400 text-sm font-bold" 518 + style={{ 519 + fontSize: 18, 520 + margin: "12px 16px 12px 16px", 521 + fontWeight: 600, 522 + }} 523 + > 524 + Replies 525 + </div> 526 + <div style={{ display: "flex", flexDirection: "column", gap: 0 }}> 527 + {replyAturis.length > 0 && 528 + replyAturis.map((reply) => { 529 + //const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 530 + return ( 531 + <UniversalPostRendererATURILoader 532 + key={reply} 533 + atUri={reply} 534 + maxReplies={4} 535 + /> 536 + ); 537 + })} 538 + {hasNextPage && ( 539 + <button 540 + onClick={() => fetchNextPage()} 541 + disabled={isFetchingNextPage} 542 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 543 + > 544 + {isFetchingNextPage ? "Loading..." : "Load More"} 545 + </button> 546 + )} 547 + </div> 520 548 </div> 521 - <div style={{ display: "flex", flexDirection: "column", gap: 0 }}> 522 - {replyAturis.length > 0 && 523 - replyAturis.map((reply) => { 524 - //const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 525 - return ( 526 - <UniversalPostRendererATURILoader 527 - key={reply} 528 - atUri={reply} 529 - maxReplies={4} 530 - /> 531 - ); 532 - })} 533 - {hasNextPage && ( 534 - <button 535 - onClick={() => fetchNextPage()} 536 - disabled={isFetchingNextPage} 537 - className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 538 - > 539 - {isFetchingNextPage ? "Loading..." : "Load More"} 540 - </button> 541 - )} 542 - </div> 543 - </div> 549 + </>)} 544 550 </> 545 551 ); 546 552 }
+118 -16
src/routes/settings.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 - import { useAtom } from "jotai"; 3 - import { Slider } from "radix-ui"; 2 + import { useAtom, useAtomValue, useSetAtom } from "jotai"; 3 + import { Slider, Switch } from "radix-ui"; 4 + import { useEffect, useState } from "react"; 4 5 5 6 import { Header } from "~/components/Header"; 6 7 import Login from "~/components/Login"; ··· 11 12 defaultImgCDN, 12 13 defaultslingshotURL, 13 14 defaultVideoCDN, 15 + enableBitesAtom, 16 + enableBridgyTextAtom, 17 + enableWafrnTextAtom, 14 18 hueAtom, 15 19 imgCDNAtom, 16 20 slingshotURLAtom, ··· 38 42 <Login /> 39 43 </div> 40 44 <div className="h-4" /> 45 + 46 + <SettingHeading title="Personalization" top /> 47 + <Hue /> 48 + 49 + <SettingHeading title="Network Configuration" /> 50 + <div className="flex flex-col px-4 pb-2"> 51 + <span className="text-md">Service Endpoints</span> 52 + <span className="text-sm text-gray-500 dark:text-gray-400"> 53 + Customize the servers to be used by the app 54 + </span> 55 + </div> 41 56 <TextInputSetting 42 57 atom={constellationURLAtom} 43 58 title={"Constellation"} ··· 67 82 init={defaultVideoCDN} 68 83 /> 69 84 70 - <Hue /> 71 - <p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm"> 72 - please restart/refresh the app if changes arent applying correctly 85 + <SettingHeading title="Experimental" /> 86 + <SwitchSetting 87 + atom={enableBitesAtom} 88 + title={"Bites"} 89 + description={"Enable Wafrn Bites to bite and be bitten by other people"} 90 + //init={false} 91 + /> 92 + <div className="h-4" /> 93 + <SwitchSetting 94 + atom={enableBridgyTextAtom} 95 + title={"Bridgy Text"} 96 + description={ 97 + "Show the original text of posts bridged from the Fediverse" 98 + } 99 + //init={false} 100 + /> 101 + <div className="h-4" /> 102 + <SwitchSetting 103 + atom={enableWafrnTextAtom} 104 + title={"Wafrn Text"} 105 + description={ 106 + "Show the original text of posts from Wafrn instances" 107 + } 108 + //init={false} 109 + /> 110 + <p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4"> 111 + Notice: Please restart/refresh the app if changes arent applying 112 + correctly 73 113 </p> 74 114 </> 75 115 ); 76 116 } 117 + 118 + export function SettingHeading({ 119 + title, 120 + top, 121 + }: { 122 + title: string; 123 + top?: boolean; 124 + }) { 125 + return ( 126 + <div 127 + className="px-4" 128 + style={{ marginTop: top ? 0 : 18, paddingBottom: 12 }} 129 + > 130 + <span className=" text-sm font-medium text-gray-500 dark:text-gray-400"> 131 + {title} 132 + </span> 133 + </div> 134 + ); 135 + } 136 + 137 + export function SwitchSetting({ 138 + atom, 139 + title, 140 + description, 141 + }: { 142 + atom: typeof enableBitesAtom; 143 + title?: string; 144 + description?: string; 145 + }) { 146 + const value = useAtomValue(atom); 147 + const setValue = useSetAtom(atom); 148 + 149 + const [hydrated, setHydrated] = useState(false); 150 + // eslint-disable-next-line react-hooks/set-state-in-effect 151 + useEffect(() => setHydrated(true), []); 152 + 153 + if (!hydrated) { 154 + // Avoid rendering Switch until we know storage is loaded 155 + return null; 156 + } 157 + 158 + return ( 159 + <div className="flex items-center gap-4 px-4 "> 160 + <label htmlFor={`switch-${title}`} className="flex flex-row flex-1"> 161 + <div className="flex flex-col"> 162 + <span className="text-md">{title}</span> 163 + <span className="text-sm text-gray-500 dark:text-gray-400"> 164 + {description} 165 + </span> 166 + </div> 167 + </label> 168 + 169 + <Switch.Root 170 + id={`switch-${title}`} 171 + checked={value} 172 + onCheckedChange={(v) => setValue(v)} 173 + className="m3switch root" 174 + > 175 + <Switch.Thumb className="m3switch thumb " /> 176 + </Switch.Root> 177 + </div> 178 + ); 179 + } 180 + 77 181 function Hue() { 78 182 const [hue, setHue] = useAtom(hueAtom); 79 183 return ( 80 - <div className="flex flex-col px-4 mt-4 "> 81 - <span className="z-10">Hue</span> 82 - <div className="flex flex-row items-center gap-4"> 83 - <SliderComponent 84 - atom={hueAtom} 85 - max={360} 86 - /> 184 + <div className="flex flex-col px-4"> 185 + <span className="z-[2] text-md">Hue</span> 186 + <span className="z-[2] text-sm text-gray-500 dark:text-gray-400"> 187 + Change the colors of the app 188 + </span> 189 + <div className="z-[1] flex flex-row items-center gap-4"> 190 + <SliderComponent atom={hueAtom} max={360} /> 87 191 <button 88 192 onClick={() => setHue(defaulthue ?? 28)} 89 193 className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 ··· 154 258 ); 155 259 } 156 260 157 - 158 261 interface SliderProps { 159 262 atom: typeof hueAtom; 160 263 min?: number; ··· 168 271 max = 100, 169 272 step = 1, 170 273 }) => { 171 - 172 - const [value, setValue] = useAtom(atom) 274 + const [value, setValue] = useAtom(atom); 173 275 174 276 return ( 175 277 <Slider.Root ··· 186 288 <Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" /> 187 289 </Slider.Root> 188 290 ); 189 - }; 291 + };
+137
src/styles/app.css
··· 197 197 198 198 /* focus ring */ 199 199 .m3input-field.m3input-label.m3input-border input:focus { 200 + /*border-color: var(--m3input-focus-color);*/ 200 201 border-color: var(--m3input-focus-color); 202 + box-shadow: 0 0 0 1px var(--m3input-focus-color); 201 203 /*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/ 202 204 } 203 205 ··· 231 233 /* radix i love you but like cmon man */ 232 234 body[data-scroll-locked]{ 233 235 margin-left: var(--removed-body-scroll-bar-size) !important; 236 + } 237 + 238 + /* radix tabs */ 239 + 240 + .m3tab[data-radix-collection-item] { 241 + flex: 1; 242 + display: flex; 243 + padding: 12px 8px; 244 + align-items: center; 245 + justify-content: center; 246 + color: var(--color-gray-500); 247 + font-weight: 500; 248 + &:hover { 249 + background-color: var(--color-gray-100); 250 + cursor: pointer; 251 + } 252 + &[aria-selected="true"] { 253 + color: var(--color-gray-950); 254 + &::before{ 255 + content: ""; 256 + position: absolute; 257 + width: min(80px, 80%); 258 + border-radius: 99px 99px 0px 0px ; 259 + height: 3px; 260 + bottom: 0; 261 + background-color: var(--color-gray-400); 262 + } 263 + } 264 + } 265 + 266 + @media (prefers-color-scheme: dark) { 267 + .m3tab[data-radix-collection-item] { 268 + color: var(--color-gray-400); 269 + &:hover { 270 + background-color: var(--color-gray-900); 271 + cursor: pointer; 272 + } 273 + &[aria-selected="true"] { 274 + color: var(--color-gray-50); 275 + &::before{ 276 + background-color: var(--color-gray-500); 277 + } 278 + } 279 + } 280 + } 281 + 282 + :root{ 283 + --thumb-size: 2rem; 284 + --root-size: 3.25rem; 285 + 286 + --switch-off-border: var(--color-gray-400); 287 + --switch-off-bg: var(--color-gray-200); 288 + --switch-off-thumb: var(--color-gray-400); 289 + 290 + 291 + --switch-on-bg: var(--color-gray-500); 292 + --switch-on-thumb: var(--color-gray-50); 293 + 294 + } 295 + @media (prefers-color-scheme: dark) { 296 + :root { 297 + --switch-off-border: var(--color-gray-500); 298 + --switch-off-bg: var(--color-gray-800); 299 + --switch-off-thumb: var(--color-gray-500); 300 + 301 + 302 + --switch-on-bg: var(--color-gray-400); 303 + --switch-on-thumb: var(--color-gray-700); 304 + } 305 + } 306 + 307 + .m3switch.root{ 308 + /*w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-gray-500 transition-colors*/ 309 + /*width: 40px; 310 + height: 24px;*/ 311 + 312 + inline-size: var(--root-size); 313 + block-size: 2rem; 314 + border-radius: 99999px; 315 + 316 + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; 317 + transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */ 318 + transition-duration: var(--default-transition-duration); /* 150ms */ 319 + 320 + .m3switch.thumb{ 321 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 322 + 323 + height: var(--thumb-size); 324 + width: var(--thumb-size); 325 + display: inline-block; 326 + border-radius: 9999px; 327 + 328 + transform-origin: center; 329 + 330 + transition-property: transform, translate, scale, rotate; 331 + transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */ 332 + transition-duration: var(--default-transition-duration); /* 150ms */ 333 + 334 + } 335 + 336 + &[aria-checked="true"] { 337 + box-shadow: inset 0px 0px 0px 1.8px transparent; 338 + background-color: var(--switch-on-bg); 339 + 340 + .m3switch.thumb{ 341 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 342 + 343 + background-color: var(--switch-on-thumb); 344 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.72); 345 + &:active { 346 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88); 347 + } 348 + 349 + } 350 + &:active .m3switch.thumb{ 351 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88); 352 + } 353 + } 354 + 355 + &[aria-checked="false"] { 356 + box-shadow: inset 0px 0px 0px 1.8px var(--switch-off-border); 357 + background-color: var(--switch-off-bg); 358 + .m3switch.thumb{ 359 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 360 + 361 + background-color: var(--switch-off-thumb); 362 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.5); 363 + &:active { 364 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88); 365 + } 366 + } 367 + &:active .m3switch.thumb{ 368 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88); 369 + } 370 + } 234 371 }
+67
src/utils/atoms.ts
··· 2 2 import { atomWithStorage } from "jotai/utils"; 3 3 import { useEffect } from "react"; 4 4 5 + import { type ProfilePostsFilter } from "~/routes/profile.$did"; 6 + 5 7 export const store = createStore(); 6 8 7 9 export const quickAuthAtom = atomWithStorage<string | null>( ··· 21 23 {} 22 24 ); 23 25 26 + type TabRouteScrollState = { 27 + activeTab: string; 28 + scrollPositions: Record<string, number>; 29 + }; 30 + /** 31 + * @deprecated should be safe to remove i think 32 + */ 33 + export const notificationsScrollAtom = atom<TabRouteScrollState>({ 34 + activeTab: "mentions", 35 + scrollPositions: {}, 36 + }); 37 + 38 + export type InteractionFilter = { 39 + likes: boolean; 40 + reposts: boolean; 41 + quotes: boolean; 42 + replies: boolean; 43 + showAll: boolean; 44 + }; 45 + const defaultFilters: InteractionFilter = { 46 + likes: true, 47 + reposts: true, 48 + quotes: true, 49 + replies: true, 50 + showAll: false, 51 + }; 52 + export const postInteractionsFiltersAtom = atomWithStorage<InteractionFilter>( 53 + "postInteractionsFilters", 54 + defaultFilters 55 + ); 56 + 57 + export const reusableTabRouteScrollAtom = atom<Record<string, TabRouteScrollState | undefined> | undefined>({}); 58 + 24 59 export const likedPostsAtom = atomWithStorage<Record<string, string>>( 25 60 "likedPosts", 26 61 {} 27 62 ); 63 + 64 + export type LikeRecord = { 65 + uri: string; // at://did/collection/rkey 66 + target: string; 67 + cid: string; 68 + }; 69 + 70 + export const internalLikedPostsAtom = atomWithStorage<Record<string, LikeRecord | null>>( 71 + "internal-liked-posts", 72 + {} 73 + ); 74 + 75 + export const profileChipsAtom = atom<Record<string, ProfilePostsFilter | null>>({}) 28 76 29 77 export const defaultconstellationURL = "constellation.microcosm.blue"; 30 78 export const constellationURLAtom = atomWithStorage<string>( ··· 80 128 // console.log("atom get ", initial); 81 129 // document.documentElement.style.setProperty(cssVar, initial.toString()); 82 130 // } 131 + 132 + 133 + 134 + // fun stuff 135 + 136 + export const enableBitesAtom = atomWithStorage<boolean>( 137 + "enableBitesAtom", 138 + false 139 + ); 140 + 141 + export const enableBridgyTextAtom = atomWithStorage<boolean>( 142 + "enableBridgyTextAtom", 143 + false 144 + ); 145 + 146 + export const enableWafrnTextAtom = atomWithStorage<boolean>( 147 + "enableWafrnTextAtom", 148 + false 149 + );
+34
src/utils/likeMutationQueue.ts
··· 1 + import { useAtom } from "jotai"; 2 + import { useCallback } from "react"; 3 + 4 + import { type LikeRecord,useLikeMutationQueue as useLikeMutationQueueFromProvider } from "~/providers/LikeMutationQueueProvider"; 5 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 6 + 7 + import { internalLikedPostsAtom } from "./atoms"; 8 + 9 + export function useFastLike(target: string, cid: string) { 10 + const { agent } = useAuth(); 11 + const { fastState, fastToggle, backfillState } = useLikeMutationQueueFromProvider(); 12 + 13 + const liked = fastState(target); 14 + const toggle = () => fastToggle(target, cid); 15 + /** 16 + * 17 + * @deprecated dont use it yet, will cause infinite rerenders 18 + */ 19 + const backfill = () => agent?.did && backfillState(target, agent.did); 20 + 21 + return { liked, toggle, backfill }; 22 + } 23 + 24 + export function useFastSetLikesFromFeed() { 25 + const [_, setLikedPosts] = useAtom(internalLikedPostsAtom); 26 + 27 + const setFastState = useCallback( 28 + (target: string, record: LikeRecord | null) => 29 + setLikedPosts((prev) => ({ ...prev, [target]: record })), 30 + [setLikedPosts] 31 + ); 32 + 33 + return { setFastState }; 34 + }
+2 -2
src/utils/oauthClient.ts
··· 1 1 import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser'; 2 2 3 - // i tried making this https://pds-nd.whey.party but cors is annoying as fuck 4 - const handleResolverPDS = 'https://bsky.social'; 3 + import resolvers from '../../public/resolvers.json' with { type: 'json' }; 4 + const handleResolverPDS = resolvers.resolver || 'https://bsky.social'; 5 5 6 6 // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 7 // @ts-ignore this should be fine ? the vite plugin should generate this before errors
+41 -17
src/utils/useQuery.ts
··· 284 284 gcTime: /*0//*/5 * 60 * 1000, 285 285 }); 286 286 } 287 + // todo do more of these instead of overloads since overloads sucks so much apparently 288 + export function useQueryConstellationLinksCountDistinctDids(query?: { 289 + method: "/links/count/distinct-dids"; 290 + target: string; 291 + collection: string; 292 + path: string; 293 + cursor?: string; 294 + }): UseQueryResult<linksCountResponse, Error> | undefined { 295 + //if (!query) return; 296 + const [constellationurl] = useAtom(constellationURLAtom) 297 + const queryres = useQuery( 298 + constructConstellationQuery(query && {constellation: constellationurl, ...query}) 299 + ) as unknown as UseQueryResult<linksCountResponse, Error>; 300 + if (!query) { 301 + return undefined as undefined; 302 + } 303 + return queryres as UseQueryResult<linksCountResponse, Error>; 304 + } 305 + 287 306 export function useQueryConstellation(query: { 288 307 method: "/links"; 289 308 target: string; ··· 352 371 ); 353 372 } 354 373 355 - type linksRecord = { 374 + export type linksRecord = { 356 375 did: string; 357 376 collection: string; 358 377 rkey: string; ··· 534 553 }[]; 535 554 }; 536 555 537 - export function constructAuthorFeedQuery(did: string, pdsUrl: string) { 556 + export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") { 538 557 return queryOptions({ 539 - queryKey: ['authorFeed', did], 558 + queryKey: ['authorFeed', did, collection], 540 559 queryFn: async ({ pageParam }: QueryFunctionContext) => { 541 560 const limit = 25; 542 561 543 562 const cursor = pageParam as string | undefined; 544 563 const cursorParam = cursor ? `&cursor=${cursor}` : ''; 545 564 546 - const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`; 565 + const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`; 547 566 548 567 const res = await fetch(url); 549 568 if (!res.ok) throw new Error("Failed to fetch author's posts"); ··· 553 572 }); 554 573 } 555 574 556 - export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) { 557 - const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!); 575 + export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) { 576 + const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection); 558 577 559 578 return useInfiniteQuery({ 560 579 queryKey, ··· 573 592 isAuthed: boolean; 574 593 pdsUrl?: string; 575 594 feedServiceDid?: string; 595 + // todo the hell is a unauthedfeedurl 596 + unauthedfeedurl?: string; 576 597 }) { 577 - const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 598 + const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = options; 578 599 579 600 return queryOptions({ 580 601 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], ··· 582 603 queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 583 604 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 584 605 585 - if (isAuthed) { 606 + if (isAuthed && !unauthedfeedurl) { 586 607 if (!agent || !pdsUrl || !feedServiceDid) { 587 608 throw new Error("Missing required info for authenticated feed fetch."); 588 609 } ··· 597 618 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 598 619 return (await res.json()) as FeedSkeletonPage; 599 620 } else { 600 - const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 621 + const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 601 622 const res = await fetch(url); 602 623 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 603 624 return (await res.json()) as FeedSkeletonPage; ··· 612 633 isAuthed: boolean; 613 634 pdsUrl?: string; 614 635 feedServiceDid?: string; 636 + unauthedfeedurl?: string; 615 637 }) { 616 638 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 617 639 ··· 622 644 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 623 645 staleTime: Infinity, 624 646 refetchOnWindowFocus: false, 625 - enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 647 + enabled: !!options.feedUri && (options.isAuthed ? (!!options.agent && !!options.pdsUrl || !!options.unauthedfeedurl) && !!options.feedServiceDid : true), 626 648 }), queryKey: queryKey}; 627 649 } 628 650 ··· 632 654 method: '/links' 633 655 target?: string 634 656 collection: string 635 - path: string 657 + path: string, 658 + staleMult?: number 636 659 }) { 637 - console.log( 638 - 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 639 - query, 640 - ) 660 + const safemult = query?.staleMult ?? 1; 661 + // console.log( 662 + // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 663 + // query, 664 + // ) 641 665 642 666 return infiniteQueryOptions({ 643 667 enabled: !!query?.target, ··· 675 699 return (lastPage as any)?.cursor ?? undefined 676 700 }, 677 701 initialPageParam: undefined, 678 - staleTime: 5 * 60 * 1000, 679 - gcTime: 5 * 60 * 1000, 702 + staleTime: 5 * 60 * 1000 * safemult, 703 + gcTime: 5 * 60 * 1000 * safemult, 680 704 }) 681 705 }
+5
vite.config.ts
··· 13 13 const PROD_URL = "https://reddwarf.app" 14 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 15 16 + const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party" 17 + const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social" 18 + 16 19 function shp(url: string): string { 17 20 return url.replace(/^https?:\/\//, ''); 18 21 } ··· 23 26 generateMetadataPlugin({ 24 27 prod: PROD_URL, 25 28 dev: DEV_URL, 29 + prodResolver: PROD_HANDLE_RESOLVER_PDS, 30 + devResolver: DEV_HANDLE_RESOLVER_PDS, 26 31 }), 27 32 TanStackRouterVite({ autoCodeSplitting: true }), 28 33 viteReact({