an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

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
+10 -1
README.md
··· 8 8 ## running dev and build 9 9 in the `vite.config.ts` file you should change these values 10 10 ```ts 11 - const PROD_URL = "https://reddwarf.whey.party" 11 + const PROD_URL = "https://reddwarf.app" 12 12 const DEV_URL = "https://local3768forumtest.whey.party" 13 13 ``` 14 14 the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work 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 + 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 17 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!
+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 + }
+1695
package-lock.json
··· 8 8 "dependencies": { 9 9 "@atproto/api": "^0.16.6", 10 10 "@atproto/oauth-client-browser": "^0.3.33", 11 + "@radix-ui/react-dialog": "^1.1.15", 12 + "@radix-ui/react-dropdown-menu": "^2.1.16", 13 + "@radix-ui/react-hover-card": "^1.1.15", 14 + "@radix-ui/react-slider": "^1.3.6", 11 15 "@tailwindcss/vite": "^4.0.6", 12 16 "@tanstack/query-sync-storage-persister": "^5.85.6", 13 17 "@tanstack/react-devtools": "^0.2.2", ··· 21 25 "idb-keyval": "^6.2.2", 22 26 "jotai": "^2.13.1", 23 27 "npm": "^11.6.2", 28 + "radix-ui": "^1.4.3", 24 29 "react": "^19.0.0", 25 30 "react-dom": "^19.0.0", 26 31 "react-player": "^3.3.2", 32 + "sonner": "^2.0.7", 27 33 "tailwindcss": "^4.0.6", 28 34 "tanstack-router-keepalive": "^1.0.0" 29 35 }, ··· 1592 1598 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 1593 1599 } 1594 1600 }, 1601 + "node_modules/@floating-ui/core": { 1602 + "version": "1.7.3", 1603 + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", 1604 + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", 1605 + "dependencies": { 1606 + "@floating-ui/utils": "^0.2.10" 1607 + } 1608 + }, 1609 + "node_modules/@floating-ui/dom": { 1610 + "version": "1.7.4", 1611 + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", 1612 + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", 1613 + "dependencies": { 1614 + "@floating-ui/core": "^1.7.3", 1615 + "@floating-ui/utils": "^0.2.10" 1616 + } 1617 + }, 1618 + "node_modules/@floating-ui/react-dom": { 1619 + "version": "2.1.6", 1620 + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", 1621 + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", 1622 + "dependencies": { 1623 + "@floating-ui/dom": "^1.7.4" 1624 + }, 1625 + "peerDependencies": { 1626 + "react": ">=16.8.0", 1627 + "react-dom": ">=16.8.0" 1628 + } 1629 + }, 1630 + "node_modules/@floating-ui/utils": { 1631 + "version": "0.2.10", 1632 + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", 1633 + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" 1634 + }, 1595 1635 "node_modules/@humanfs/core": { 1596 1636 "version": "0.19.1", 1597 1637 "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", ··· 1895 1935 "node": ">= 8" 1896 1936 } 1897 1937 }, 1938 + "node_modules/@radix-ui/number": { 1939 + "version": "1.1.1", 1940 + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", 1941 + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" 1942 + }, 1943 + "node_modules/@radix-ui/primitive": { 1944 + "version": "1.1.3", 1945 + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", 1946 + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" 1947 + }, 1948 + "node_modules/@radix-ui/react-accessible-icon": { 1949 + "version": "1.1.7", 1950 + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", 1951 + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", 1952 + "dependencies": { 1953 + "@radix-ui/react-visually-hidden": "1.2.3" 1954 + }, 1955 + "peerDependencies": { 1956 + "@types/react": "*", 1957 + "@types/react-dom": "*", 1958 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 1959 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 1960 + }, 1961 + "peerDependenciesMeta": { 1962 + "@types/react": { 1963 + "optional": true 1964 + }, 1965 + "@types/react-dom": { 1966 + "optional": true 1967 + } 1968 + } 1969 + }, 1970 + "node_modules/@radix-ui/react-accordion": { 1971 + "version": "1.2.12", 1972 + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", 1973 + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", 1974 + "dependencies": { 1975 + "@radix-ui/primitive": "1.1.3", 1976 + "@radix-ui/react-collapsible": "1.1.12", 1977 + "@radix-ui/react-collection": "1.1.7", 1978 + "@radix-ui/react-compose-refs": "1.1.2", 1979 + "@radix-ui/react-context": "1.1.2", 1980 + "@radix-ui/react-direction": "1.1.1", 1981 + "@radix-ui/react-id": "1.1.1", 1982 + "@radix-ui/react-primitive": "2.1.3", 1983 + "@radix-ui/react-use-controllable-state": "1.2.2" 1984 + }, 1985 + "peerDependencies": { 1986 + "@types/react": "*", 1987 + "@types/react-dom": "*", 1988 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 1989 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 1990 + }, 1991 + "peerDependenciesMeta": { 1992 + "@types/react": { 1993 + "optional": true 1994 + }, 1995 + "@types/react-dom": { 1996 + "optional": true 1997 + } 1998 + } 1999 + }, 2000 + "node_modules/@radix-ui/react-alert-dialog": { 2001 + "version": "1.1.15", 2002 + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", 2003 + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", 2004 + "dependencies": { 2005 + "@radix-ui/primitive": "1.1.3", 2006 + "@radix-ui/react-compose-refs": "1.1.2", 2007 + "@radix-ui/react-context": "1.1.2", 2008 + "@radix-ui/react-dialog": "1.1.15", 2009 + "@radix-ui/react-primitive": "2.1.3", 2010 + "@radix-ui/react-slot": "1.2.3" 2011 + }, 2012 + "peerDependencies": { 2013 + "@types/react": "*", 2014 + "@types/react-dom": "*", 2015 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2016 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2017 + }, 2018 + "peerDependenciesMeta": { 2019 + "@types/react": { 2020 + "optional": true 2021 + }, 2022 + "@types/react-dom": { 2023 + "optional": true 2024 + } 2025 + } 2026 + }, 2027 + "node_modules/@radix-ui/react-arrow": { 2028 + "version": "1.1.7", 2029 + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", 2030 + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", 2031 + "dependencies": { 2032 + "@radix-ui/react-primitive": "2.1.3" 2033 + }, 2034 + "peerDependencies": { 2035 + "@types/react": "*", 2036 + "@types/react-dom": "*", 2037 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2038 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2039 + }, 2040 + "peerDependenciesMeta": { 2041 + "@types/react": { 2042 + "optional": true 2043 + }, 2044 + "@types/react-dom": { 2045 + "optional": true 2046 + } 2047 + } 2048 + }, 2049 + "node_modules/@radix-ui/react-aspect-ratio": { 2050 + "version": "1.1.7", 2051 + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", 2052 + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", 2053 + "dependencies": { 2054 + "@radix-ui/react-primitive": "2.1.3" 2055 + }, 2056 + "peerDependencies": { 2057 + "@types/react": "*", 2058 + "@types/react-dom": "*", 2059 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2060 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2061 + }, 2062 + "peerDependenciesMeta": { 2063 + "@types/react": { 2064 + "optional": true 2065 + }, 2066 + "@types/react-dom": { 2067 + "optional": true 2068 + } 2069 + } 2070 + }, 2071 + "node_modules/@radix-ui/react-avatar": { 2072 + "version": "1.1.10", 2073 + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", 2074 + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", 2075 + "dependencies": { 2076 + "@radix-ui/react-context": "1.1.2", 2077 + "@radix-ui/react-primitive": "2.1.3", 2078 + "@radix-ui/react-use-callback-ref": "1.1.1", 2079 + "@radix-ui/react-use-is-hydrated": "0.1.0", 2080 + "@radix-ui/react-use-layout-effect": "1.1.1" 2081 + }, 2082 + "peerDependencies": { 2083 + "@types/react": "*", 2084 + "@types/react-dom": "*", 2085 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2086 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2087 + }, 2088 + "peerDependenciesMeta": { 2089 + "@types/react": { 2090 + "optional": true 2091 + }, 2092 + "@types/react-dom": { 2093 + "optional": true 2094 + } 2095 + } 2096 + }, 2097 + "node_modules/@radix-ui/react-checkbox": { 2098 + "version": "1.3.3", 2099 + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", 2100 + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", 2101 + "dependencies": { 2102 + "@radix-ui/primitive": "1.1.3", 2103 + "@radix-ui/react-compose-refs": "1.1.2", 2104 + "@radix-ui/react-context": "1.1.2", 2105 + "@radix-ui/react-presence": "1.1.5", 2106 + "@radix-ui/react-primitive": "2.1.3", 2107 + "@radix-ui/react-use-controllable-state": "1.2.2", 2108 + "@radix-ui/react-use-previous": "1.1.1", 2109 + "@radix-ui/react-use-size": "1.1.1" 2110 + }, 2111 + "peerDependencies": { 2112 + "@types/react": "*", 2113 + "@types/react-dom": "*", 2114 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2115 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2116 + }, 2117 + "peerDependenciesMeta": { 2118 + "@types/react": { 2119 + "optional": true 2120 + }, 2121 + "@types/react-dom": { 2122 + "optional": true 2123 + } 2124 + } 2125 + }, 2126 + "node_modules/@radix-ui/react-collapsible": { 2127 + "version": "1.1.12", 2128 + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", 2129 + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", 2130 + "dependencies": { 2131 + "@radix-ui/primitive": "1.1.3", 2132 + "@radix-ui/react-compose-refs": "1.1.2", 2133 + "@radix-ui/react-context": "1.1.2", 2134 + "@radix-ui/react-id": "1.1.1", 2135 + "@radix-ui/react-presence": "1.1.5", 2136 + "@radix-ui/react-primitive": "2.1.3", 2137 + "@radix-ui/react-use-controllable-state": "1.2.2", 2138 + "@radix-ui/react-use-layout-effect": "1.1.1" 2139 + }, 2140 + "peerDependencies": { 2141 + "@types/react": "*", 2142 + "@types/react-dom": "*", 2143 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2144 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2145 + }, 2146 + "peerDependenciesMeta": { 2147 + "@types/react": { 2148 + "optional": true 2149 + }, 2150 + "@types/react-dom": { 2151 + "optional": true 2152 + } 2153 + } 2154 + }, 2155 + "node_modules/@radix-ui/react-collection": { 2156 + "version": "1.1.7", 2157 + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", 2158 + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", 2159 + "dependencies": { 2160 + "@radix-ui/react-compose-refs": "1.1.2", 2161 + "@radix-ui/react-context": "1.1.2", 2162 + "@radix-ui/react-primitive": "2.1.3", 2163 + "@radix-ui/react-slot": "1.2.3" 2164 + }, 2165 + "peerDependencies": { 2166 + "@types/react": "*", 2167 + "@types/react-dom": "*", 2168 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2169 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2170 + }, 2171 + "peerDependenciesMeta": { 2172 + "@types/react": { 2173 + "optional": true 2174 + }, 2175 + "@types/react-dom": { 2176 + "optional": true 2177 + } 2178 + } 2179 + }, 2180 + "node_modules/@radix-ui/react-compose-refs": { 2181 + "version": "1.1.2", 2182 + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", 2183 + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", 2184 + "peerDependencies": { 2185 + "@types/react": "*", 2186 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2187 + }, 2188 + "peerDependenciesMeta": { 2189 + "@types/react": { 2190 + "optional": true 2191 + } 2192 + } 2193 + }, 2194 + "node_modules/@radix-ui/react-context": { 2195 + "version": "1.1.2", 2196 + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", 2197 + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", 2198 + "peerDependencies": { 2199 + "@types/react": "*", 2200 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2201 + }, 2202 + "peerDependenciesMeta": { 2203 + "@types/react": { 2204 + "optional": true 2205 + } 2206 + } 2207 + }, 2208 + "node_modules/@radix-ui/react-context-menu": { 2209 + "version": "2.2.16", 2210 + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", 2211 + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", 2212 + "dependencies": { 2213 + "@radix-ui/primitive": "1.1.3", 2214 + "@radix-ui/react-context": "1.1.2", 2215 + "@radix-ui/react-menu": "2.1.16", 2216 + "@radix-ui/react-primitive": "2.1.3", 2217 + "@radix-ui/react-use-callback-ref": "1.1.1", 2218 + "@radix-ui/react-use-controllable-state": "1.2.2" 2219 + }, 2220 + "peerDependencies": { 2221 + "@types/react": "*", 2222 + "@types/react-dom": "*", 2223 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2224 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2225 + }, 2226 + "peerDependenciesMeta": { 2227 + "@types/react": { 2228 + "optional": true 2229 + }, 2230 + "@types/react-dom": { 2231 + "optional": true 2232 + } 2233 + } 2234 + }, 2235 + "node_modules/@radix-ui/react-dialog": { 2236 + "version": "1.1.15", 2237 + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", 2238 + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", 2239 + "dependencies": { 2240 + "@radix-ui/primitive": "1.1.3", 2241 + "@radix-ui/react-compose-refs": "1.1.2", 2242 + "@radix-ui/react-context": "1.1.2", 2243 + "@radix-ui/react-dismissable-layer": "1.1.11", 2244 + "@radix-ui/react-focus-guards": "1.1.3", 2245 + "@radix-ui/react-focus-scope": "1.1.7", 2246 + "@radix-ui/react-id": "1.1.1", 2247 + "@radix-ui/react-portal": "1.1.9", 2248 + "@radix-ui/react-presence": "1.1.5", 2249 + "@radix-ui/react-primitive": "2.1.3", 2250 + "@radix-ui/react-slot": "1.2.3", 2251 + "@radix-ui/react-use-controllable-state": "1.2.2", 2252 + "aria-hidden": "^1.2.4", 2253 + "react-remove-scroll": "^2.6.3" 2254 + }, 2255 + "peerDependencies": { 2256 + "@types/react": "*", 2257 + "@types/react-dom": "*", 2258 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2259 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2260 + }, 2261 + "peerDependenciesMeta": { 2262 + "@types/react": { 2263 + "optional": true 2264 + }, 2265 + "@types/react-dom": { 2266 + "optional": true 2267 + } 2268 + } 2269 + }, 2270 + "node_modules/@radix-ui/react-direction": { 2271 + "version": "1.1.1", 2272 + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", 2273 + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", 2274 + "peerDependencies": { 2275 + "@types/react": "*", 2276 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2277 + }, 2278 + "peerDependenciesMeta": { 2279 + "@types/react": { 2280 + "optional": true 2281 + } 2282 + } 2283 + }, 2284 + "node_modules/@radix-ui/react-dismissable-layer": { 2285 + "version": "1.1.11", 2286 + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", 2287 + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", 2288 + "dependencies": { 2289 + "@radix-ui/primitive": "1.1.3", 2290 + "@radix-ui/react-compose-refs": "1.1.2", 2291 + "@radix-ui/react-primitive": "2.1.3", 2292 + "@radix-ui/react-use-callback-ref": "1.1.1", 2293 + "@radix-ui/react-use-escape-keydown": "1.1.1" 2294 + }, 2295 + "peerDependencies": { 2296 + "@types/react": "*", 2297 + "@types/react-dom": "*", 2298 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2299 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2300 + }, 2301 + "peerDependenciesMeta": { 2302 + "@types/react": { 2303 + "optional": true 2304 + }, 2305 + "@types/react-dom": { 2306 + "optional": true 2307 + } 2308 + } 2309 + }, 2310 + "node_modules/@radix-ui/react-dropdown-menu": { 2311 + "version": "2.1.16", 2312 + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", 2313 + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", 2314 + "dependencies": { 2315 + "@radix-ui/primitive": "1.1.3", 2316 + "@radix-ui/react-compose-refs": "1.1.2", 2317 + "@radix-ui/react-context": "1.1.2", 2318 + "@radix-ui/react-id": "1.1.1", 2319 + "@radix-ui/react-menu": "2.1.16", 2320 + "@radix-ui/react-primitive": "2.1.3", 2321 + "@radix-ui/react-use-controllable-state": "1.2.2" 2322 + }, 2323 + "peerDependencies": { 2324 + "@types/react": "*", 2325 + "@types/react-dom": "*", 2326 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2327 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2328 + }, 2329 + "peerDependenciesMeta": { 2330 + "@types/react": { 2331 + "optional": true 2332 + }, 2333 + "@types/react-dom": { 2334 + "optional": true 2335 + } 2336 + } 2337 + }, 2338 + "node_modules/@radix-ui/react-focus-guards": { 2339 + "version": "1.1.3", 2340 + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", 2341 + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", 2342 + "peerDependencies": { 2343 + "@types/react": "*", 2344 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2345 + }, 2346 + "peerDependenciesMeta": { 2347 + "@types/react": { 2348 + "optional": true 2349 + } 2350 + } 2351 + }, 2352 + "node_modules/@radix-ui/react-focus-scope": { 2353 + "version": "1.1.7", 2354 + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", 2355 + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", 2356 + "dependencies": { 2357 + "@radix-ui/react-compose-refs": "1.1.2", 2358 + "@radix-ui/react-primitive": "2.1.3", 2359 + "@radix-ui/react-use-callback-ref": "1.1.1" 2360 + }, 2361 + "peerDependencies": { 2362 + "@types/react": "*", 2363 + "@types/react-dom": "*", 2364 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2365 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2366 + }, 2367 + "peerDependenciesMeta": { 2368 + "@types/react": { 2369 + "optional": true 2370 + }, 2371 + "@types/react-dom": { 2372 + "optional": true 2373 + } 2374 + } 2375 + }, 2376 + "node_modules/@radix-ui/react-form": { 2377 + "version": "0.1.8", 2378 + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", 2379 + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", 2380 + "dependencies": { 2381 + "@radix-ui/primitive": "1.1.3", 2382 + "@radix-ui/react-compose-refs": "1.1.2", 2383 + "@radix-ui/react-context": "1.1.2", 2384 + "@radix-ui/react-id": "1.1.1", 2385 + "@radix-ui/react-label": "2.1.7", 2386 + "@radix-ui/react-primitive": "2.1.3" 2387 + }, 2388 + "peerDependencies": { 2389 + "@types/react": "*", 2390 + "@types/react-dom": "*", 2391 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2392 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2393 + }, 2394 + "peerDependenciesMeta": { 2395 + "@types/react": { 2396 + "optional": true 2397 + }, 2398 + "@types/react-dom": { 2399 + "optional": true 2400 + } 2401 + } 2402 + }, 2403 + "node_modules/@radix-ui/react-hover-card": { 2404 + "version": "1.1.15", 2405 + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", 2406 + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", 2407 + "license": "MIT", 2408 + "dependencies": { 2409 + "@radix-ui/primitive": "1.1.3", 2410 + "@radix-ui/react-compose-refs": "1.1.2", 2411 + "@radix-ui/react-context": "1.1.2", 2412 + "@radix-ui/react-dismissable-layer": "1.1.11", 2413 + "@radix-ui/react-popper": "1.2.8", 2414 + "@radix-ui/react-portal": "1.1.9", 2415 + "@radix-ui/react-presence": "1.1.5", 2416 + "@radix-ui/react-primitive": "2.1.3", 2417 + "@radix-ui/react-use-controllable-state": "1.2.2" 2418 + }, 2419 + "peerDependencies": { 2420 + "@types/react": "*", 2421 + "@types/react-dom": "*", 2422 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2423 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2424 + }, 2425 + "peerDependenciesMeta": { 2426 + "@types/react": { 2427 + "optional": true 2428 + }, 2429 + "@types/react-dom": { 2430 + "optional": true 2431 + } 2432 + } 2433 + }, 2434 + "node_modules/@radix-ui/react-id": { 2435 + "version": "1.1.1", 2436 + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", 2437 + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", 2438 + "dependencies": { 2439 + "@radix-ui/react-use-layout-effect": "1.1.1" 2440 + }, 2441 + "peerDependencies": { 2442 + "@types/react": "*", 2443 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2444 + }, 2445 + "peerDependenciesMeta": { 2446 + "@types/react": { 2447 + "optional": true 2448 + } 2449 + } 2450 + }, 2451 + "node_modules/@radix-ui/react-label": { 2452 + "version": "2.1.7", 2453 + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", 2454 + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", 2455 + "dependencies": { 2456 + "@radix-ui/react-primitive": "2.1.3" 2457 + }, 2458 + "peerDependencies": { 2459 + "@types/react": "*", 2460 + "@types/react-dom": "*", 2461 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2462 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2463 + }, 2464 + "peerDependenciesMeta": { 2465 + "@types/react": { 2466 + "optional": true 2467 + }, 2468 + "@types/react-dom": { 2469 + "optional": true 2470 + } 2471 + } 2472 + }, 2473 + "node_modules/@radix-ui/react-menu": { 2474 + "version": "2.1.16", 2475 + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", 2476 + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", 2477 + "dependencies": { 2478 + "@radix-ui/primitive": "1.1.3", 2479 + "@radix-ui/react-collection": "1.1.7", 2480 + "@radix-ui/react-compose-refs": "1.1.2", 2481 + "@radix-ui/react-context": "1.1.2", 2482 + "@radix-ui/react-direction": "1.1.1", 2483 + "@radix-ui/react-dismissable-layer": "1.1.11", 2484 + "@radix-ui/react-focus-guards": "1.1.3", 2485 + "@radix-ui/react-focus-scope": "1.1.7", 2486 + "@radix-ui/react-id": "1.1.1", 2487 + "@radix-ui/react-popper": "1.2.8", 2488 + "@radix-ui/react-portal": "1.1.9", 2489 + "@radix-ui/react-presence": "1.1.5", 2490 + "@radix-ui/react-primitive": "2.1.3", 2491 + "@radix-ui/react-roving-focus": "1.1.11", 2492 + "@radix-ui/react-slot": "1.2.3", 2493 + "@radix-ui/react-use-callback-ref": "1.1.1", 2494 + "aria-hidden": "^1.2.4", 2495 + "react-remove-scroll": "^2.6.3" 2496 + }, 2497 + "peerDependencies": { 2498 + "@types/react": "*", 2499 + "@types/react-dom": "*", 2500 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2501 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2502 + }, 2503 + "peerDependenciesMeta": { 2504 + "@types/react": { 2505 + "optional": true 2506 + }, 2507 + "@types/react-dom": { 2508 + "optional": true 2509 + } 2510 + } 2511 + }, 2512 + "node_modules/@radix-ui/react-menubar": { 2513 + "version": "1.1.16", 2514 + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", 2515 + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", 2516 + "dependencies": { 2517 + "@radix-ui/primitive": "1.1.3", 2518 + "@radix-ui/react-collection": "1.1.7", 2519 + "@radix-ui/react-compose-refs": "1.1.2", 2520 + "@radix-ui/react-context": "1.1.2", 2521 + "@radix-ui/react-direction": "1.1.1", 2522 + "@radix-ui/react-id": "1.1.1", 2523 + "@radix-ui/react-menu": "2.1.16", 2524 + "@radix-ui/react-primitive": "2.1.3", 2525 + "@radix-ui/react-roving-focus": "1.1.11", 2526 + "@radix-ui/react-use-controllable-state": "1.2.2" 2527 + }, 2528 + "peerDependencies": { 2529 + "@types/react": "*", 2530 + "@types/react-dom": "*", 2531 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2532 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2533 + }, 2534 + "peerDependenciesMeta": { 2535 + "@types/react": { 2536 + "optional": true 2537 + }, 2538 + "@types/react-dom": { 2539 + "optional": true 2540 + } 2541 + } 2542 + }, 2543 + "node_modules/@radix-ui/react-navigation-menu": { 2544 + "version": "1.2.14", 2545 + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", 2546 + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", 2547 + "dependencies": { 2548 + "@radix-ui/primitive": "1.1.3", 2549 + "@radix-ui/react-collection": "1.1.7", 2550 + "@radix-ui/react-compose-refs": "1.1.2", 2551 + "@radix-ui/react-context": "1.1.2", 2552 + "@radix-ui/react-direction": "1.1.1", 2553 + "@radix-ui/react-dismissable-layer": "1.1.11", 2554 + "@radix-ui/react-id": "1.1.1", 2555 + "@radix-ui/react-presence": "1.1.5", 2556 + "@radix-ui/react-primitive": "2.1.3", 2557 + "@radix-ui/react-use-callback-ref": "1.1.1", 2558 + "@radix-ui/react-use-controllable-state": "1.2.2", 2559 + "@radix-ui/react-use-layout-effect": "1.1.1", 2560 + "@radix-ui/react-use-previous": "1.1.1", 2561 + "@radix-ui/react-visually-hidden": "1.2.3" 2562 + }, 2563 + "peerDependencies": { 2564 + "@types/react": "*", 2565 + "@types/react-dom": "*", 2566 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2567 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2568 + }, 2569 + "peerDependenciesMeta": { 2570 + "@types/react": { 2571 + "optional": true 2572 + }, 2573 + "@types/react-dom": { 2574 + "optional": true 2575 + } 2576 + } 2577 + }, 2578 + "node_modules/@radix-ui/react-one-time-password-field": { 2579 + "version": "0.1.8", 2580 + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", 2581 + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", 2582 + "dependencies": { 2583 + "@radix-ui/number": "1.1.1", 2584 + "@radix-ui/primitive": "1.1.3", 2585 + "@radix-ui/react-collection": "1.1.7", 2586 + "@radix-ui/react-compose-refs": "1.1.2", 2587 + "@radix-ui/react-context": "1.1.2", 2588 + "@radix-ui/react-direction": "1.1.1", 2589 + "@radix-ui/react-primitive": "2.1.3", 2590 + "@radix-ui/react-roving-focus": "1.1.11", 2591 + "@radix-ui/react-use-controllable-state": "1.2.2", 2592 + "@radix-ui/react-use-effect-event": "0.0.2", 2593 + "@radix-ui/react-use-is-hydrated": "0.1.0", 2594 + "@radix-ui/react-use-layout-effect": "1.1.1" 2595 + }, 2596 + "peerDependencies": { 2597 + "@types/react": "*", 2598 + "@types/react-dom": "*", 2599 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2600 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2601 + }, 2602 + "peerDependenciesMeta": { 2603 + "@types/react": { 2604 + "optional": true 2605 + }, 2606 + "@types/react-dom": { 2607 + "optional": true 2608 + } 2609 + } 2610 + }, 2611 + "node_modules/@radix-ui/react-password-toggle-field": { 2612 + "version": "0.1.3", 2613 + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", 2614 + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", 2615 + "dependencies": { 2616 + "@radix-ui/primitive": "1.1.3", 2617 + "@radix-ui/react-compose-refs": "1.1.2", 2618 + "@radix-ui/react-context": "1.1.2", 2619 + "@radix-ui/react-id": "1.1.1", 2620 + "@radix-ui/react-primitive": "2.1.3", 2621 + "@radix-ui/react-use-controllable-state": "1.2.2", 2622 + "@radix-ui/react-use-effect-event": "0.0.2", 2623 + "@radix-ui/react-use-is-hydrated": "0.1.0" 2624 + }, 2625 + "peerDependencies": { 2626 + "@types/react": "*", 2627 + "@types/react-dom": "*", 2628 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2629 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2630 + }, 2631 + "peerDependenciesMeta": { 2632 + "@types/react": { 2633 + "optional": true 2634 + }, 2635 + "@types/react-dom": { 2636 + "optional": true 2637 + } 2638 + } 2639 + }, 2640 + "node_modules/@radix-ui/react-popover": { 2641 + "version": "1.1.15", 2642 + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", 2643 + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", 2644 + "dependencies": { 2645 + "@radix-ui/primitive": "1.1.3", 2646 + "@radix-ui/react-compose-refs": "1.1.2", 2647 + "@radix-ui/react-context": "1.1.2", 2648 + "@radix-ui/react-dismissable-layer": "1.1.11", 2649 + "@radix-ui/react-focus-guards": "1.1.3", 2650 + "@radix-ui/react-focus-scope": "1.1.7", 2651 + "@radix-ui/react-id": "1.1.1", 2652 + "@radix-ui/react-popper": "1.2.8", 2653 + "@radix-ui/react-portal": "1.1.9", 2654 + "@radix-ui/react-presence": "1.1.5", 2655 + "@radix-ui/react-primitive": "2.1.3", 2656 + "@radix-ui/react-slot": "1.2.3", 2657 + "@radix-ui/react-use-controllable-state": "1.2.2", 2658 + "aria-hidden": "^1.2.4", 2659 + "react-remove-scroll": "^2.6.3" 2660 + }, 2661 + "peerDependencies": { 2662 + "@types/react": "*", 2663 + "@types/react-dom": "*", 2664 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2665 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2666 + }, 2667 + "peerDependenciesMeta": { 2668 + "@types/react": { 2669 + "optional": true 2670 + }, 2671 + "@types/react-dom": { 2672 + "optional": true 2673 + } 2674 + } 2675 + }, 2676 + "node_modules/@radix-ui/react-popper": { 2677 + "version": "1.2.8", 2678 + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", 2679 + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", 2680 + "dependencies": { 2681 + "@floating-ui/react-dom": "^2.0.0", 2682 + "@radix-ui/react-arrow": "1.1.7", 2683 + "@radix-ui/react-compose-refs": "1.1.2", 2684 + "@radix-ui/react-context": "1.1.2", 2685 + "@radix-ui/react-primitive": "2.1.3", 2686 + "@radix-ui/react-use-callback-ref": "1.1.1", 2687 + "@radix-ui/react-use-layout-effect": "1.1.1", 2688 + "@radix-ui/react-use-rect": "1.1.1", 2689 + "@radix-ui/react-use-size": "1.1.1", 2690 + "@radix-ui/rect": "1.1.1" 2691 + }, 2692 + "peerDependencies": { 2693 + "@types/react": "*", 2694 + "@types/react-dom": "*", 2695 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2696 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2697 + }, 2698 + "peerDependenciesMeta": { 2699 + "@types/react": { 2700 + "optional": true 2701 + }, 2702 + "@types/react-dom": { 2703 + "optional": true 2704 + } 2705 + } 2706 + }, 2707 + "node_modules/@radix-ui/react-portal": { 2708 + "version": "1.1.9", 2709 + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", 2710 + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", 2711 + "dependencies": { 2712 + "@radix-ui/react-primitive": "2.1.3", 2713 + "@radix-ui/react-use-layout-effect": "1.1.1" 2714 + }, 2715 + "peerDependencies": { 2716 + "@types/react": "*", 2717 + "@types/react-dom": "*", 2718 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2719 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2720 + }, 2721 + "peerDependenciesMeta": { 2722 + "@types/react": { 2723 + "optional": true 2724 + }, 2725 + "@types/react-dom": { 2726 + "optional": true 2727 + } 2728 + } 2729 + }, 2730 + "node_modules/@radix-ui/react-presence": { 2731 + "version": "1.1.5", 2732 + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", 2733 + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", 2734 + "dependencies": { 2735 + "@radix-ui/react-compose-refs": "1.1.2", 2736 + "@radix-ui/react-use-layout-effect": "1.1.1" 2737 + }, 2738 + "peerDependencies": { 2739 + "@types/react": "*", 2740 + "@types/react-dom": "*", 2741 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2742 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2743 + }, 2744 + "peerDependenciesMeta": { 2745 + "@types/react": { 2746 + "optional": true 2747 + }, 2748 + "@types/react-dom": { 2749 + "optional": true 2750 + } 2751 + } 2752 + }, 2753 + "node_modules/@radix-ui/react-primitive": { 2754 + "version": "2.1.3", 2755 + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", 2756 + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", 2757 + "dependencies": { 2758 + "@radix-ui/react-slot": "1.2.3" 2759 + }, 2760 + "peerDependencies": { 2761 + "@types/react": "*", 2762 + "@types/react-dom": "*", 2763 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2764 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2765 + }, 2766 + "peerDependenciesMeta": { 2767 + "@types/react": { 2768 + "optional": true 2769 + }, 2770 + "@types/react-dom": { 2771 + "optional": true 2772 + } 2773 + } 2774 + }, 2775 + "node_modules/@radix-ui/react-progress": { 2776 + "version": "1.1.7", 2777 + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", 2778 + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", 2779 + "dependencies": { 2780 + "@radix-ui/react-context": "1.1.2", 2781 + "@radix-ui/react-primitive": "2.1.3" 2782 + }, 2783 + "peerDependencies": { 2784 + "@types/react": "*", 2785 + "@types/react-dom": "*", 2786 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2787 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2788 + }, 2789 + "peerDependenciesMeta": { 2790 + "@types/react": { 2791 + "optional": true 2792 + }, 2793 + "@types/react-dom": { 2794 + "optional": true 2795 + } 2796 + } 2797 + }, 2798 + "node_modules/@radix-ui/react-radio-group": { 2799 + "version": "1.3.8", 2800 + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", 2801 + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", 2802 + "dependencies": { 2803 + "@radix-ui/primitive": "1.1.3", 2804 + "@radix-ui/react-compose-refs": "1.1.2", 2805 + "@radix-ui/react-context": "1.1.2", 2806 + "@radix-ui/react-direction": "1.1.1", 2807 + "@radix-ui/react-presence": "1.1.5", 2808 + "@radix-ui/react-primitive": "2.1.3", 2809 + "@radix-ui/react-roving-focus": "1.1.11", 2810 + "@radix-ui/react-use-controllable-state": "1.2.2", 2811 + "@radix-ui/react-use-previous": "1.1.1", 2812 + "@radix-ui/react-use-size": "1.1.1" 2813 + }, 2814 + "peerDependencies": { 2815 + "@types/react": "*", 2816 + "@types/react-dom": "*", 2817 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2818 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2819 + }, 2820 + "peerDependenciesMeta": { 2821 + "@types/react": { 2822 + "optional": true 2823 + }, 2824 + "@types/react-dom": { 2825 + "optional": true 2826 + } 2827 + } 2828 + }, 2829 + "node_modules/@radix-ui/react-roving-focus": { 2830 + "version": "1.1.11", 2831 + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", 2832 + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", 2833 + "dependencies": { 2834 + "@radix-ui/primitive": "1.1.3", 2835 + "@radix-ui/react-collection": "1.1.7", 2836 + "@radix-ui/react-compose-refs": "1.1.2", 2837 + "@radix-ui/react-context": "1.1.2", 2838 + "@radix-ui/react-direction": "1.1.1", 2839 + "@radix-ui/react-id": "1.1.1", 2840 + "@radix-ui/react-primitive": "2.1.3", 2841 + "@radix-ui/react-use-callback-ref": "1.1.1", 2842 + "@radix-ui/react-use-controllable-state": "1.2.2" 2843 + }, 2844 + "peerDependencies": { 2845 + "@types/react": "*", 2846 + "@types/react-dom": "*", 2847 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2848 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2849 + }, 2850 + "peerDependenciesMeta": { 2851 + "@types/react": { 2852 + "optional": true 2853 + }, 2854 + "@types/react-dom": { 2855 + "optional": true 2856 + } 2857 + } 2858 + }, 2859 + "node_modules/@radix-ui/react-scroll-area": { 2860 + "version": "1.2.10", 2861 + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", 2862 + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", 2863 + "dependencies": { 2864 + "@radix-ui/number": "1.1.1", 2865 + "@radix-ui/primitive": "1.1.3", 2866 + "@radix-ui/react-compose-refs": "1.1.2", 2867 + "@radix-ui/react-context": "1.1.2", 2868 + "@radix-ui/react-direction": "1.1.1", 2869 + "@radix-ui/react-presence": "1.1.5", 2870 + "@radix-ui/react-primitive": "2.1.3", 2871 + "@radix-ui/react-use-callback-ref": "1.1.1", 2872 + "@radix-ui/react-use-layout-effect": "1.1.1" 2873 + }, 2874 + "peerDependencies": { 2875 + "@types/react": "*", 2876 + "@types/react-dom": "*", 2877 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2878 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2879 + }, 2880 + "peerDependenciesMeta": { 2881 + "@types/react": { 2882 + "optional": true 2883 + }, 2884 + "@types/react-dom": { 2885 + "optional": true 2886 + } 2887 + } 2888 + }, 2889 + "node_modules/@radix-ui/react-select": { 2890 + "version": "2.2.6", 2891 + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", 2892 + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", 2893 + "dependencies": { 2894 + "@radix-ui/number": "1.1.1", 2895 + "@radix-ui/primitive": "1.1.3", 2896 + "@radix-ui/react-collection": "1.1.7", 2897 + "@radix-ui/react-compose-refs": "1.1.2", 2898 + "@radix-ui/react-context": "1.1.2", 2899 + "@radix-ui/react-direction": "1.1.1", 2900 + "@radix-ui/react-dismissable-layer": "1.1.11", 2901 + "@radix-ui/react-focus-guards": "1.1.3", 2902 + "@radix-ui/react-focus-scope": "1.1.7", 2903 + "@radix-ui/react-id": "1.1.1", 2904 + "@radix-ui/react-popper": "1.2.8", 2905 + "@radix-ui/react-portal": "1.1.9", 2906 + "@radix-ui/react-primitive": "2.1.3", 2907 + "@radix-ui/react-slot": "1.2.3", 2908 + "@radix-ui/react-use-callback-ref": "1.1.1", 2909 + "@radix-ui/react-use-controllable-state": "1.2.2", 2910 + "@radix-ui/react-use-layout-effect": "1.1.1", 2911 + "@radix-ui/react-use-previous": "1.1.1", 2912 + "@radix-ui/react-visually-hidden": "1.2.3", 2913 + "aria-hidden": "^1.2.4", 2914 + "react-remove-scroll": "^2.6.3" 2915 + }, 2916 + "peerDependencies": { 2917 + "@types/react": "*", 2918 + "@types/react-dom": "*", 2919 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2920 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2921 + }, 2922 + "peerDependenciesMeta": { 2923 + "@types/react": { 2924 + "optional": true 2925 + }, 2926 + "@types/react-dom": { 2927 + "optional": true 2928 + } 2929 + } 2930 + }, 2931 + "node_modules/@radix-ui/react-separator": { 2932 + "version": "1.1.7", 2933 + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", 2934 + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", 2935 + "dependencies": { 2936 + "@radix-ui/react-primitive": "2.1.3" 2937 + }, 2938 + "peerDependencies": { 2939 + "@types/react": "*", 2940 + "@types/react-dom": "*", 2941 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2942 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2943 + }, 2944 + "peerDependenciesMeta": { 2945 + "@types/react": { 2946 + "optional": true 2947 + }, 2948 + "@types/react-dom": { 2949 + "optional": true 2950 + } 2951 + } 2952 + }, 2953 + "node_modules/@radix-ui/react-slider": { 2954 + "version": "1.3.6", 2955 + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", 2956 + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", 2957 + "dependencies": { 2958 + "@radix-ui/number": "1.1.1", 2959 + "@radix-ui/primitive": "1.1.3", 2960 + "@radix-ui/react-collection": "1.1.7", 2961 + "@radix-ui/react-compose-refs": "1.1.2", 2962 + "@radix-ui/react-context": "1.1.2", 2963 + "@radix-ui/react-direction": "1.1.1", 2964 + "@radix-ui/react-primitive": "2.1.3", 2965 + "@radix-ui/react-use-controllable-state": "1.2.2", 2966 + "@radix-ui/react-use-layout-effect": "1.1.1", 2967 + "@radix-ui/react-use-previous": "1.1.1", 2968 + "@radix-ui/react-use-size": "1.1.1" 2969 + }, 2970 + "peerDependencies": { 2971 + "@types/react": "*", 2972 + "@types/react-dom": "*", 2973 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2974 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2975 + }, 2976 + "peerDependenciesMeta": { 2977 + "@types/react": { 2978 + "optional": true 2979 + }, 2980 + "@types/react-dom": { 2981 + "optional": true 2982 + } 2983 + } 2984 + }, 2985 + "node_modules/@radix-ui/react-slot": { 2986 + "version": "1.2.3", 2987 + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", 2988 + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", 2989 + "dependencies": { 2990 + "@radix-ui/react-compose-refs": "1.1.2" 2991 + }, 2992 + "peerDependencies": { 2993 + "@types/react": "*", 2994 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2995 + }, 2996 + "peerDependenciesMeta": { 2997 + "@types/react": { 2998 + "optional": true 2999 + } 3000 + } 3001 + }, 3002 + "node_modules/@radix-ui/react-switch": { 3003 + "version": "1.2.6", 3004 + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", 3005 + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", 3006 + "dependencies": { 3007 + "@radix-ui/primitive": "1.1.3", 3008 + "@radix-ui/react-compose-refs": "1.1.2", 3009 + "@radix-ui/react-context": "1.1.2", 3010 + "@radix-ui/react-primitive": "2.1.3", 3011 + "@radix-ui/react-use-controllable-state": "1.2.2", 3012 + "@radix-ui/react-use-previous": "1.1.1", 3013 + "@radix-ui/react-use-size": "1.1.1" 3014 + }, 3015 + "peerDependencies": { 3016 + "@types/react": "*", 3017 + "@types/react-dom": "*", 3018 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3019 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3020 + }, 3021 + "peerDependenciesMeta": { 3022 + "@types/react": { 3023 + "optional": true 3024 + }, 3025 + "@types/react-dom": { 3026 + "optional": true 3027 + } 3028 + } 3029 + }, 3030 + "node_modules/@radix-ui/react-tabs": { 3031 + "version": "1.1.13", 3032 + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", 3033 + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", 3034 + "dependencies": { 3035 + "@radix-ui/primitive": "1.1.3", 3036 + "@radix-ui/react-context": "1.1.2", 3037 + "@radix-ui/react-direction": "1.1.1", 3038 + "@radix-ui/react-id": "1.1.1", 3039 + "@radix-ui/react-presence": "1.1.5", 3040 + "@radix-ui/react-primitive": "2.1.3", 3041 + "@radix-ui/react-roving-focus": "1.1.11", 3042 + "@radix-ui/react-use-controllable-state": "1.2.2" 3043 + }, 3044 + "peerDependencies": { 3045 + "@types/react": "*", 3046 + "@types/react-dom": "*", 3047 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3048 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3049 + }, 3050 + "peerDependenciesMeta": { 3051 + "@types/react": { 3052 + "optional": true 3053 + }, 3054 + "@types/react-dom": { 3055 + "optional": true 3056 + } 3057 + } 3058 + }, 3059 + "node_modules/@radix-ui/react-toast": { 3060 + "version": "1.2.15", 3061 + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", 3062 + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", 3063 + "dependencies": { 3064 + "@radix-ui/primitive": "1.1.3", 3065 + "@radix-ui/react-collection": "1.1.7", 3066 + "@radix-ui/react-compose-refs": "1.1.2", 3067 + "@radix-ui/react-context": "1.1.2", 3068 + "@radix-ui/react-dismissable-layer": "1.1.11", 3069 + "@radix-ui/react-portal": "1.1.9", 3070 + "@radix-ui/react-presence": "1.1.5", 3071 + "@radix-ui/react-primitive": "2.1.3", 3072 + "@radix-ui/react-use-callback-ref": "1.1.1", 3073 + "@radix-ui/react-use-controllable-state": "1.2.2", 3074 + "@radix-ui/react-use-layout-effect": "1.1.1", 3075 + "@radix-ui/react-visually-hidden": "1.2.3" 3076 + }, 3077 + "peerDependencies": { 3078 + "@types/react": "*", 3079 + "@types/react-dom": "*", 3080 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3081 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3082 + }, 3083 + "peerDependenciesMeta": { 3084 + "@types/react": { 3085 + "optional": true 3086 + }, 3087 + "@types/react-dom": { 3088 + "optional": true 3089 + } 3090 + } 3091 + }, 3092 + "node_modules/@radix-ui/react-toggle": { 3093 + "version": "1.1.10", 3094 + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", 3095 + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", 3096 + "dependencies": { 3097 + "@radix-ui/primitive": "1.1.3", 3098 + "@radix-ui/react-primitive": "2.1.3", 3099 + "@radix-ui/react-use-controllable-state": "1.2.2" 3100 + }, 3101 + "peerDependencies": { 3102 + "@types/react": "*", 3103 + "@types/react-dom": "*", 3104 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3105 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3106 + }, 3107 + "peerDependenciesMeta": { 3108 + "@types/react": { 3109 + "optional": true 3110 + }, 3111 + "@types/react-dom": { 3112 + "optional": true 3113 + } 3114 + } 3115 + }, 3116 + "node_modules/@radix-ui/react-toggle-group": { 3117 + "version": "1.1.11", 3118 + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", 3119 + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", 3120 + "dependencies": { 3121 + "@radix-ui/primitive": "1.1.3", 3122 + "@radix-ui/react-context": "1.1.2", 3123 + "@radix-ui/react-direction": "1.1.1", 3124 + "@radix-ui/react-primitive": "2.1.3", 3125 + "@radix-ui/react-roving-focus": "1.1.11", 3126 + "@radix-ui/react-toggle": "1.1.10", 3127 + "@radix-ui/react-use-controllable-state": "1.2.2" 3128 + }, 3129 + "peerDependencies": { 3130 + "@types/react": "*", 3131 + "@types/react-dom": "*", 3132 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3133 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3134 + }, 3135 + "peerDependenciesMeta": { 3136 + "@types/react": { 3137 + "optional": true 3138 + }, 3139 + "@types/react-dom": { 3140 + "optional": true 3141 + } 3142 + } 3143 + }, 3144 + "node_modules/@radix-ui/react-toolbar": { 3145 + "version": "1.1.11", 3146 + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", 3147 + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", 3148 + "dependencies": { 3149 + "@radix-ui/primitive": "1.1.3", 3150 + "@radix-ui/react-context": "1.1.2", 3151 + "@radix-ui/react-direction": "1.1.1", 3152 + "@radix-ui/react-primitive": "2.1.3", 3153 + "@radix-ui/react-roving-focus": "1.1.11", 3154 + "@radix-ui/react-separator": "1.1.7", 3155 + "@radix-ui/react-toggle-group": "1.1.11" 3156 + }, 3157 + "peerDependencies": { 3158 + "@types/react": "*", 3159 + "@types/react-dom": "*", 3160 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3161 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3162 + }, 3163 + "peerDependenciesMeta": { 3164 + "@types/react": { 3165 + "optional": true 3166 + }, 3167 + "@types/react-dom": { 3168 + "optional": true 3169 + } 3170 + } 3171 + }, 3172 + "node_modules/@radix-ui/react-tooltip": { 3173 + "version": "1.2.8", 3174 + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", 3175 + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", 3176 + "dependencies": { 3177 + "@radix-ui/primitive": "1.1.3", 3178 + "@radix-ui/react-compose-refs": "1.1.2", 3179 + "@radix-ui/react-context": "1.1.2", 3180 + "@radix-ui/react-dismissable-layer": "1.1.11", 3181 + "@radix-ui/react-id": "1.1.1", 3182 + "@radix-ui/react-popper": "1.2.8", 3183 + "@radix-ui/react-portal": "1.1.9", 3184 + "@radix-ui/react-presence": "1.1.5", 3185 + "@radix-ui/react-primitive": "2.1.3", 3186 + "@radix-ui/react-slot": "1.2.3", 3187 + "@radix-ui/react-use-controllable-state": "1.2.2", 3188 + "@radix-ui/react-visually-hidden": "1.2.3" 3189 + }, 3190 + "peerDependencies": { 3191 + "@types/react": "*", 3192 + "@types/react-dom": "*", 3193 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3194 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3195 + }, 3196 + "peerDependenciesMeta": { 3197 + "@types/react": { 3198 + "optional": true 3199 + }, 3200 + "@types/react-dom": { 3201 + "optional": true 3202 + } 3203 + } 3204 + }, 3205 + "node_modules/@radix-ui/react-use-callback-ref": { 3206 + "version": "1.1.1", 3207 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", 3208 + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", 3209 + "peerDependencies": { 3210 + "@types/react": "*", 3211 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3212 + }, 3213 + "peerDependenciesMeta": { 3214 + "@types/react": { 3215 + "optional": true 3216 + } 3217 + } 3218 + }, 3219 + "node_modules/@radix-ui/react-use-controllable-state": { 3220 + "version": "1.2.2", 3221 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", 3222 + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", 3223 + "dependencies": { 3224 + "@radix-ui/react-use-effect-event": "0.0.2", 3225 + "@radix-ui/react-use-layout-effect": "1.1.1" 3226 + }, 3227 + "peerDependencies": { 3228 + "@types/react": "*", 3229 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3230 + }, 3231 + "peerDependenciesMeta": { 3232 + "@types/react": { 3233 + "optional": true 3234 + } 3235 + } 3236 + }, 3237 + "node_modules/@radix-ui/react-use-effect-event": { 3238 + "version": "0.0.2", 3239 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", 3240 + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", 3241 + "dependencies": { 3242 + "@radix-ui/react-use-layout-effect": "1.1.1" 3243 + }, 3244 + "peerDependencies": { 3245 + "@types/react": "*", 3246 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3247 + }, 3248 + "peerDependenciesMeta": { 3249 + "@types/react": { 3250 + "optional": true 3251 + } 3252 + } 3253 + }, 3254 + "node_modules/@radix-ui/react-use-escape-keydown": { 3255 + "version": "1.1.1", 3256 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", 3257 + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", 3258 + "dependencies": { 3259 + "@radix-ui/react-use-callback-ref": "1.1.1" 3260 + }, 3261 + "peerDependencies": { 3262 + "@types/react": "*", 3263 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3264 + }, 3265 + "peerDependenciesMeta": { 3266 + "@types/react": { 3267 + "optional": true 3268 + } 3269 + } 3270 + }, 3271 + "node_modules/@radix-ui/react-use-is-hydrated": { 3272 + "version": "0.1.0", 3273 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", 3274 + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", 3275 + "dependencies": { 3276 + "use-sync-external-store": "^1.5.0" 3277 + }, 3278 + "peerDependencies": { 3279 + "@types/react": "*", 3280 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3281 + }, 3282 + "peerDependenciesMeta": { 3283 + "@types/react": { 3284 + "optional": true 3285 + } 3286 + } 3287 + }, 3288 + "node_modules/@radix-ui/react-use-layout-effect": { 3289 + "version": "1.1.1", 3290 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", 3291 + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", 3292 + "peerDependencies": { 3293 + "@types/react": "*", 3294 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3295 + }, 3296 + "peerDependenciesMeta": { 3297 + "@types/react": { 3298 + "optional": true 3299 + } 3300 + } 3301 + }, 3302 + "node_modules/@radix-ui/react-use-previous": { 3303 + "version": "1.1.1", 3304 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", 3305 + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", 3306 + "peerDependencies": { 3307 + "@types/react": "*", 3308 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3309 + }, 3310 + "peerDependenciesMeta": { 3311 + "@types/react": { 3312 + "optional": true 3313 + } 3314 + } 3315 + }, 3316 + "node_modules/@radix-ui/react-use-rect": { 3317 + "version": "1.1.1", 3318 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", 3319 + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", 3320 + "dependencies": { 3321 + "@radix-ui/rect": "1.1.1" 3322 + }, 3323 + "peerDependencies": { 3324 + "@types/react": "*", 3325 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3326 + }, 3327 + "peerDependenciesMeta": { 3328 + "@types/react": { 3329 + "optional": true 3330 + } 3331 + } 3332 + }, 3333 + "node_modules/@radix-ui/react-use-size": { 3334 + "version": "1.1.1", 3335 + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", 3336 + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", 3337 + "dependencies": { 3338 + "@radix-ui/react-use-layout-effect": "1.1.1" 3339 + }, 3340 + "peerDependencies": { 3341 + "@types/react": "*", 3342 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3343 + }, 3344 + "peerDependenciesMeta": { 3345 + "@types/react": { 3346 + "optional": true 3347 + } 3348 + } 3349 + }, 3350 + "node_modules/@radix-ui/react-visually-hidden": { 3351 + "version": "1.2.3", 3352 + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", 3353 + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", 3354 + "dependencies": { 3355 + "@radix-ui/react-primitive": "2.1.3" 3356 + }, 3357 + "peerDependencies": { 3358 + "@types/react": "*", 3359 + "@types/react-dom": "*", 3360 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3361 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3362 + }, 3363 + "peerDependenciesMeta": { 3364 + "@types/react": { 3365 + "optional": true 3366 + }, 3367 + "@types/react-dom": { 3368 + "optional": true 3369 + } 3370 + } 3371 + }, 3372 + "node_modules/@radix-ui/rect": { 3373 + "version": "1.1.1", 3374 + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", 3375 + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" 3376 + }, 1898 3377 "node_modules/@rolldown/pluginutils": { 1899 3378 "version": "1.0.0-beta.27", 1900 3379 "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", ··· 3807 5286 "dev": true, 3808 5287 "license": "Python-2.0" 3809 5288 }, 5289 + "node_modules/aria-hidden": { 5290 + "version": "1.2.6", 5291 + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", 5292 + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", 5293 + "dependencies": { 5294 + "tslib": "^2.0.0" 5295 + }, 5296 + "engines": { 5297 + "node": ">=10" 5298 + } 5299 + }, 3810 5300 "node_modules/aria-query": { 3811 5301 "version": "5.3.0", 3812 5302 "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", ··· 4715 6205 "engines": { 4716 6206 "node": ">=8" 4717 6207 } 6208 + }, 6209 + "node_modules/detect-node-es": { 6210 + "version": "1.1.0", 6211 + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", 6212 + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" 4718 6213 }, 4719 6214 "node_modules/diff": { 4720 6215 "version": "8.0.2", ··· 5735 7230 }, 5736 7231 "funding": { 5737 7232 "url": "https://github.com/sponsors/ljharb" 7233 + } 7234 + }, 7235 + "node_modules/get-nonce": { 7236 + "version": "1.0.1", 7237 + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", 7238 + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", 7239 + "engines": { 7240 + "node": ">=6" 5738 7241 } 5739 7242 }, 5740 7243 "node_modules/get-proto": { ··· 10338 11841 ], 10339 11842 "license": "MIT" 10340 11843 }, 11844 + "node_modules/radix-ui": { 11845 + "version": "1.4.3", 11846 + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", 11847 + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", 11848 + "dependencies": { 11849 + "@radix-ui/primitive": "1.1.3", 11850 + "@radix-ui/react-accessible-icon": "1.1.7", 11851 + "@radix-ui/react-accordion": "1.2.12", 11852 + "@radix-ui/react-alert-dialog": "1.1.15", 11853 + "@radix-ui/react-arrow": "1.1.7", 11854 + "@radix-ui/react-aspect-ratio": "1.1.7", 11855 + "@radix-ui/react-avatar": "1.1.10", 11856 + "@radix-ui/react-checkbox": "1.3.3", 11857 + "@radix-ui/react-collapsible": "1.1.12", 11858 + "@radix-ui/react-collection": "1.1.7", 11859 + "@radix-ui/react-compose-refs": "1.1.2", 11860 + "@radix-ui/react-context": "1.1.2", 11861 + "@radix-ui/react-context-menu": "2.2.16", 11862 + "@radix-ui/react-dialog": "1.1.15", 11863 + "@radix-ui/react-direction": "1.1.1", 11864 + "@radix-ui/react-dismissable-layer": "1.1.11", 11865 + "@radix-ui/react-dropdown-menu": "2.1.16", 11866 + "@radix-ui/react-focus-guards": "1.1.3", 11867 + "@radix-ui/react-focus-scope": "1.1.7", 11868 + "@radix-ui/react-form": "0.1.8", 11869 + "@radix-ui/react-hover-card": "1.1.15", 11870 + "@radix-ui/react-label": "2.1.7", 11871 + "@radix-ui/react-menu": "2.1.16", 11872 + "@radix-ui/react-menubar": "1.1.16", 11873 + "@radix-ui/react-navigation-menu": "1.2.14", 11874 + "@radix-ui/react-one-time-password-field": "0.1.8", 11875 + "@radix-ui/react-password-toggle-field": "0.1.3", 11876 + "@radix-ui/react-popover": "1.1.15", 11877 + "@radix-ui/react-popper": "1.2.8", 11878 + "@radix-ui/react-portal": "1.1.9", 11879 + "@radix-ui/react-presence": "1.1.5", 11880 + "@radix-ui/react-primitive": "2.1.3", 11881 + "@radix-ui/react-progress": "1.1.7", 11882 + "@radix-ui/react-radio-group": "1.3.8", 11883 + "@radix-ui/react-roving-focus": "1.1.11", 11884 + "@radix-ui/react-scroll-area": "1.2.10", 11885 + "@radix-ui/react-select": "2.2.6", 11886 + "@radix-ui/react-separator": "1.1.7", 11887 + "@radix-ui/react-slider": "1.3.6", 11888 + "@radix-ui/react-slot": "1.2.3", 11889 + "@radix-ui/react-switch": "1.2.6", 11890 + "@radix-ui/react-tabs": "1.1.13", 11891 + "@radix-ui/react-toast": "1.2.15", 11892 + "@radix-ui/react-toggle": "1.1.10", 11893 + "@radix-ui/react-toggle-group": "1.1.11", 11894 + "@radix-ui/react-toolbar": "1.1.11", 11895 + "@radix-ui/react-tooltip": "1.2.8", 11896 + "@radix-ui/react-use-callback-ref": "1.1.1", 11897 + "@radix-ui/react-use-controllable-state": "1.2.2", 11898 + "@radix-ui/react-use-effect-event": "0.0.2", 11899 + "@radix-ui/react-use-escape-keydown": "1.1.1", 11900 + "@radix-ui/react-use-is-hydrated": "0.1.0", 11901 + "@radix-ui/react-use-layout-effect": "1.1.1", 11902 + "@radix-ui/react-use-size": "1.1.1", 11903 + "@radix-ui/react-visually-hidden": "1.2.3" 11904 + }, 11905 + "peerDependencies": { 11906 + "@types/react": "*", 11907 + "@types/react-dom": "*", 11908 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 11909 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 11910 + }, 11911 + "peerDependenciesMeta": { 11912 + "@types/react": { 11913 + "optional": true 11914 + }, 11915 + "@types/react-dom": { 11916 + "optional": true 11917 + } 11918 + } 11919 + }, 10341 11920 "node_modules/react": { 10342 11921 "version": "19.1.1", 10343 11922 "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", ··· 10397 11976 "license": "MIT", 10398 11977 "engines": { 10399 11978 "node": ">=0.10.0" 11979 + } 11980 + }, 11981 + "node_modules/react-remove-scroll": { 11982 + "version": "2.7.1", 11983 + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", 11984 + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", 11985 + "dependencies": { 11986 + "react-remove-scroll-bar": "^2.3.7", 11987 + "react-style-singleton": "^2.2.3", 11988 + "tslib": "^2.1.0", 11989 + "use-callback-ref": "^1.3.3", 11990 + "use-sidecar": "^1.1.3" 11991 + }, 11992 + "engines": { 11993 + "node": ">=10" 11994 + }, 11995 + "peerDependencies": { 11996 + "@types/react": "*", 11997 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" 11998 + }, 11999 + "peerDependenciesMeta": { 12000 + "@types/react": { 12001 + "optional": true 12002 + } 12003 + } 12004 + }, 12005 + "node_modules/react-remove-scroll-bar": { 12006 + "version": "2.3.8", 12007 + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", 12008 + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", 12009 + "dependencies": { 12010 + "react-style-singleton": "^2.2.2", 12011 + "tslib": "^2.0.0" 12012 + }, 12013 + "engines": { 12014 + "node": ">=10" 12015 + }, 12016 + "peerDependencies": { 12017 + "@types/react": "*", 12018 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 12019 + }, 12020 + "peerDependenciesMeta": { 12021 + "@types/react": { 12022 + "optional": true 12023 + } 12024 + } 12025 + }, 12026 + "node_modules/react-style-singleton": { 12027 + "version": "2.2.3", 12028 + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", 12029 + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", 12030 + "dependencies": { 12031 + "get-nonce": "^1.0.0", 12032 + "tslib": "^2.0.0" 12033 + }, 12034 + "engines": { 12035 + "node": ">=10" 12036 + }, 12037 + "peerDependencies": { 12038 + "@types/react": "*", 12039 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" 12040 + }, 12041 + "peerDependenciesMeta": { 12042 + "@types/react": { 12043 + "optional": true 12044 + } 10400 12045 } 10401 12046 }, 10402 12047 "node_modules/readdirp": { ··· 10899 12544 "csstype": "^3.1.0", 10900 12545 "seroval": "~1.3.0", 10901 12546 "seroval-plugins": "~1.3.0" 12547 + } 12548 + }, 12549 + "node_modules/sonner": { 12550 + "version": "2.0.7", 12551 + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", 12552 + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", 12553 + "peerDependencies": { 12554 + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", 12555 + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" 10902 12556 } 10903 12557 }, 10904 12558 "node_modules/source-map": { ··· 11892 13546 "peer": true, 11893 13547 "dependencies": { 11894 13548 "punycode": "^2.1.0" 13549 + } 13550 + }, 13551 + "node_modules/use-callback-ref": { 13552 + "version": "1.3.3", 13553 + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", 13554 + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", 13555 + "dependencies": { 13556 + "tslib": "^2.0.0" 13557 + }, 13558 + "engines": { 13559 + "node": ">=10" 13560 + }, 13561 + "peerDependencies": { 13562 + "@types/react": "*", 13563 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" 13564 + }, 13565 + "peerDependenciesMeta": { 13566 + "@types/react": { 13567 + "optional": true 13568 + } 13569 + } 13570 + }, 13571 + "node_modules/use-sidecar": { 13572 + "version": "1.1.3", 13573 + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", 13574 + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", 13575 + "dependencies": { 13576 + "detect-node-es": "^1.1.0", 13577 + "tslib": "^2.0.0" 13578 + }, 13579 + "engines": { 13580 + "node": ">=10" 13581 + }, 13582 + "peerDependencies": { 13583 + "@types/react": "*", 13584 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" 13585 + }, 13586 + "peerDependenciesMeta": { 13587 + "@types/react": { 13588 + "optional": true 13589 + } 11895 13590 } 11896 13591 }, 11897 13592 "node_modules/use-sync-external-store": {
+6
package.json
··· 12 12 "dependencies": { 13 13 "@atproto/api": "^0.16.6", 14 14 "@atproto/oauth-client-browser": "^0.3.33", 15 + "@radix-ui/react-dialog": "^1.1.15", 16 + "@radix-ui/react-dropdown-menu": "^2.1.16", 17 + "@radix-ui/react-hover-card": "^1.1.15", 18 + "@radix-ui/react-slider": "^1.3.6", 15 19 "@tailwindcss/vite": "^4.0.6", 16 20 "@tanstack/query-sync-storage-persister": "^5.85.6", 17 21 "@tanstack/react-devtools": "^0.2.2", ··· 25 29 "idb-keyval": "^6.2.2", 26 30 "jotai": "^2.13.1", 27 31 "npm": "^11.6.2", 32 + "radix-ui": "^1.4.3", 28 33 "react": "^19.0.0", 29 34 "react-dom": "^19.0.0", 30 35 "react-player": "^3.3.2", 36 + "sonner": "^2.0.7", 31 37 "tailwindcss": "^4.0.6", 32 38 "tanstack-router-keepalive": "^1.0.0" 33 39 },
+6
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 IconMdiClose: typeof import('~icons/mdi/close.jsx').default 24 + const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 21 25 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 26 + const IconMdiShield: typeof import('~icons/mdi/shield.jsx').default 27 + const IconMdiShieldOutline: typeof import('~icons/mdi/shield-outline.jsx').default 22 28 }
+292
src/components/Composer.tsx
··· 1 + import { AppBskyRichtextFacet, RichText } from "@atproto/api"; 2 + import { useAtom } from "jotai"; 3 + import { Dialog } from "radix-ui"; 4 + import { useEffect, useRef, useState } from "react"; 5 + 6 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { composerAtom } from "~/utils/atoms"; 8 + import { useQueryPost } from "~/utils/useQuery"; 9 + 10 + import { ProfileThing } from "./Login"; 11 + import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 12 + 13 + const MAX_POST_LENGTH = 300; 14 + 15 + export function Composer() { 16 + const [composerState, setComposerState] = useAtom(composerAtom); 17 + const { agent } = useAuth(); 18 + 19 + const [postText, setPostText] = useState(""); 20 + const [posting, setPosting] = useState(false); 21 + const [postSuccess, setPostSuccess] = useState(false); 22 + const [postError, setPostError] = useState<string | null>(null); 23 + 24 + useEffect(() => { 25 + setPostText(""); 26 + setPosting(false); 27 + setPostSuccess(false); 28 + setPostError(null); 29 + }, [composerState.kind]); 30 + 31 + const parentUri = 32 + composerState.kind === "reply" 33 + ? composerState.parent 34 + : composerState.kind === "quote" 35 + ? composerState.subject 36 + : undefined; 37 + 38 + const { data: parentPost, isLoading: isParentLoading } = 39 + useQueryPost(parentUri); 40 + 41 + async function handlePost() { 42 + if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return; 43 + 44 + setPosting(true); 45 + setPostError(null); 46 + 47 + try { 48 + const rt = new RichText({ text: postText }); 49 + await rt.detectFacets(agent); 50 + 51 + if (rt.facets?.length) { 52 + rt.facets = rt.facets.filter((item) => { 53 + if (item.$type !== "app.bsky.richtext.facet") return true; 54 + if (!item.features?.length) return true; 55 + 56 + item.features = item.features.filter((feature) => { 57 + if (feature.$type !== "app.bsky.richtext.facet#mention") return true; 58 + const did = feature.$type === "app.bsky.richtext.facet#mention" ? (feature as AppBskyRichtextFacet.Mention)?.did : undefined; 59 + return typeof did === "string" && did.startsWith("did:"); 60 + }); 61 + 62 + return item.features.length > 0; 63 + }); 64 + } 65 + 66 + const record: Record<string, unknown> = { 67 + $type: "app.bsky.feed.post", 68 + text: rt.text, 69 + facets: rt.facets, 70 + createdAt: new Date().toISOString(), 71 + }; 72 + 73 + if (composerState.kind === "reply" && parentPost) { 74 + record.reply = { 75 + root: parentPost.value?.reply?.root ?? { 76 + uri: parentPost.uri, 77 + cid: parentPost.cid, 78 + }, 79 + parent: { 80 + uri: parentPost.uri, 81 + cid: parentPost.cid, 82 + }, 83 + }; 84 + } 85 + 86 + if (composerState.kind === "quote" && parentPost) { 87 + record.embed = { 88 + $type: "app.bsky.embed.record", 89 + record: { 90 + uri: parentPost.uri, 91 + cid: parentPost.cid, 92 + }, 93 + }; 94 + } 95 + 96 + await agent.com.atproto.repo.createRecord({ 97 + collection: "app.bsky.feed.post", 98 + repo: agent.assertDid, 99 + record, 100 + }); 101 + 102 + setPostSuccess(true); 103 + setPostText(""); 104 + 105 + setTimeout(() => { 106 + setPostSuccess(false); 107 + setComposerState({ kind: "closed" }); 108 + }, 1500); 109 + } catch (e: any) { 110 + setPostError(e?.message || "Failed to post"); 111 + } finally { 112 + setPosting(false); 113 + } 114 + } 115 + // if (composerState.kind === "closed") { 116 + // return null; 117 + // } 118 + 119 + const getPlaceholder = () => { 120 + switch (composerState.kind) { 121 + case "reply": 122 + return "Post your reply"; 123 + case "quote": 124 + return "Add a comment..."; 125 + case "root": 126 + default: 127 + return "What's happening?!"; 128 + } 129 + }; 130 + 131 + const charsLeft = MAX_POST_LENGTH - postText.length; 132 + const isPostButtonDisabled = 133 + posting || !postText.trim() || isParentLoading || charsLeft < 0; 134 + 135 + return ( 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" /> 144 + 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" 164 + > 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> 170 + 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> 185 + </div> 186 + </div> 187 + 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} 208 + /> 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 + )} 214 + </div> 215 + )} 216 + 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> 230 + </div> 231 + 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 + )} 250 + 251 + {postError && ( 252 + <div className="text-red-500 text-sm my-2 text-center"> 253 + {postError} 254 + </div> 255 + )} 256 + </div> 257 + )} 258 + </div> 259 + </Dialog.Content> 260 + </Dialog.Portal> 261 + </Dialog.Root> 262 + ); 263 + } 264 + 265 + function AutoGrowTextarea({ 266 + value, 267 + className, 268 + onChange, 269 + ...props 270 + }: React.DetailedHTMLProps< 271 + React.TextareaHTMLAttributes<HTMLTextAreaElement>, 272 + HTMLTextAreaElement 273 + >) { 274 + const ref = useRef<HTMLTextAreaElement>(null); 275 + 276 + useEffect(() => { 277 + const el = ref.current; 278 + if (!el) return; 279 + el.style.height = "auto"; 280 + el.style.height = el.scrollHeight + "px"; 281 + }, [value]); 282 + 283 + return ( 284 + <textarea 285 + ref={ref} 286 + className={className} 287 + value={value} 288 + onChange={onChange} 289 + {...props} 290 + /> 291 + ); 292 + }
+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"
+184
src/components/Import.tsx
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { useNavigate, type UseNavigateResult } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 + import { useState } from "react"; 5 + 6 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { lycanURLAtom } from "~/utils/atoms"; 8 + import { useQueryLycanStatus } from "~/utils/useQuery"; 9 + 10 + /** 11 + * Basically the best equivalent to Search that i can do 12 + */ 13 + export function Import({ 14 + optionaltextstring, 15 + }: { 16 + optionaltextstring?: string; 17 + }) { 18 + const [textInput, setTextInput] = useState<string | undefined>( 19 + optionaltextstring 20 + ); 21 + const navigate = useNavigate(); 22 + 23 + const { status } = useAuth(); 24 + const [lycandomain] = useAtom(lycanURLAtom); 25 + const lycanExists = lycandomain !== ""; 26 + const { data: lycanstatusdata } = useQueryLycanStatus(); 27 + const lycanIndexed = lycanstatusdata?.status === "finished" || false; 28 + const lycanIndexing = lycanstatusdata?.status === "in_progress" || false; 29 + const lycanIndexingProgress = lycanIndexing 30 + ? lycanstatusdata?.progress 31 + : undefined; 32 + const authed = status === "signedIn"; 33 + 34 + const lycanReady = lycanExists && lycanIndexed && authed; 35 + 36 + const handleEnter = () => { 37 + if (!textInput) return; 38 + handleImport({ 39 + text: textInput, 40 + navigate, 41 + lycanReady: 42 + lycanReady || (!!lycanIndexingProgress && lycanIndexingProgress > 0), 43 + }); 44 + }; 45 + 46 + const placeholder = lycanReady ? "Search..." : "Import..."; 47 + 48 + return ( 49 + <div className="w-full relative"> 50 + <IconMaterialSymbolsSearch className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" /> 51 + 52 + <input 53 + type="text" 54 + placeholder={placeholder} 55 + value={textInput} 56 + onChange={(e) => setTextInput(e.target.value)} 57 + onKeyDown={(e) => { 58 + if (e.key === "Enter") handleEnter(); 59 + }} 60 + className="w-full h-12 pl-12 pr-4 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-500 box-border transition" 61 + /> 62 + </div> 63 + ); 64 + } 65 + 66 + function handleImport({ 67 + text, 68 + navigate, 69 + lycanReady, 70 + }: { 71 + text: string; 72 + navigate: UseNavigateResult<string>; 73 + lycanReady?: boolean; 74 + }) { 75 + const trimmed = text.trim(); 76 + // parse text 77 + /** 78 + * text might be 79 + * 1. bsky dot app url (reddwarf link segments might be uri encoded,) 80 + * 2. aturi 81 + * 3. plain handle 82 + * 4. plain did 83 + */ 84 + 85 + // 1. Check if itโ€™s a URL 86 + try { 87 + const url = new URL(text); 88 + const knownHosts = [ 89 + "bsky.app", 90 + "social.daniela.lol", 91 + "deer.social", 92 + "reddwarf.whey.party", 93 + "reddwarf.app", 94 + "main.bsky.dev", 95 + "catsky.social", 96 + "blacksky.community", 97 + "red-dwarf-social-app.whey.party", 98 + "zeppelin.social", 99 + ]; 100 + if (knownHosts.includes(url.hostname)) { 101 + // parse path to get URI or handle 102 + const path = decodeURIComponent(url.pathname.slice(1)); // remove leading / 103 + console.log("BSky URL path:", path); 104 + navigate({ 105 + to: `/${path}`, 106 + }); 107 + return; 108 + } 109 + } catch { 110 + // not a URL, continue 111 + } 112 + 113 + // 2. Check if text looks like an at-uri 114 + try { 115 + if (text.startsWith("at://")) { 116 + console.log("AT URI detected:", text); 117 + const aturi = new AtUri(text); 118 + switch (aturi.collection) { 119 + case "app.bsky.feed.post": { 120 + navigate({ 121 + to: "/profile/$did/post/$rkey", 122 + params: { 123 + did: aturi.host, 124 + rkey: aturi.rkey, 125 + }, 126 + }); 127 + return; 128 + } 129 + case "app.bsky.actor.profile": { 130 + navigate({ 131 + to: "/profile/$did", 132 + params: { 133 + did: aturi.host, 134 + }, 135 + }); 136 + return; 137 + } 138 + // todo add more handlers as more routes are added. like feeds, lists, etc etc thanks! 139 + default: { 140 + // continue 141 + } 142 + } 143 + } 144 + } catch { 145 + // continue 146 + } 147 + 148 + // 3. Plain handle (starts with @) 149 + try { 150 + if (text.startsWith("@")) { 151 + const handle = text.slice(1); 152 + console.log("Handle detected:", handle); 153 + navigate({ to: "/profile/$did", params: { did: handle } }); 154 + return; 155 + } 156 + } catch { 157 + // continue 158 + } 159 + 160 + // 4. Plain DID (starts with did:) 161 + try { 162 + if (text.startsWith("did:")) { 163 + console.log("did detected:", text); 164 + navigate({ to: "/profile/$did", params: { did: text } }); 165 + return; 166 + } 167 + } catch { 168 + // continue 169 + } 170 + 171 + // if all else fails 172 + 173 + // try { 174 + // // probably a user? 175 + // navigate({ to: "/profile/$did", params: { did: text } }); 176 + // return; 177 + // } catch { 178 + // // continue 179 + // } 180 + 181 + if (lycanReady) { 182 + navigate({ to: "/search", search: { q: text } }); 183 + } 184 + }
+38 -7
src/components/InfiniteCustomFeed.tsx
··· 1 + import { useQueryClient } from "@tanstack/react-query"; 1 2 import * as React from "react"; 2 3 3 4 //import { useInView } from "react-intersection-observer"; ··· 13 14 feedUri: string; 14 15 pdsUrl?: string; 15 16 feedServiceDid?: string; 17 + authedOverride?: boolean; 18 + unauthedfeedurl?: string; 16 19 } 17 20 18 21 export function InfiniteCustomFeed({ 19 22 feedUri, 20 23 pdsUrl, 21 24 feedServiceDid, 25 + authedOverride, 26 + unauthedfeedurl, 22 27 }: InfiniteCustomFeedProps) { 23 28 const { agent } = useAuth(); 24 - const authed = !!agent?.did; 29 + const authed = authedOverride || !!agent?.did; 25 30 26 31 // const identityresultmaybe = useQueryIdentity(agent?.did); 27 32 // const identity = identityresultmaybe?.data; ··· 37 42 isFetchingNextPage, 38 43 refetch, 39 44 isRefetching, 45 + queryKey, 40 46 } = useInfiniteQueryFeedSkeleton({ 41 47 feedUri: feedUri, 42 48 agent: agent ?? undefined, 43 49 isAuthed: authed ?? false, 44 50 pdsUrl: pdsUrl, 45 51 feedServiceDid: feedServiceDid, 52 + unauthedfeedurl: unauthedfeedurl, 46 53 }); 54 + const queryClient = useQueryClient(); 55 + 47 56 48 57 const handleRefresh = () => { 58 + queryClient.removeQueries({queryKey: queryKey}); 59 + //queryClient.invalidateQueries(["infinite-feed", feedUri] as const); 49 60 refetch(); 50 61 }; 51 62 63 + const allPosts = React.useMemo(() => { 64 + const flattenedPosts = data?.pages.flatMap((page) => page?.feed) ?? []; 65 + 66 + const seenUris = new Set<string>(); 67 + 68 + return flattenedPosts.filter((item) => { 69 + if (!item?.post) return false; 70 + 71 + if (seenUris.has(item.post)) { 72 + return false; 73 + } 74 + 75 + seenUris.add(item.post); 76 + 77 + return true; 78 + }); 79 + }, [data]); 80 + 52 81 //const { ref, inView } = useInView(); 53 82 54 83 // React.useEffect(() => { ··· 67 96 ); 68 97 } 69 98 70 - const allPosts = 71 - data?.pages.flatMap((page) => { 72 - if (page) return page.feed; 73 - }) ?? []; 99 + // const allPosts = 100 + // data?.pages.flatMap((page) => { 101 + // if (page) return page.feed; 102 + // }) ?? []; 74 103 75 104 if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) { 76 105 return ( ··· 116 145 className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed" 117 146 aria-label="Refresh feed" 118 147 > 119 - <RefreshIcon className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} /> 148 + <RefreshIcon 149 + className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} 150 + /> 120 151 </button> 121 152 </> 122 153 ); ··· 139 170 d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" 140 171 ></path> 141 172 </svg> 142 - ); 173 + );
+78 -34
src/components/Login.tsx
··· 1 1 // src/components/Login.tsx 2 - import { Agent } from "@atproto/api"; 2 + import AtpAgent, { Agent } from "@atproto/api"; 3 + import { useAtom } from "jotai"; 3 4 import React, { useEffect, useRef, useState } from "react"; 4 5 5 6 import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { imgCDNAtom } from "~/utils/atoms"; 8 + import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery"; 6 9 7 10 // --- 1. The Main Component (Orchestrator with `compact` prop) --- 8 11 export default function Login({ ··· 21 24 className={ 22 25 compact 23 26 ? "flex items-center justify-center p-1" 24 - : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]" 27 + : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-4 mx-4 flex justify-center items-center h-[280px]" 25 28 } 26 29 > 27 30 <span ··· 40 43 // Large view 41 44 if (!compact) { 42 45 return ( 43 - <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4"> 46 + <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4"> 44 47 <div className="flex flex-col items-center justify-center text-center"> 45 48 <p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100"> 46 49 You are logged in! ··· 74 77 if (!compact) { 75 78 // Large view renders the form directly in the card 76 79 return ( 77 - <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4"> 80 + <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4"> 78 81 <UnifiedLoginForm /> 79 82 </div> 80 83 ); ··· 110 113 // --- 3. Helper components for layouts, forms, and UI --- 111 114 112 115 // A new component to contain the logic for the compact dropdown 113 - const CompactLoginButton = ({popup}:{popup?: boolean}) => { 116 + const CompactLoginButton = ({ popup }: { popup?: boolean }) => { 114 117 const [showForm, setShowForm] = useState(false); 115 118 const formRef = useRef<HTMLDivElement>(null); 116 119 ··· 137 140 Log in 138 141 </button> 139 142 {showForm && ( 140 - <div className={`absolute ${popup ? `bottom-[calc(100%)]` :`top-full`} right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50`}> 143 + <div 144 + className={`absolute ${popup ? `bottom-[calc(100%)]` : `top-full`} right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50`} 145 + > 141 146 <UnifiedLoginForm /> 142 147 </div> 143 148 )} ··· 158 163 onClick={onClick} 159 164 className={`px-4 py-2 text-sm font-medium transition-colors rounded-full flex-1 ${ 160 165 active 161 - ? "text-gray-950 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500" 166 + ? "text-gray-50 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500" 162 167 : "text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200" 163 168 }`} 164 169 > ··· 187 192 <p className="text-xs text-gray-500 dark:text-gray-400"> 188 193 Sign in with AT. Your password is never shared. 189 194 </p> 190 - <input 195 + {/* <input 191 196 type="text" 192 197 placeholder="handle.bsky.social" 193 198 value={handle} 194 199 onChange={(e) => setHandle(e.target.value)} 195 200 className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" 196 - /> 197 - <button 198 - type="submit" 199 - className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors" 200 - > 201 - Log in 202 - </button> 201 + /> */} 202 + <div className="flex flex-col gap-3"> 203 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 204 + <input 205 + type="text" 206 + placeholder=" " 207 + value={handle} 208 + onChange={(e) => setHandle(e.target.value)} 209 + /> 210 + <label>AT Handle</label> 211 + </div> 212 + <button 213 + type="submit" 214 + className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors" 215 + > 216 + Log in 217 + </button> 218 + </div> 203 219 </form> 204 220 ); 205 221 }; ··· 232 248 <p className="text-xs text-red-500 dark:text-red-400"> 233 249 Warning: Less secure. Use an App Password. 234 250 </p> 235 - <input 251 + {/* <input 236 252 type="text" 237 253 placeholder="handle.bsky.social" 238 254 value={user} ··· 254 270 value={serviceURL} 255 271 onChange={(e) => setServiceURL(e.target.value)} 256 272 className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" 257 - /> 273 + /> */} 274 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 275 + <input 276 + type="text" 277 + placeholder=" " 278 + value={user} 279 + onChange={(e) => setUser(e.target.value)} 280 + /> 281 + <label>AT Handle</label> 282 + </div> 283 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 284 + <input 285 + type="text" 286 + placeholder=" " 287 + value={password} 288 + onChange={(e) => setPassword(e.target.value)} 289 + /> 290 + <label>App Password</label> 291 + </div> 292 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 293 + <input 294 + type="text" 295 + placeholder=" " 296 + value={serviceURL} 297 + onChange={(e) => setServiceURL(e.target.value)} 298 + /> 299 + <label>PDS</label> 300 + </div> 258 301 {error && <p className="text-xs text-red-500">{error}</p>} 259 302 <button 260 303 type="submit" ··· 274 317 agent: Agent | null; 275 318 large?: boolean; 276 319 }) => { 277 - const [profile, setProfile] = useState<any>(null); 320 + const did = ((agent as AtpAgent)?.session?.did ?? 321 + (agent as AtpAgent)?.assertDid ?? 322 + agent?.did) as string | undefined; 323 + const { data: identity } = useQueryIdentity(did); 324 + const { data: profiledata } = useQueryProfile( 325 + `at://${did}/app.bsky.actor.profile/self` 326 + ); 327 + const profile = profiledata?.value; 278 328 279 - useEffect(() => { 280 - const fetchUser = async () => { 281 - const did = (agent as any)?.session?.did ?? (agent as any)?.assertDid; 282 - if (!did) return; 283 - try { 284 - const res = await agent!.getProfile({ actor: did }); 285 - setProfile(res.data); 286 - } catch (e) { 287 - console.error("Failed to fetch profile", e); 288 - } 289 - }; 290 - if (agent) fetchUser(); 291 - }, [agent]); 329 + const [imgcdn] = useAtom(imgCDNAtom) 292 330 293 - if (!profile) { 331 + function getAvatarUrl(p: typeof profile) { 332 + const link = p?.avatar?.ref?.["$link"]; 333 + if (!link || !did) return null; 334 + return `https://${imgcdn}/img/avatar/plain/${did}/${link}@jpeg`; 335 + } 336 + 337 + if (!profiledata) { 294 338 return ( 295 339 // Skeleton loader 296 340 <div ··· 316 360 className={`flex flex-row items-center gap-2.5 ${large ? "mb-1" : ""}`} 317 361 > 318 362 <img 319 - src={profile?.avatar} 363 + src={getAvatarUrl(profile) ?? undefined} 320 364 alt="avatar" 321 365 className={`object-cover rounded-full ${large ? "w-10 h-10" : "w-[30px] h-[30px]"}`} 322 366 /> ··· 329 373 <div 330 374 className={` ${large ? "text-gray-500 dark:text-gray-400 text-sm" : "text-gray-500 dark:text-gray-400 text-xs"}`} 331 375 > 332 - @{profile?.handle} 376 + @{identity?.handle} 333 377 </div> 334 378 </div> 335 379 </div>
+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 + */
+6
src/components/Star.tsx
··· 1 + import type { SVGProps } from 'react'; 2 + import React from 'react'; 3 + 4 + export function FluentEmojiHighContrastGlowingStar(props: SVGProps<SVGSVGElement>) { 5 + return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} viewBox="0 0 32 32" {...props}><g fill="currentColor"><path d="m28.979 17.003l-3.108.214c-.834.06-1.178 1.079-.542 1.608l2.388 1.955c.521.428 1.314.204 1.523-.428l.709-2.127c.219-.632-.292-1.273-.97-1.222M21.75 2.691l-.72 2.9c-.2.78.66 1.41 1.34.98l2.54-1.58c.55-.34.58-1.14.05-1.52l-1.78-1.29a.912.912 0 0 0-1.43.51M6.43 4.995l2.53 1.58c.68.43 1.54-.19 1.35-.98l-.72-2.9a.92.92 0 0 0-1.43-.52l-1.78 1.29c-.53.4-.5 1.19.05 1.53M4.185 20.713l2.29-1.92c.62-.52.29-1.53-.51-1.58l-2.98-.21a.92.92 0 0 0-.94 1.2l.68 2.09c.2.62.97.84 1.46.42m13.61 7.292l-1.12-2.77c-.3-.75-1.36-.75-1.66 0l-1.12 2.77c-.24.6.2 1.26.85 1.26h2.2a.92.92 0 0 0 .85-1.26"></path><path d="m17.565 3.324l1.726 3.72c.326.694.967 1.18 1.717 1.29l4.056.624c1.835.278 2.575 2.53 1.293 3.859L23.268 16a2.28 2.28 0 0 0-.612 1.964l.71 4.374c.307 1.885-1.687 3.293-3.354 2.37l-3.405-1.894a2.25 2.25 0 0 0-2.21 0l-3.404 1.895c-1.668.922-3.661-.486-3.355-2.37l.71-4.375A2.28 2.28 0 0 0 7.736 16l-3.088-3.184c-1.293-1.34-.543-3.581 1.293-3.859l4.055-.625a2.3 2.3 0 0 0 1.717-1.29l1.727-3.719c.819-1.765 3.306-1.765 4.124 0"></path></g></svg>); 6 + }
+420 -127
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"; 5 + import { DropdownMenu } from "radix-ui"; 6 + import { HoverCard } from "radix-ui"; 4 7 import * as React from "react"; 5 8 import { type SVGProps } from "react"; 6 9 7 - import { likedPostsAtom } from "~/utils/atoms"; 10 + import { 11 + composerAtom, 12 + constellationURLAtom, 13 + enableBridgyTextAtom, 14 + enableWafrnTextAtom, 15 + imgCDNAtom, 16 + } from "~/utils/atoms"; 8 17 import { useHydratedEmbed } from "~/utils/useHydrated"; 9 18 import { 10 19 useQueryConstellation, ··· 31 40 feedviewpost?: boolean; 32 41 repostedby?: string; 33 42 style?: React.CSSProperties; 34 - ref?: React.Ref<HTMLDivElement>; 43 + ref?: React.RefObject<HTMLDivElement>; 35 44 dataIndexPropPass?: number; 36 45 nopics?: boolean; 46 + concise?: boolean; 37 47 lightboxCallback?: (d: LightboxProps) => void; 38 48 maxReplies?: number; 49 + isQuote?: boolean; 50 + filterNoReplies?: boolean; 51 + filterMustHaveMedia?: boolean; 52 + filterMustBeReply?: boolean; 39 53 } 40 54 41 55 // export async function cachedGetRecord({ ··· 144 158 ref, 145 159 dataIndexPropPass, 146 160 nopics, 161 + concise, 147 162 lightboxCallback, 148 163 maxReplies, 164 + isQuote, 165 + filterNoReplies, 166 + filterMustHaveMedia, 167 + filterMustBeReply, 149 168 }: UniversalPostRendererATURILoaderProps) { 169 + // todo remove this once tree rendering is implemented, use a prop like isTree 170 + const TEMPLINEAR = true; 150 171 // /*mass comment*/ console.log("atUri", atUri); 151 172 //const { get, set } = usePersistentStore(); 152 173 //const [record, setRecord] = React.useState<any>(null); ··· 398 419 // path: ".reply.parent.uri", 399 420 // }); 400 421 422 + const [constellationurl] = useAtom(constellationURLAtom); 423 + 401 424 const infinitequeryresults = useInfiniteQuery({ 402 425 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 403 426 { 427 + constellation: constellationurl, 404 428 method: "/links", 405 429 target: atUri, 406 430 collection: "app.bsky.feed.post", 407 431 path: ".reply.parent.uri", 408 432 } 409 433 ), 410 - enabled: !!atUri && !!maxReplies, 434 + enabled: !!atUri && !!maxReplies && !isQuote, 411 435 }); 412 436 413 437 const { ··· 419 443 420 444 // auto-fetch all pages 421 445 useEffect(() => { 422 - if (!maxReplies) return; 446 + if (!maxReplies || isQuote || TEMPLINEAR) return; 423 447 if ( 424 448 infinitequeryresults.hasNextPage && 425 449 !infinitequeryresults.isFetchingNextPage ··· 427 451 console.log("Fetching the next page..."); 428 452 infinitequeryresults.fetchNextPage(); 429 453 } 430 - }, [infinitequeryresults]); 454 + }, [TEMPLINEAR, infinitequeryresults, isQuote, maxReplies]); 431 455 432 456 const replyAturis = repliesData 433 457 ? repliesData.pages.flatMap((page) => ··· 443 467 //const [oldestOpsReply, setOldestOpsReply] = useState<string | undefined>(undefined); 444 468 445 469 const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => { 446 - if (!replyAturis || replyAturis.length === 0 || !maxReplies) 470 + if (isQuote || !replyAturis || replyAturis.length === 0 || !maxReplies) 447 471 return { 448 472 oldestOpsReply: undefined, 449 473 oldestOpsReplyElseNewestNonOpsReply: undefined, ··· 504 528 ? true 505 529 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 506 530 ? false 507 - : bottomReplyLine 531 + : maxReplies === 0 && (!replies || (!!replies && replies === 0)) 532 + ? false 533 + : bottomReplyLine 508 534 } 509 535 topReplyLine={topReplyLine} 510 536 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 522 548 ref={ref} 523 549 dataIndexPropPass={dataIndexPropPass} 524 550 nopics={nopics} 551 + concise={concise} 525 552 lightboxCallback={lightboxCallback} 526 553 maxReplies={maxReplies} 554 + isQuote={isQuote} 555 + filterNoReplies={filterNoReplies} 556 + filterMustHaveMedia={filterMustHaveMedia} 557 + filterMustBeReply={filterMustBeReply} 527 558 /> 528 - {oldestOpsReplyElseNewestNonOpsReply && ( 559 + <> 560 + {maxReplies && maxReplies === 0 && replies && replies > 0 ? ( 561 + <> 562 + {/* <div>hello</div> */} 563 + <MoreReplies atUri={atUri} /> 564 + </> 565 + ) : ( 566 + <></> 567 + )} 568 + </> 569 + {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 529 570 <> 530 571 {/* <span>hello {maxReplies}</span> */} 531 572 <UniversalPostRendererATURILoader 532 573 //detailed={detailed} 533 574 atUri={oldestOpsReplyElseNewestNonOpsReply} 534 575 bottomReplyLine={(maxReplies ?? 0) > 0} 535 - topReplyLine={(maxReplies ?? 0) > 1} 576 + topReplyLine={ 577 + (!!(maxReplies && maxReplies - 1 === 0) && 578 + !!(replies && replies > 0)) || 579 + !!((maxReplies ?? 0) > 1) 580 + } 536 581 bottomBorder={bottomBorder} 537 582 feedviewpost={feedviewpost} 538 583 repostedby={repostedby} ··· 540 585 ref={ref} 541 586 dataIndexPropPass={dataIndexPropPass} 542 587 nopics={nopics} 588 + concise={concise} 543 589 lightboxCallback={lightboxCallback} 544 590 maxReplies={ 545 591 maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined 546 592 } 547 593 /> 548 - {maxReplies && maxReplies - 1 === 0 && ( 549 - <MoreReplies atUri={oldestOpsReplyElseNewestNonOpsReply} /> 550 - )} 551 594 </> 552 595 )} 553 596 </> ··· 588 631 ); 589 632 } 590 633 591 - function getAvatarUrl(opProfile: any, did: string) { 634 + function getAvatarUrl(opProfile: any, did: string, cdn: string) { 592 635 const link = opProfile?.value?.avatar?.ref?.["$link"]; 593 636 if (!link) return null; 594 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`; 637 + return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`; 595 638 } 596 639 597 640 export function UniversalPostRendererRawRecordShim({ ··· 612 655 ref, 613 656 dataIndexPropPass, 614 657 nopics, 658 + concise, 615 659 lightboxCallback, 616 660 maxReplies, 661 + isQuote, 662 + filterNoReplies, 663 + filterMustHaveMedia, 664 + filterMustBeReply, 617 665 }: { 618 666 postRecord: any; 619 667 profileRecord: any; ··· 629 677 feedviewpost?: boolean; 630 678 repostedby?: string; 631 679 style?: React.CSSProperties; 632 - ref?: React.Ref<HTMLDivElement>; 680 + ref?: React.RefObject<HTMLDivElement>; 633 681 dataIndexPropPass?: number; 634 682 nopics?: boolean; 683 + concise?: boolean; 635 684 lightboxCallback?: (d: LightboxProps) => void; 636 685 maxReplies?: number; 686 + isQuote?: boolean; 687 + filterNoReplies?: boolean; 688 + filterMustHaveMedia?: boolean; 689 + filterMustBeReply?: boolean; 637 690 }) { 638 691 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 639 692 const navigate = useNavigate(); ··· 704 757 // run(); 705 758 // }, [postRecord, resolved?.did]); 706 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); 776 + 707 777 const { 708 778 data: hydratedEmbed, 709 779 isLoading: isEmbedLoading, 710 780 error: embedError, 711 781 } = useHydratedEmbed(postRecord?.value?.embed, resolved?.did); 712 782 783 + const [imgcdn] = useAtom(imgCDNAtom); 784 + 713 785 const parsedaturi = new AtUri(aturi); //parseAtUri(aturi); 714 786 787 + const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>( 788 + () => ({ 789 + did: resolved?.did || "", 790 + handle: resolved?.handle || "", 791 + displayName: profileRecord?.value?.displayName || "", 792 + avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "", 793 + viewer: undefined, 794 + labels: profileRecord?.labels || undefined, 795 + verification: undefined, 796 + }), 797 + [imgcdn, profileRecord, resolved?.did, resolved?.handle] 798 + ); 799 + 800 + const fakeprofileviewdetailed = 801 + React.useMemo<AppBskyActorDefs.ProfileViewDetailed>( 802 + () => ({ 803 + ...fakeprofileviewbasic, 804 + $type: "app.bsky.actor.defs#profileViewDetailed", 805 + description: profileRecord?.value?.description || undefined, 806 + }), 807 + [fakeprofileviewbasic, profileRecord?.value?.description] 808 + ); 809 + 715 810 const fakepost = React.useMemo<AppBskyFeedDefs.PostView>( 716 811 () => ({ 717 812 $type: "app.bsky.feed.defs#postView", 718 813 uri: aturi, 719 814 cid: postRecord?.cid || "", 720 - author: { 721 - did: resolved?.did || "", 722 - handle: resolved?.handle || "", 723 - displayName: profileRecord?.value?.displayName || "", 724 - avatar: getAvatarUrl(profileRecord, resolved?.did) || "", 725 - viewer: undefined, 726 - labels: profileRecord?.labels || undefined, 727 - verification: undefined, 728 - }, 815 + author: fakeprofileviewbasic, 729 816 record: postRecord?.value || {}, 730 817 embed: hydratedEmbed ?? undefined, 731 818 replyCount: repliesCount ?? 0, ··· 742 829 postRecord?.cid, 743 830 postRecord?.value, 744 831 postRecord?.labels, 745 - resolved?.did, 746 - resolved?.handle, 747 - profileRecord, 832 + fakeprofileviewbasic, 748 833 hydratedEmbed, 749 834 repliesCount, 750 835 repostsCount, ··· 783 868 // }, [fakepost, get, set]); 784 869 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 785 870 ?.uri; 786 - const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; 871 + const feedviewpostreplydid = 872 + thereply && !filterNoReplies ? new AtUri(thereply).host : undefined; 787 873 const replyhookvalue = useQueryIdentity( 788 874 feedviewpost ? feedviewpostreplydid : undefined 789 875 ); ··· 794 880 repostedby ? aturirepostbydid : undefined 795 881 ); 796 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 + 797 890 return ( 798 891 <> 799 892 {/* <p> 800 893 {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} 801 894 </p> */} 895 + {/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span> 896 + <span>thereply is {thereply ? "true" : "false"}</span> */} 802 897 <UniversalPostRenderer 803 898 expanded={detailed} 804 899 onPostClick={() => ··· 821 916 } 822 917 }} 823 918 post={fakepost} 919 + uprrrsauthor={fakeprofileviewdetailed} 824 920 salt={aturi} 825 921 bottomReplyLine={bottomReplyLine} 826 922 topReplyLine={topReplyLine} ··· 832 928 ref={ref} 833 929 dataIndexPropPass={dataIndexPropPass} 834 930 nopics={nopics} 931 + concise={concise} 835 932 lightboxCallback={lightboxCallback} 836 933 maxReplies={maxReplies} 934 + isQuote={isQuote} 837 935 /> 838 936 </> 839 937 ); ··· 872 970 {...props} 873 971 > 874 972 <path 875 - fill="oklch(0.704 0.05 28)" 973 + fill="var(--color-gray-400)" 876 974 d="M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12z" 877 975 ></path> 878 976 </svg> ··· 889 987 {...props} 890 988 > 891 989 <path 892 - fill="oklch(0.704 0.05 28)" 990 + fill="var(--color-gray-400)" 893 991 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 894 992 ></path> 895 993 </svg> ··· 940 1038 {...props} 941 1039 > 942 1040 <path 943 - fill="oklch(0.704 0.05 28)" 1041 + fill="var(--color-gray-400)" 944 1042 d="m12.1 18.55l-.1.1l-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5c0 2.89-3.14 5.74-7.9 10.05M16.5 3c-1.74 0-3.41.81-4.5 2.08C10.91 3.81 9.24 3 7.5 3C4.42 3 2 5.41 2 8.5c0 3.77 3.4 6.86 8.55 11.53L12 21.35l1.45-1.32C18.6 15.36 22 12.27 22 8.5C22 5.41 19.58 3 16.5 3" 945 1043 ></path> 946 1044 </svg> ··· 957 1055 {...props} 958 1056 > 959 1057 <path 960 - fill="oklch(0.704 0.05 28)" 1058 + fill="var(--color-gray-400)" 961 1059 d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3a3 3 0 0 0-3-3a3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08" 962 1060 ></path> 963 1061 </svg> ··· 974 1072 {...props} 975 1073 > 976 1074 <path 977 - fill="oklch(0.704 0.05 28)" 1075 + fill="var(--color-gray-400)" 978 1076 d="M16 12a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2" 979 1077 ></path> 980 1078 </svg> ··· 991 1089 {...props} 992 1090 > 993 1091 <path 994 - fill="oklch(0.704 0.05 28)" 1092 + fill="var(--color-gray-400)" 995 1093 d="M17.9 17.39c-.26-.8-1.01-1.39-1.9-1.39h-1v-3a1 1 0 0 0-1-1H8v-2h2a1 1 0 0 0 1-1V7h2a2 2 0 0 0 2-2v-.41a7.984 7.984 0 0 1 2.9 12.8M11 19.93c-3.95-.49-7-3.85-7-7.93c0-.62.08-1.22.21-1.79L9 15v1a2 2 0 0 0 2 2m1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2" 996 1094 ></path> 997 1095 </svg> ··· 1025 1123 {...props} 1026 1124 > 1027 1125 <path 1028 - fill="oklch(0.704 0.05 28)" 1126 + fill="var(--color-gray-400)" 1029 1127 d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11" 1030 1128 ></path> 1031 1129 </svg> ··· 1079 1177 {...props} 1080 1178 > 1081 1179 <path 1082 - fill="oklch(0.704 0.05 28)" 1180 + fill="var(--color-gray-400)" 1083 1181 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 1084 1182 ></path> 1085 1183 </svg> ··· 1096 1194 {...props} 1097 1195 > 1098 1196 <path 1099 - fill="oklch(0.704 0.05 28)" 1197 + fill="var(--color-gray-400)" 1100 1198 d="M6 5.75L10.25 10H7v6h6.5l2 2H7a2 2 0 0 1-2-2v-6H1.75zm12 12.5L13.75 14H17V8h-6.5l-2-2H17a2 2 0 0 1 2 2v6h3.25z" 1101 1199 ></path> 1102 1200 </svg> ··· 1124 1222 //import Masonry from "@mui/lab/Masonry"; 1125 1223 import { 1126 1224 type $Typed, 1225 + AppBskyActorDefs, 1127 1226 AppBskyEmbedDefs, 1128 1227 AppBskyEmbedExternal, 1129 1228 AppBskyEmbedImages, ··· 1153 1252 1154 1253 import defaultpfp from "~/../public/favicon.png"; 1155 1254 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1255 + import { renderSnack } from "~/routes/__root"; 1256 + import { 1257 + FeedItemRenderAturiLoader, 1258 + FollowButton, 1259 + Mutual, 1260 + } from "~/routes/profile.$did"; 1156 1261 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1262 + import { useFastLike } from "~/utils/likeMutationQueue"; 1157 1263 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 1158 1264 // import type { 1159 1265 // ViewRecord, ··· 1261 1367 1262 1368 function UniversalPostRenderer({ 1263 1369 post, 1370 + uprrrsauthor, 1264 1371 //setMainItem, 1265 1372 //isMainItem, 1266 1373 onPostClick, ··· 1281 1388 ref, 1282 1389 dataIndexPropPass, 1283 1390 nopics, 1391 + concise, 1284 1392 lightboxCallback, 1285 1393 maxReplies, 1286 1394 }: { 1287 1395 post: PostView; 1396 + uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed; 1288 1397 // optional for now because i havent ported every use to this yet 1289 1398 // setMainItem?: React.Dispatch< 1290 1399 // React.SetStateAction<AppBskyFeedDefs.FeedViewPost> ··· 1303 1412 depth?: number; 1304 1413 repostedby?: string; 1305 1414 style?: React.CSSProperties; 1306 - ref?: React.Ref<HTMLDivElement>; 1415 + ref?: React.RefObject<HTMLDivElement>; 1307 1416 dataIndexPropPass?: number; 1308 1417 nopics?: boolean; 1418 + concise?: boolean; 1309 1419 lightboxCallback?: (d: LightboxProps) => void; 1310 1420 maxReplies?: number; 1311 1421 }) { 1312 1422 const parsed = new AtUri(post.uri); 1313 1423 const navigate = useNavigate(); 1314 - const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom); 1315 1424 const [hasRetweeted, setHasRetweeted] = useState<boolean>( 1316 1425 post.viewer?.repost ? true : false 1317 1426 ); 1318 - const [hasLiked, setHasLiked] = useState<boolean>( 1319 - post.uri in likedPosts || post.viewer?.like ? true : false 1320 - ); 1427 + const [, setComposerPost] = useAtom(composerAtom); 1321 1428 const { agent } = useAuth(); 1322 - const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like); 1323 1429 const [retweetUri, setRetweetUri] = useState<string | undefined>( 1324 1430 post.viewer?.repost 1325 1431 ); 1326 - 1327 - const likeOrUnlikePost = async () => { 1328 - const newLikedPosts = { ...likedPosts }; 1329 - if (!agent) { 1330 - console.error("Agent is null or undefined"); 1331 - return; 1332 - } 1333 - if (hasLiked) { 1334 - if (post.uri in likedPosts) { 1335 - const likeUri = likedPosts[post.uri]; 1336 - setLikeUri(likeUri); 1337 - } 1338 - if (likeUri) { 1339 - await agent.deleteLike(likeUri); 1340 - setHasLiked(false); 1341 - delete newLikedPosts[post.uri]; 1342 - } 1343 - } else { 1344 - const { uri } = await agent.like(post.uri, post.cid); 1345 - setLikeUri(uri); 1346 - setHasLiked(true); 1347 - newLikedPosts[post.uri] = uri; 1348 - } 1349 - setLikedPosts(newLikedPosts); 1350 - }; 1432 + const { liked, toggle, backfill } = useFastLike(post.uri, post.cid); 1433 + // const bovref = useBackfillOnView(post.uri, post.cid); 1434 + // React.useLayoutEffect(()=>{ 1435 + // if (expanded && !isQuote) { 1436 + // backfill(); 1437 + // } 1438 + // },[backfill, expanded, isQuote]) 1351 1439 1352 1440 const repostOrUnrepostPost = async () => { 1353 1441 if (!agent) { ··· 1378 1466 : undefined; 1379 1467 1380 1468 const emergencySalt = randomString(); 1381 - const fedi = (post.record as { bridgyOriginalText?: string }) 1469 + 1470 + const [showBridgyText] = useAtom(enableBridgyTextAtom); 1471 + const [showWafrnText] = useAtom(enableWafrnTextAtom); 1472 + 1473 + const unfedibridgy = (post.record as { bridgyOriginalText?: string }) 1382 1474 .bridgyOriginalText; 1475 + const unfediwafrnPartial = (post.record as { fullText?: string }).fullText; 1476 + const unfediwafrnTags = (post.record as { fullTags?: string }).fullTags; 1477 + const unfediwafrnUnHost = (post.record as { fediverseId?: string }) 1478 + .fediverseId; 1479 + 1480 + const undfediwafrnHost = unfediwafrnUnHost 1481 + ? new URL(unfediwafrnUnHost).hostname 1482 + : undefined; 1483 + 1484 + const tags = unfediwafrnTags 1485 + ? unfediwafrnTags 1486 + .split("\n") 1487 + .map((t) => t.trim()) 1488 + .filter(Boolean) 1489 + : undefined; 1490 + 1491 + const links = tags 1492 + ? tags 1493 + .map((tag) => { 1494 + const encoded = encodeURIComponent(tag); 1495 + return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`; 1496 + }) 1497 + .join("<br>") 1498 + : ""; 1499 + 1500 + const unfediwafrn = unfediwafrnPartial 1501 + ? unfediwafrnPartial + (links ? `<br>${links}` : "") 1502 + : undefined; 1503 + 1504 + const fedi = 1505 + (showBridgyText ? unfedibridgy : undefined) ?? 1506 + (showWafrnText ? unfediwafrn : undefined); 1383 1507 1384 1508 /* fuck you */ 1385 1509 const isMainItem = false; 1386 1510 const setMainItem = (any: any) => {}; 1387 1511 // eslint-disable-next-line react-hooks/refs 1388 - console.log("Received ref in UniversalPostRenderer:", ref); 1512 + //console.log("Received ref in UniversalPostRenderer:", usedref); 1389 1513 return ( 1390 1514 <div ref={ref} style={style} data-index={dataIndexPropPass}> 1391 1515 <div ··· 1414 1538 paddingLeft: isQuote ? 12 : 16, 1415 1539 paddingRight: isQuote ? 12 : 16, 1416 1540 //paddingTop: 16, 1417 - paddingTop: isRepost ? 10 : isQuote ? 12 : 16, 1541 + paddingTop: isRepost ? 10 : isQuote ? 12 : topReplyLine ? 8 : 16, 1418 1542 //paddingBottom: bottomReplyLine ? 0 : 16, 1419 1543 paddingBottom: 0, 1420 1544 fontFamily: "system-ui, sans-serif", ··· 1455 1579 //left: 16 + (42 / 2), 1456 1580 width: 2, 1457 1581 //height: "100%", 1458 - height: isRepost ? "calc(16px + 1rem - 6px)" : 16 - 6, 1582 + height: isRepost 1583 + ? "calc(16px + 1rem - 6px)" 1584 + : topReplyLine 1585 + ? 8 - 6 1586 + : 16 - 6, 1459 1587 // background: theme.textSecondary, 1460 1588 //opacity: 0.5, 1461 1589 // no flex here ··· 1463 1591 className="bg-gray-500 dark:bg-gray-400" 1464 1592 /> 1465 1593 )} 1466 - <div 1467 - style={{ 1468 - position: "absolute", 1469 - //top: isRepost ? "calc(16px + 1rem)" : 16, 1470 - //left: 16, 1471 - zIndex: 1, 1472 - top: isRepost ? "calc(16px + 1rem)" : isQuote ? 12 : 16, 1473 - left: isQuote ? 12 : 16, 1474 - }} 1475 - onClick={onProfileClick} 1476 - > 1477 - <img 1478 - src={post.author.avatar || defaultpfp} 1479 - alt="avatar" 1480 - // transition={{ 1481 - // type: "spring", 1482 - // stiffness: 260, 1483 - // damping: 20, 1484 - // }} 1485 - style={{ 1486 - borderRadius: "50%", 1487 - marginRight: 12, 1488 - objectFit: "cover", 1489 - //background: theme.border, 1490 - //border: `1px solid ${theme.border}`, 1491 - width: isQuote ? 16 : 42, 1492 - height: isQuote ? 16 : 42, 1493 - }} 1494 - className="border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1495 - /> 1496 - </div> 1594 + <HoverCard.Root> 1595 + <HoverCard.Trigger asChild> 1596 + <div 1597 + className={`absolute`} 1598 + style={{ 1599 + top: isRepost 1600 + ? "calc(16px + 1rem)" 1601 + : isQuote 1602 + ? 12 1603 + : topReplyLine 1604 + ? 8 1605 + : 16, 1606 + left: isQuote ? 12 : 16, 1607 + }} 1608 + onClick={onProfileClick} 1609 + > 1610 + <img 1611 + src={post.author.avatar || defaultpfp} 1612 + alt="avatar" 1613 + className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`} 1614 + style={{ 1615 + width: isQuote ? 16 : 42, 1616 + height: isQuote ? 16 : 42, 1617 + }} 1618 + /> 1619 + </div> 1620 + </HoverCard.Trigger> 1621 + <HoverCard.Portal> 1622 + <HoverCard.Content 1623 + className="rounded-md p-4 w-72 bg-gray-50 dark:bg-gray-900 shadow-lg border border-gray-300 dark:border-gray-800 animate-slide-fade z-50" 1624 + side={"bottom"} 1625 + sideOffset={5} 1626 + onClick={onProfileClick} 1627 + > 1628 + <div className="flex flex-col gap-2"> 1629 + <div className="flex flex-row"> 1630 + <img 1631 + src={post.author.avatar || defaultpfp} 1632 + alt="avatar" 1633 + className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1634 + /> 1635 + <div className=" flex-1 flex flex-row align-middle justify-end"> 1636 + <FollowButton targetdidorhandle={post.author.did} /> 1637 + </div> 1638 + </div> 1639 + <div className="flex flex-col gap-3"> 1640 + <div> 1641 + <div className="text-gray-900 dark:text-gray-100 font-medium text-md"> 1642 + {post.author.displayName || post.author.handle}{" "} 1643 + </div> 1644 + <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1645 + <Mutual targetdidorhandle={post.author.did} />@ 1646 + {post.author.handle}{" "} 1647 + </div> 1648 + </div> 1649 + {uprrrsauthor?.description && ( 1650 + <div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3"> 1651 + {uprrrsauthor.description} 1652 + </div> 1653 + )} 1654 + {/* <div className="flex gap-4"> 1655 + <div className="flex gap-1"> 1656 + <div className="font-medium text-gray-900 dark:text-gray-100"> 1657 + 0 1658 + </div> 1659 + <div className="text-gray-500 dark:text-gray-400"> 1660 + Following 1661 + </div> 1662 + </div> 1663 + <div className="flex gap-1"> 1664 + <div className="font-medium text-gray-900 dark:text-gray-100"> 1665 + 2,900 1666 + </div> 1667 + <div className="text-gray-500 dark:text-gray-400"> 1668 + Followers 1669 + </div> 1670 + </div> 1671 + </div> */} 1672 + </div> 1673 + </div> 1674 + 1675 + {/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */} 1676 + </HoverCard.Content> 1677 + </HoverCard.Portal> 1678 + </HoverCard.Root> 1679 + 1497 1680 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}> 1498 1681 <div 1499 1682 style={{ ··· 1507 1690 }} 1508 1691 > 1509 1692 {/* dummy for later use */} 1510 - <div style={{ width: 42, height: 42 + 8, minHeight: 42 + 8 }} /> 1693 + <div style={{ width: 42, height: 42 + 6, minHeight: 42 + 6 }} /> 1511 1694 {/* reply line !!!! bottomReplyLine */} 1512 1695 {bottomReplyLine && ( 1513 1696 <div ··· 1653 1836 <div 1654 1837 style={{ 1655 1838 fontSize: 16, 1656 - marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8, 1839 + marginBottom: !post.embed || concise ? 0 : 8, 1657 1840 whiteSpace: "pre-wrap", 1658 1841 textAlign: "left", 1659 1842 overflowWrap: "anywhere", 1660 1843 wordBreak: "break-word", 1661 - //color: theme.text, 1844 + ...(concise && { 1845 + display: "-webkit-box", 1846 + WebkitBoxOrient: "vertical", 1847 + WebkitLineClamp: 2, 1848 + overflow: "hidden", 1849 + }), 1662 1850 }} 1663 1851 className="text-gray-900 dark:text-gray-100" 1664 1852 > ··· 1681 1869 </> 1682 1870 )} 1683 1871 </div> 1684 - {post.embed && depth < 1 ? ( 1872 + {post.embed && depth < 1 && !concise ? ( 1685 1873 <PostEmbeds 1686 1874 embed={post.embed} 1687 1875 //moderation={moderation} ··· 1703 1891 </div> 1704 1892 </> 1705 1893 )} 1706 - <div style={{ paddingTop: post.embed && depth < 1 ? 4 : 0 }}> 1894 + <div 1895 + style={{ 1896 + paddingTop: post.embed && !concise && depth < 1 ? 4 : 0, 1897 + }} 1898 + > 1707 1899 <> 1708 1900 {expanded && ( 1709 1901 <div ··· 1740 1932 }} 1741 1933 className="text-gray-500 dark:text-gray-400" 1742 1934 > 1743 - <span style={btnstyle}> 1744 - <MdiCommentOutline /> 1745 - {post.replyCount} 1746 - </span> 1747 1935 <HitSlopButton 1748 1936 onClick={() => { 1749 - repostOrUnrepostPost(); 1937 + setComposerPost({ kind: "reply", parent: post.uri }); 1750 1938 }} 1751 1939 style={{ 1752 1940 ...btnstyle, 1753 - ...(hasRetweeted ? { color: "#5CEFAA" } : {}), 1754 1941 }} 1755 1942 > 1756 - {hasRetweeted ? <MdiRepeatGreen /> : <MdiRepeat />} 1757 - {(post.repostCount || 0) + (hasRetweeted ? 1 : 0)} 1943 + <MdiCommentOutline /> 1944 + {post.replyCount} 1758 1945 </HitSlopButton> 1946 + <DropdownMenu.Root modal={false}> 1947 + <DropdownMenu.Trigger asChild> 1948 + <div 1949 + style={{ 1950 + ...btnstyle, 1951 + ...(hasRetweeted ? { color: "#5CEFAA" } : {}), 1952 + }} 1953 + aria-label="Repost or quote post" 1954 + > 1955 + {hasRetweeted ? <MdiRepeatGreen /> : <MdiRepeat />} 1956 + {post.repostCount ?? 0} 1957 + </div> 1958 + </DropdownMenu.Trigger> 1959 + 1960 + <DropdownMenu.Portal> 1961 + <DropdownMenu.Content 1962 + align="start" 1963 + sideOffset={5} 1964 + className="bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-32 z-50 overflow-hidden" 1965 + > 1966 + <DropdownMenu.Item 1967 + onSelect={repostOrUnrepostPost} 1968 + className="px-3 py-2 text-sm flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700" 1969 + > 1970 + <MdiRepeat 1971 + className={hasRetweeted ? "text-green-400" : ""} 1972 + /> 1973 + <span>{hasRetweeted ? "Undo Repost" : "Repost"}</span> 1974 + </DropdownMenu.Item> 1975 + 1976 + <DropdownMenu.Item 1977 + onSelect={() => { 1978 + setComposerPost({ 1979 + kind: "quote", 1980 + subject: post.uri, 1981 + }); 1982 + }} 1983 + className="px-3 py-2 text-sm flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700" 1984 + > 1985 + {/* You might want a specific quote icon here */} 1986 + <MdiCommentOutline /> 1987 + <span>Quote</span> 1988 + </DropdownMenu.Item> 1989 + </DropdownMenu.Content> 1990 + </DropdownMenu.Portal> 1991 + </DropdownMenu.Root> 1759 1992 <HitSlopButton 1760 1993 onClick={() => { 1761 - likeOrUnlikePost(); 1994 + toggle(); 1762 1995 }} 1763 1996 style={{ 1764 1997 ...btnstyle, 1765 - ...(hasLiked ? { color: "#EC4899" } : {}), 1998 + ...(liked ? { color: "#EC4899" } : {}), 1766 1999 }} 1767 2000 > 1768 - {hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 1769 - {(post.likeCount || 0) + (hasLiked ? 1 : 0)} 2001 + {liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 2002 + {(post.likeCount || 0) + (liked ? 1 : 0)} 1770 2003 </HitSlopButton> 1771 2004 <div style={{ display: "flex", gap: 8 }}> 1772 2005 <HitSlopButton ··· 1780 2013 "/post/" + 1781 2014 post.uri.split("/").pop() 1782 2015 ); 2016 + renderSnack({ 2017 + title: "Copied to clipboard!", 2018 + }); 1783 2019 } catch (_e) { 1784 2020 // idk 2021 + renderSnack({ 2022 + title: "Failed to copy link", 2023 + }); 1785 2024 } 1786 2025 }} 1787 2026 style={{ ··· 1790 2029 > 1791 2030 <MdiShareVariant /> 1792 2031 </HitSlopButton> 1793 - <span style={btnstyle}> 1794 - <MdiMoreHoriz /> 1795 - </span> 2032 + <HitSlopButton 2033 + onClick={() => { 2034 + renderSnack({ 2035 + title: "Not implemented yet...", 2036 + }); 2037 + }} 2038 + > 2039 + <span style={btnstyle}> 2040 + <MdiMoreHoriz /> 2041 + </span> 2042 + </HitSlopButton> 1796 2043 </div> 1797 2044 </div> 1798 2045 )} ··· 2018 2265 } 2019 2266 2020 2267 if (AppBskyEmbedRecord.isView(embed)) { 2268 + // hey im really lazy and im gonna do it the bad way 2269 + const reallybaduri = (embed?.record as any)?.uri as string | undefined; 2270 + const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined; 2271 + 2021 2272 // custom feed embed (i.e. generator view) 2022 2273 if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 2023 2274 // stopgap sorry ··· 2027 2278 // <MaybeFeedCard view={embed.record} /> 2028 2279 // </div> 2029 2280 // ) 2281 + } else if ( 2282 + !!reallybaduri && 2283 + !!reallybadaturi && 2284 + reallybadaturi.collection === "app.bsky.feed.generator" 2285 + ) { 2286 + return ( 2287 + <div className="rounded-xl border"> 2288 + <FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder /> 2289 + </div> 2290 + ); 2030 2291 } 2031 2292 2032 2293 // list embed ··· 2038 2299 // <MaybeListCard view={embed.record} /> 2039 2300 // </div> 2040 2301 // ) 2302 + } else if ( 2303 + !!reallybaduri && 2304 + !!reallybadaturi && 2305 + reallybadaturi.collection === "app.bsky.graph.list" 2306 + ) { 2307 + return ( 2308 + <div className="rounded-xl border"> 2309 + <FeedItemRenderAturiLoader 2310 + aturi={reallybaduri} 2311 + disableBottomBorder 2312 + listmode 2313 + disablePropagation 2314 + /> 2315 + </div> 2316 + ); 2041 2317 } 2042 2318 2043 2319 // starter pack embed ··· 2049 2325 // <StarterPackCard starterPack={embed.record} /> 2050 2326 // </div> 2051 2327 // ) 2328 + } else if ( 2329 + !!reallybaduri && 2330 + !!reallybadaturi && 2331 + reallybadaturi.collection === "app.bsky.graph.starterpack" 2332 + ) { 2333 + return ( 2334 + <div className="rounded-xl border"> 2335 + <FeedItemRenderAturiLoader 2336 + aturi={reallybaduri} 2337 + disableBottomBorder 2338 + listmode 2339 + disablePropagation 2340 + /> 2341 + </div> 2342 + ); 2052 2343 } 2053 2344 2054 2345 // quote post ··· 2108 2399 </div> 2109 2400 ); 2110 2401 } else { 2402 + console.log("what the hell is a ", embed); 2111 2403 return <>sorry</>; 2112 2404 } 2113 2405 //return <QuotePostRenderer record={embed.record} moderation={moderation} />; ··· 2417 2709 // = 2418 2710 if (AppBskyEmbedVideo.isView(embed)) { 2419 2711 // hls playlist 2712 + if (nopics) return; 2420 2713 const playlist = embed.playlist; 2421 2714 return ( 2422 2715 <SmartHLSPlayer ··· 2508 2801 return { start, end, feature: f.features[0] }; 2509 2802 }); 2510 2803 } 2511 - function renderTextWithFacets({ 2804 + export function renderTextWithFacets({ 2512 2805 text, 2513 2806 facets, 2514 2807 navigate, ··· 2540 2833 className="link" 2541 2834 style={{ 2542 2835 textDecoration: "none", 2543 - color: "rgb(29, 122, 242)", 2836 + color: "var(--link-text-color)", 2544 2837 wordBreak: "break-all", 2545 2838 }} 2546 2839 target="_blank" ··· 2560 2853 result.push( 2561 2854 <span 2562 2855 key={start} 2563 - style={{ color: "rgb(29, 122, 242)" }} 2856 + style={{ color: "var(--link-text-color)" }} 2564 2857 className=" cursor-pointer" 2565 2858 onClick={(e) => { 2566 2859 e.stopPropagation(); ··· 2578 2871 result.push( 2579 2872 <span 2580 2873 key={start} 2581 - style={{ color: "rgb(29, 122, 242)" }} 2874 + style={{ color: "var(--link-text-color)" }} 2582 2875 onClick={(e) => { 2583 2876 e.stopPropagation(); 2584 2877 }}
+2
src/main.tsx
··· 14 14 import { routeTree } from "./routeTree.gen"; 15 15 import { isAtTopAtom } from "./utils/atoms.ts"; 16 16 17 + //initAtomToCssVar(hueAtom, "--tw-gray-hue") 18 + 17 19 const queryClient = new QueryClient({ 18 20 defaultOptions: { 19 21 queries: {
+163
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 { renderSnack } from "~/routes/__root"; 9 + import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms"; 10 + import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery"; 11 + 12 + export type LikeRecord = { uri: string; target: string; cid: string }; 13 + export type LikeMutation = { type: 'like'; target: string; cid: string }; 14 + export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord }; 15 + export type Mutation = LikeMutation | UnlikeMutation; 16 + 17 + interface LikeMutationQueueContextType { 18 + fastState: (target: string) => LikeRecord | null | undefined; 19 + fastToggle: (target:string, cid:string) => void; 20 + backfillState: (target: string, user: string) => Promise<void>; 21 + } 22 + 23 + const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined); 24 + 25 + export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) { 26 + const { agent } = useAuth(); 27 + const queryClient = useQueryClient(); 28 + const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom); 29 + const [constellationurl] = useAtom(constellationURLAtom); 30 + 31 + const likedPostsRef = useRef(likedPosts); 32 + useEffect(() => { 33 + likedPostsRef.current = likedPosts; 34 + }, [likedPosts]); 35 + 36 + const queueRef = useRef<Mutation[]>([]); 37 + const runningRef = useRef(false); 38 + 39 + const fastState = (target: string) => likedPosts[target]; 40 + 41 + const setFastState = useCallback( 42 + (target: string, record: LikeRecord | null) => 43 + setLikedPosts((prev) => ({ ...prev, [target]: record })), 44 + [setLikedPosts] 45 + ); 46 + 47 + const enqueue = (mutation: Mutation) => queueRef.current.push(mutation); 48 + 49 + const fastToggle = useCallback((target: string, cid: string) => { 50 + const likedRecord = likedPostsRef.current[target]; 51 + 52 + if (likedRecord) { 53 + setFastState(target, null); 54 + if (likedRecord.uri !== 'pending') { 55 + enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord }); 56 + } 57 + } else { 58 + setFastState(target, { uri: "pending", target, cid }); 59 + enqueue({ type: "like", target, cid }); 60 + } 61 + }, [setFastState]); 62 + 63 + /** 64 + * 65 + * @deprecated dont use it yet, will cause infinite rerenders 66 + */ 67 + const backfillState = async (target: string, user: string) => { 68 + const query = constructConstellationQuery({ 69 + constellation: constellationurl, 70 + method: "/links", 71 + target, 72 + collection: "app.bsky.feed.like", 73 + path: ".subject.uri", 74 + dids: [user], 75 + }); 76 + const data = await queryClient.fetchQuery(query); 77 + const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? []; 78 + const found = likes.find((r) => r.did === user); 79 + if (found) { 80 + const uri = `at://${found.did}/${found.collection}/${found.rkey}`; 81 + const ciddata = await queryClient.fetchQuery( 82 + constructArbitraryQuery(uri) 83 + ); 84 + if (ciddata?.cid) 85 + setFastState(target, { uri, target, cid: ciddata?.cid }); 86 + } else { 87 + setFastState(target, null); 88 + } 89 + }; 90 + 91 + 92 + useEffect(() => { 93 + if (!agent?.did) return; 94 + 95 + const processQueue = async () => { 96 + if (runningRef.current || queueRef.current.length === 0) return; 97 + runningRef.current = true; 98 + 99 + while (queueRef.current.length > 0) { 100 + const mutation = queueRef.current.shift()!; 101 + try { 102 + if (mutation.type === "like") { 103 + const newRecord = { 104 + repo: agent.did!, 105 + collection: "app.bsky.feed.like", 106 + rkey: TID.next().toString(), 107 + record: { 108 + $type: "app.bsky.feed.like", 109 + subject: { uri: mutation.target, cid: mutation.cid }, 110 + createdAt: new Date().toISOString(), 111 + }, 112 + }; 113 + const response = await agent.com.atproto.repo.createRecord(newRecord); 114 + if (!response.success) throw new Error("createRecord failed"); 115 + 116 + const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`; 117 + setFastState(mutation.target, { 118 + uri, 119 + target: mutation.target, 120 + cid: mutation.cid, 121 + }); 122 + } else if (mutation.type === "unlike") { 123 + const aturi = new AtUri(mutation.likeRecordUri); 124 + await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey }); 125 + setFastState(mutation.target, null); 126 + } 127 + } catch (err) { 128 + console.error("Like mutation failed, reverting:", err); 129 + renderSnack({ 130 + title: 'Like Mutation Failed', 131 + description: 'Please try again.', 132 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 133 + }) 134 + if (mutation.type === 'like') { 135 + setFastState(mutation.target, null); 136 + } else if (mutation.type === 'unlike') { 137 + setFastState(mutation.target, mutation.originalRecord); 138 + } 139 + } 140 + } 141 + runningRef.current = false; 142 + }; 143 + 144 + const interval = setInterval(processQueue, 1000); 145 + return () => clearInterval(interval); 146 + }, [agent, setFastState]); 147 + 148 + const value = { fastState, fastToggle, backfillState }; 149 + 150 + return ( 151 + <LikeMutationQueueContext value={value}> 152 + {children} 153 + </LikeMutationQueueContext> 154 + ); 155 + } 156 + 157 + export function useLikeMutationQueue() { 158 + const context = use(LikeMutationQueueContext); 159 + if (context === undefined) { 160 + throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider'); 161 + } 162 + return context; 163 + }
+26 -23
src/providers/UnifiedAuthProvider.tsx
··· 1 - // src/providers/UnifiedAuthProvider.tsx 2 - // Import both Agent and the (soon to be deprecated) AtpAgent 3 1 import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api"; 4 2 import { 5 3 type OAuthSession, ··· 7 5 TokenRefreshError, 8 6 TokenRevokedError, 9 7 } from "@atproto/oauth-client-browser"; 8 + import { useAtom } from "jotai"; 10 9 import React, { 11 10 createContext, 12 11 use, ··· 15 14 useState, 16 15 } from "react"; 17 16 18 - import { oauthClient } from "../utils/oauthClient"; // Adjust path if needed 17 + import { quickAuthAtom } from "~/utils/atoms"; 18 + 19 + import { oauthClient } from "../utils/oauthClient"; 19 20 20 - // Define the unified status and authentication method 21 21 type AuthStatus = "loading" | "signedIn" | "signedOut"; 22 22 type AuthMethod = "password" | "oauth" | null; 23 23 24 24 interface AuthContextValue { 25 - agent: Agent | null; // The agent is typed as the base class `Agent` 25 + agent: Agent | null; 26 26 status: AuthStatus; 27 27 authMethod: AuthMethod; 28 28 loginWithPassword: ( ··· 41 41 }: { 42 42 children: React.ReactNode; 43 43 }) => { 44 - // The state is typed as the base class `Agent`, which accepts both `Agent` and `AtpAgent` instances. 45 44 const [agent, setAgent] = useState<Agent | null>(null); 46 45 const [status, setStatus] = useState<AuthStatus>("loading"); 47 46 const [authMethod, setAuthMethod] = useState<AuthMethod>(null); 48 47 const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null); 48 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 49 49 50 - // Unified Initialization Logic 51 50 const initialize = useCallback(async () => { 52 - // --- 1. Try OAuth initialization first --- 53 51 try { 54 52 const oauthResult = await oauthClient.init(); 55 53 if (oauthResult) { 56 54 // /*mass comment*/ console.log("OAuth session restored."); 57 - const apiAgent = new Agent(oauthResult.session); // Standard Agent 55 + const apiAgent = new Agent(oauthResult.session); 58 56 setAgent(apiAgent); 59 57 setOauthSession(oauthResult.session); 60 58 setAuthMethod("oauth"); 61 59 setStatus("signedIn"); 62 - return; // Success 60 + setQuickAuth(apiAgent?.did || null); 61 + return; 63 62 } 64 63 } catch (e) { 65 64 console.error("OAuth init failed, checking password session.", e); 65 + if (!quickAuth) { 66 + // quickAuth restoration. if last used method is oauth we immediately call for oauth redo 67 + // (and set a persistent atom somewhere to not retry again if it failed) 68 + } 66 69 } 67 70 68 - // --- 2. If no OAuth, try password-based session using AtpAgent --- 69 71 try { 70 72 const service = localStorage.getItem("service"); 71 73 const sessionString = localStorage.getItem("sess"); 72 74 73 75 if (service && sessionString) { 74 76 // /*mass comment*/ console.log("Resuming password-based session using AtpAgent..."); 75 - // Use the original, working AtpAgent logic 76 77 const apiAgent = new AtpAgent({ service }); 77 78 const session: AtpSessionData = JSON.parse(sessionString); 78 79 await apiAgent.resumeSession(session); 79 80 80 81 // /*mass comment*/ console.log("Password-based session resumed successfully."); 81 - setAgent(apiAgent); // This works because AtpAgent is a subclass of Agent 82 + setAgent(apiAgent); 82 83 setAuthMethod("password"); 83 84 setStatus("signedIn"); 84 - return; // Success 85 + setQuickAuth(apiAgent?.did || null); 86 + return; 85 87 } 86 88 } catch (e) { 87 89 console.error("Failed to resume password-based session.", e); ··· 89 91 localStorage.removeItem("service"); 90 92 } 91 93 92 - // --- 3. If neither worked, user is signed out --- 93 94 // /*mass comment*/ console.log("No active session found."); 94 95 setStatus("signedOut"); 95 96 setAgent(null); 96 97 setAuthMethod(null); 97 - }, []); 98 + // do we want to null it here? 99 + setQuickAuth(null); 100 + }, [quickAuth, setQuickAuth]); 98 101 99 102 useEffect(() => { 100 103 const handleOAuthSessionDeleted = ( ··· 105 108 setOauthSession(null); 106 109 setAuthMethod(null); 107 110 setStatus("signedOut"); 111 + setQuickAuth(null); 108 112 }; 109 113 110 114 oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener); ··· 113 117 return () => { 114 118 oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener); 115 119 }; 116 - }, [initialize]); 120 + }, [initialize, setQuickAuth]); 117 121 118 - // --- Login Methods --- 119 122 const loginWithPassword = async ( 120 123 user: string, 121 124 password: string, ··· 125 128 setStatus("loading"); 126 129 try { 127 130 let sessionData: AtpSessionData | undefined; 128 - // Use the AtpAgent for its simple login and session persistence 129 131 const apiAgent = new AtpAgent({ 130 132 service, 131 133 persistSession: (_evt, sess) => { ··· 137 139 if (sessionData) { 138 140 localStorage.setItem("service", service); 139 141 localStorage.setItem("sess", JSON.stringify(sessionData)); 140 - setAgent(apiAgent); // Store the AtpAgent instance in our state 142 + setAgent(apiAgent); 141 143 setAuthMethod("password"); 142 144 setStatus("signedIn"); 145 + setQuickAuth(apiAgent?.did || null); 143 146 // /*mass comment*/ console.log("Successfully logged in with password."); 144 147 } else { 145 148 throw new Error("Session data not persisted after login."); ··· 147 150 } catch (e) { 148 151 console.error("Password login failed:", e); 149 152 setStatus("signedOut"); 153 + setQuickAuth(null); 150 154 throw e; 151 155 } 152 156 }; ··· 161 165 } 162 166 }, [status]); 163 167 164 - // --- Unified Logout --- 165 168 const logout = useCallback(async () => { 166 169 if (status !== "signedIn" || !agent) return; 167 170 setStatus("loading"); ··· 173 176 } else if (authMethod === "password") { 174 177 localStorage.removeItem("service"); 175 178 localStorage.removeItem("sess"); 176 - // AtpAgent has its own logout methods 177 179 await (agent as AtpAgent).com.atproto.server.deleteSession(); 178 180 // /*mass comment*/ console.log("Password-based session deleted."); 179 181 } ··· 184 186 setAuthMethod(null); 185 187 setOauthSession(null); 186 188 setStatus("signedOut"); 189 + setQuickAuth(null); 187 190 } 188 - }, [status, authMethod, agent, oauthSession]); 191 + }, [status, agent, authMethod, oauthSession, setQuickAuth]); 189 192 190 193 return ( 191 194 <AuthContext
+150
src/routeTree.gen.ts
··· 12 12 import { Route as SettingsRouteImport } from './routes/settings' 13 13 import { Route as SearchRouteImport } from './routes/search' 14 14 import { Route as NotificationsRouteImport } from './routes/notifications' 15 + import { Route as ModerationRouteImport } from './routes/moderation' 15 16 import { Route as FeedsRouteImport } from './routes/feeds' 16 17 import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' 17 18 import { Route as IndexRouteImport } from './routes/index' 18 19 import { Route as CallbackIndexRouteImport } from './routes/callback/index' 19 20 import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout' 20 21 import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index' 22 + import { Route as ProfileDidFollowsRouteImport } from './routes/profile.$did/follows' 23 + import { Route as ProfileDidFollowersRouteImport } from './routes/profile.$did/followers' 21 24 import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' 22 25 import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a' 23 26 import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey' 27 + import { Route as ProfileDidFeedRkeyRouteImport } from './routes/profile.$did/feed.$rkey' 28 + import { Route as ProfileDidPostRkeyRepostedByRouteImport } from './routes/profile.$did/post.$rkey.reposted-by' 29 + import { Route as ProfileDidPostRkeyQuotesRouteImport } from './routes/profile.$did/post.$rkey.quotes' 30 + import { Route as ProfileDidPostRkeyLikedByRouteImport } from './routes/profile.$did/post.$rkey.liked-by' 24 31 import { Route as ProfileDidPostRkeyImageIRouteImport } from './routes/profile.$did/post.$rkey.image.$i' 25 32 26 33 const SettingsRoute = SettingsRouteImport.update({ ··· 38 45 path: '/notifications', 39 46 getParentRoute: () => rootRouteImport, 40 47 } as any) 48 + const ModerationRoute = ModerationRouteImport.update({ 49 + id: '/moderation', 50 + path: '/moderation', 51 + getParentRoute: () => rootRouteImport, 52 + } as any) 41 53 const FeedsRoute = FeedsRouteImport.update({ 42 54 id: '/feeds', 43 55 path: '/feeds', ··· 67 79 path: '/profile/$did/', 68 80 getParentRoute: () => rootRouteImport, 69 81 } as any) 82 + const ProfileDidFollowsRoute = ProfileDidFollowsRouteImport.update({ 83 + id: '/profile/$did/follows', 84 + path: '/profile/$did/follows', 85 + getParentRoute: () => rootRouteImport, 86 + } as any) 87 + const ProfileDidFollowersRoute = ProfileDidFollowersRouteImport.update({ 88 + id: '/profile/$did/followers', 89 + path: '/profile/$did/followers', 90 + getParentRoute: () => rootRouteImport, 91 + } as any) 70 92 const PathlessLayoutNestedLayoutRouteBRoute = 71 93 PathlessLayoutNestedLayoutRouteBRouteImport.update({ 72 94 id: '/route-b', ··· 84 106 path: '/profile/$did/post/$rkey', 85 107 getParentRoute: () => rootRouteImport, 86 108 } as any) 109 + const ProfileDidFeedRkeyRoute = ProfileDidFeedRkeyRouteImport.update({ 110 + id: '/profile/$did/feed/$rkey', 111 + path: '/profile/$did/feed/$rkey', 112 + getParentRoute: () => rootRouteImport, 113 + } as any) 114 + const ProfileDidPostRkeyRepostedByRoute = 115 + ProfileDidPostRkeyRepostedByRouteImport.update({ 116 + id: '/reposted-by', 117 + path: '/reposted-by', 118 + getParentRoute: () => ProfileDidPostRkeyRoute, 119 + } as any) 120 + const ProfileDidPostRkeyQuotesRoute = 121 + ProfileDidPostRkeyQuotesRouteImport.update({ 122 + id: '/quotes', 123 + path: '/quotes', 124 + getParentRoute: () => ProfileDidPostRkeyRoute, 125 + } as any) 126 + const ProfileDidPostRkeyLikedByRoute = 127 + ProfileDidPostRkeyLikedByRouteImport.update({ 128 + id: '/liked-by', 129 + path: '/liked-by', 130 + getParentRoute: () => ProfileDidPostRkeyRoute, 131 + } as any) 87 132 const ProfileDidPostRkeyImageIRoute = 88 133 ProfileDidPostRkeyImageIRouteImport.update({ 89 134 id: '/image/$i', ··· 94 139 export interface FileRoutesByFullPath { 95 140 '/': typeof IndexRoute 96 141 '/feeds': typeof FeedsRoute 142 + '/moderation': typeof ModerationRoute 97 143 '/notifications': typeof NotificationsRoute 98 144 '/search': typeof SearchRoute 99 145 '/settings': typeof SettingsRoute 100 146 '/callback': typeof CallbackIndexRoute 101 147 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 102 148 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 149 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 150 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 103 151 '/profile/$did': typeof ProfileDidIndexRoute 152 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 104 153 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 154 + '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 155 + '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute 156 + '/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute 105 157 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 106 158 } 107 159 export interface FileRoutesByTo { 108 160 '/': typeof IndexRoute 109 161 '/feeds': typeof FeedsRoute 162 + '/moderation': typeof ModerationRoute 110 163 '/notifications': typeof NotificationsRoute 111 164 '/search': typeof SearchRoute 112 165 '/settings': typeof SettingsRoute 113 166 '/callback': typeof CallbackIndexRoute 114 167 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 115 168 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 169 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 170 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 116 171 '/profile/$did': typeof ProfileDidIndexRoute 172 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 117 173 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 174 + '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 175 + '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute 176 + '/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute 118 177 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 119 178 } 120 179 export interface FileRoutesById { ··· 122 181 '/': typeof IndexRoute 123 182 '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren 124 183 '/feeds': typeof FeedsRoute 184 + '/moderation': typeof ModerationRoute 125 185 '/notifications': typeof NotificationsRoute 126 186 '/search': typeof SearchRoute 127 187 '/settings': typeof SettingsRoute ··· 129 189 '/callback/': typeof CallbackIndexRoute 130 190 '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 131 191 '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 192 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 193 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 132 194 '/profile/$did/': typeof ProfileDidIndexRoute 195 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 133 196 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 197 + '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 198 + '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute 199 + '/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute 134 200 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 135 201 } 136 202 export interface FileRouteTypes { ··· 138 204 fullPaths: 139 205 | '/' 140 206 | '/feeds' 207 + | '/moderation' 141 208 | '/notifications' 142 209 | '/search' 143 210 | '/settings' 144 211 | '/callback' 145 212 | '/route-a' 146 213 | '/route-b' 214 + | '/profile/$did/followers' 215 + | '/profile/$did/follows' 147 216 | '/profile/$did' 217 + | '/profile/$did/feed/$rkey' 148 218 | '/profile/$did/post/$rkey' 219 + | '/profile/$did/post/$rkey/liked-by' 220 + | '/profile/$did/post/$rkey/quotes' 221 + | '/profile/$did/post/$rkey/reposted-by' 149 222 | '/profile/$did/post/$rkey/image/$i' 150 223 fileRoutesByTo: FileRoutesByTo 151 224 to: 152 225 | '/' 153 226 | '/feeds' 227 + | '/moderation' 154 228 | '/notifications' 155 229 | '/search' 156 230 | '/settings' 157 231 | '/callback' 158 232 | '/route-a' 159 233 | '/route-b' 234 + | '/profile/$did/followers' 235 + | '/profile/$did/follows' 160 236 | '/profile/$did' 237 + | '/profile/$did/feed/$rkey' 161 238 | '/profile/$did/post/$rkey' 239 + | '/profile/$did/post/$rkey/liked-by' 240 + | '/profile/$did/post/$rkey/quotes' 241 + | '/profile/$did/post/$rkey/reposted-by' 162 242 | '/profile/$did/post/$rkey/image/$i' 163 243 id: 164 244 | '__root__' 165 245 | '/' 166 246 | '/_pathlessLayout' 167 247 | '/feeds' 248 + | '/moderation' 168 249 | '/notifications' 169 250 | '/search' 170 251 | '/settings' ··· 172 253 | '/callback/' 173 254 | '/_pathlessLayout/_nested-layout/route-a' 174 255 | '/_pathlessLayout/_nested-layout/route-b' 256 + | '/profile/$did/followers' 257 + | '/profile/$did/follows' 175 258 | '/profile/$did/' 259 + | '/profile/$did/feed/$rkey' 176 260 | '/profile/$did/post/$rkey' 261 + | '/profile/$did/post/$rkey/liked-by' 262 + | '/profile/$did/post/$rkey/quotes' 263 + | '/profile/$did/post/$rkey/reposted-by' 177 264 | '/profile/$did/post/$rkey/image/$i' 178 265 fileRoutesById: FileRoutesById 179 266 } ··· 181 268 IndexRoute: typeof IndexRoute 182 269 PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren 183 270 FeedsRoute: typeof FeedsRoute 271 + ModerationRoute: typeof ModerationRoute 184 272 NotificationsRoute: typeof NotificationsRoute 185 273 SearchRoute: typeof SearchRoute 186 274 SettingsRoute: typeof SettingsRoute 187 275 CallbackIndexRoute: typeof CallbackIndexRoute 276 + ProfileDidFollowersRoute: typeof ProfileDidFollowersRoute 277 + ProfileDidFollowsRoute: typeof ProfileDidFollowsRoute 188 278 ProfileDidIndexRoute: typeof ProfileDidIndexRoute 279 + ProfileDidFeedRkeyRoute: typeof ProfileDidFeedRkeyRoute 189 280 ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren 190 281 } 191 282 ··· 212 303 preLoaderRoute: typeof NotificationsRouteImport 213 304 parentRoute: typeof rootRouteImport 214 305 } 306 + '/moderation': { 307 + id: '/moderation' 308 + path: '/moderation' 309 + fullPath: '/moderation' 310 + preLoaderRoute: typeof ModerationRouteImport 311 + parentRoute: typeof rootRouteImport 312 + } 215 313 '/feeds': { 216 314 id: '/feeds' 217 315 path: '/feeds' ··· 254 352 preLoaderRoute: typeof ProfileDidIndexRouteImport 255 353 parentRoute: typeof rootRouteImport 256 354 } 355 + '/profile/$did/follows': { 356 + id: '/profile/$did/follows' 357 + path: '/profile/$did/follows' 358 + fullPath: '/profile/$did/follows' 359 + preLoaderRoute: typeof ProfileDidFollowsRouteImport 360 + parentRoute: typeof rootRouteImport 361 + } 362 + '/profile/$did/followers': { 363 + id: '/profile/$did/followers' 364 + path: '/profile/$did/followers' 365 + fullPath: '/profile/$did/followers' 366 + preLoaderRoute: typeof ProfileDidFollowersRouteImport 367 + parentRoute: typeof rootRouteImport 368 + } 257 369 '/_pathlessLayout/_nested-layout/route-b': { 258 370 id: '/_pathlessLayout/_nested-layout/route-b' 259 371 path: '/route-b' ··· 275 387 preLoaderRoute: typeof ProfileDidPostRkeyRouteImport 276 388 parentRoute: typeof rootRouteImport 277 389 } 390 + '/profile/$did/feed/$rkey': { 391 + id: '/profile/$did/feed/$rkey' 392 + path: '/profile/$did/feed/$rkey' 393 + fullPath: '/profile/$did/feed/$rkey' 394 + preLoaderRoute: typeof ProfileDidFeedRkeyRouteImport 395 + parentRoute: typeof rootRouteImport 396 + } 397 + '/profile/$did/post/$rkey/reposted-by': { 398 + id: '/profile/$did/post/$rkey/reposted-by' 399 + path: '/reposted-by' 400 + fullPath: '/profile/$did/post/$rkey/reposted-by' 401 + preLoaderRoute: typeof ProfileDidPostRkeyRepostedByRouteImport 402 + parentRoute: typeof ProfileDidPostRkeyRoute 403 + } 404 + '/profile/$did/post/$rkey/quotes': { 405 + id: '/profile/$did/post/$rkey/quotes' 406 + path: '/quotes' 407 + fullPath: '/profile/$did/post/$rkey/quotes' 408 + preLoaderRoute: typeof ProfileDidPostRkeyQuotesRouteImport 409 + parentRoute: typeof ProfileDidPostRkeyRoute 410 + } 411 + '/profile/$did/post/$rkey/liked-by': { 412 + id: '/profile/$did/post/$rkey/liked-by' 413 + path: '/liked-by' 414 + fullPath: '/profile/$did/post/$rkey/liked-by' 415 + preLoaderRoute: typeof ProfileDidPostRkeyLikedByRouteImport 416 + parentRoute: typeof ProfileDidPostRkeyRoute 417 + } 278 418 '/profile/$did/post/$rkey/image/$i': { 279 419 id: '/profile/$did/post/$rkey/image/$i' 280 420 path: '/image/$i' ··· 316 456 ) 317 457 318 458 interface ProfileDidPostRkeyRouteChildren { 459 + ProfileDidPostRkeyLikedByRoute: typeof ProfileDidPostRkeyLikedByRoute 460 + ProfileDidPostRkeyQuotesRoute: typeof ProfileDidPostRkeyQuotesRoute 461 + ProfileDidPostRkeyRepostedByRoute: typeof ProfileDidPostRkeyRepostedByRoute 319 462 ProfileDidPostRkeyImageIRoute: typeof ProfileDidPostRkeyImageIRoute 320 463 } 321 464 322 465 const ProfileDidPostRkeyRouteChildren: ProfileDidPostRkeyRouteChildren = { 466 + ProfileDidPostRkeyLikedByRoute: ProfileDidPostRkeyLikedByRoute, 467 + ProfileDidPostRkeyQuotesRoute: ProfileDidPostRkeyQuotesRoute, 468 + ProfileDidPostRkeyRepostedByRoute: ProfileDidPostRkeyRepostedByRoute, 323 469 ProfileDidPostRkeyImageIRoute: ProfileDidPostRkeyImageIRoute, 324 470 } 325 471 ··· 330 476 IndexRoute: IndexRoute, 331 477 PathlessLayoutRoute: PathlessLayoutRouteWithChildren, 332 478 FeedsRoute: FeedsRoute, 479 + ModerationRoute: ModerationRoute, 333 480 NotificationsRoute: NotificationsRoute, 334 481 SearchRoute: SearchRoute, 335 482 SettingsRoute: SettingsRoute, 336 483 CallbackIndexRoute: CallbackIndexRoute, 484 + ProfileDidFollowersRoute: ProfileDidFollowersRoute, 485 + ProfileDidFollowsRoute: ProfileDidFollowsRoute, 337 486 ProfileDidIndexRoute: ProfileDidIndexRoute, 487 + ProfileDidFeedRkeyRoute: ProfileDidFeedRkeyRoute, 338 488 ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren, 339 489 } 340 490 export const routeTree = rootRouteImport
+197 -104
src/routes/__root.tsx
··· 12 12 useNavigate, 13 13 } from "@tanstack/react-router"; 14 14 import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 15 - import { useState } from "react"; 15 + import { useAtom } from "jotai"; 16 16 import * as React from "react"; 17 + import { toast as sonnerToast } from "sonner"; 18 + import { Toaster } from "sonner"; 17 19 import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive"; 18 20 21 + import { Composer } from "~/components/Composer"; 19 22 import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 23 + import { Import } from "~/components/Import"; 20 24 import Login from "~/components/Login"; 21 25 import { NotFound } from "~/components/NotFound"; 26 + import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; 27 + import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; 22 28 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 29 + import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms"; 23 30 import { seo } from "~/utils/seo"; 24 31 25 32 export const Route = createRootRouteWithContext<{ ··· 75 82 function RootComponent() { 76 83 return ( 77 84 <UnifiedAuthProvider> 78 - <RootDocument> 79 - <KeepAliveProvider> 80 - <KeepAliveOutlet /> 81 - </KeepAliveProvider> 82 - </RootDocument> 85 + <LikeMutationQueueProvider> 86 + <RootDocument> 87 + <KeepAliveProvider> 88 + <AppToaster /> 89 + <KeepAliveOutlet /> 90 + </KeepAliveProvider> 91 + </RootDocument> 92 + </LikeMutationQueueProvider> 83 93 </UnifiedAuthProvider> 84 94 ); 85 95 } 86 96 97 + export function AppToaster() { 98 + return ( 99 + <Toaster 100 + position="bottom-center" 101 + toastOptions={{ 102 + duration: 4000, 103 + }} 104 + /> 105 + ); 106 + } 107 + 108 + export function renderSnack({ 109 + title, 110 + description, 111 + button, 112 + }: Omit<ToastProps, "id">) { 113 + return sonnerToast.custom((id) => ( 114 + <Snack 115 + id={id} 116 + title={title} 117 + description={description} 118 + button={ 119 + button?.label 120 + ? { 121 + label: button?.label, 122 + onClick: () => { 123 + button?.onClick?.(); 124 + }, 125 + } 126 + : undefined 127 + } 128 + /> 129 + )); 130 + } 131 + 132 + function Snack(props: ToastProps) { 133 + const { title, description, button, id } = props; 134 + 135 + return ( 136 + <div 137 + role="status" 138 + aria-live="polite" 139 + className=" 140 + w-full md:max-w-[520px] 141 + flex items-center justify-between 142 + rounded-md 143 + px-4 py-3 144 + shadow-sm 145 + dark:bg-gray-300 dark:text-gray-900 146 + bg-gray-700 text-gray-100 147 + ring-1 dark:ring-gray-200 ring-gray-800 148 + " 149 + > 150 + <div className="flex-1 min-w-0"> 151 + <p className="text-sm font-medium truncate">{title}</p> 152 + {description ? ( 153 + <p className="mt-1 text-sm dark:text-gray-600 text-gray-300 truncate"> 154 + {description} 155 + </p> 156 + ) : null} 157 + </div> 158 + 159 + {button ? ( 160 + <div className="ml-4 flex-shrink-0"> 161 + <button 162 + className=" 163 + text-sm font-medium 164 + px-3 py-1 rounded-md 165 + bg-gray-200 text-gray-900 166 + hover:bg-gray-300 167 + dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 168 + focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-300 dark:focus:ring-gray-700 169 + " 170 + onClick={() => { 171 + button.onClick(); 172 + sonnerToast.dismiss(id); 173 + }} 174 + > 175 + {button.label} 176 + </button> 177 + </div> 178 + ) : null} 179 + <button className=" ml-4" 180 + onClick={() => { 181 + sonnerToast.dismiss(id); 182 + }} 183 + > 184 + <IconMdiClose /> 185 + </button> 186 + </div> 187 + ); 188 + } 189 + 190 + /* Types */ 191 + interface ToastProps { 192 + id: string | number; 193 + title: string; 194 + description?: string; 195 + button?: { 196 + label: string; 197 + onClick: () => void; 198 + }; 199 + } 200 + 87 201 function RootDocument({ children }: { children: React.ReactNode }) { 202 + useAtomCssVar(hueAtom, "--tw-gray-hue"); 88 203 const location = useLocation(); 89 204 const navigate = useNavigate(); 90 205 const { agent } = useAuth(); ··· 98 213 const isSettings = location.pathname.startsWith("/settings"); 99 214 const isSearch = location.pathname.startsWith("/search"); 100 215 const isFeeds = location.pathname.startsWith("/feeds"); 216 + const isModeration = location.pathname.startsWith("/moderation"); 101 217 102 218 const locationEnum: 103 219 | "feeds" ··· 105 221 | "settings" 106 222 | "notifications" 107 223 | "profile" 224 + | "moderation" 108 225 | "home" = isFeeds 109 226 ? "feeds" 110 227 : isSearch ··· 115 232 ? "notifications" 116 233 : isProfile 117 234 ? "profile" 118 - : "home"; 235 + : isModeration 236 + ? "moderation" 237 + : "home"; 119 238 120 - const [postOpen, setPostOpen] = useState(false); 121 - const [postText, setPostText] = useState(""); 122 - const [posting, setPosting] = useState(false); 123 - const [postSuccess, setPostSuccess] = useState(false); 124 - const [postError, setPostError] = useState<string | null>(null); 125 - 126 - async function handlePost() { 127 - if (!agent) return; 128 - setPosting(true); 129 - setPostError(null); 130 - try { 131 - await agent.com.atproto.repo.createRecord({ 132 - collection: "app.bsky.feed.post", 133 - repo: agent.assertDid, 134 - record: { 135 - $type: "app.bsky.feed.post", 136 - text: postText, 137 - createdAt: new Date().toISOString(), 138 - }, 139 - }); 140 - setPostSuccess(true); 141 - setPostText(""); 142 - setTimeout(() => { 143 - setPostSuccess(false); 144 - setPostOpen(false); 145 - }, 1500); 146 - } catch (e: any) { 147 - setPostError(e?.message || "Failed to post"); 148 - } finally { 149 - setPosting(false); 150 - } 151 - } 239 + const [, setComposerPost] = useAtom(composerAtom); 152 240 153 241 return ( 154 242 <> 155 - {postOpen && ( 156 - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> 157 - <div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-md relative"> 158 - <button 159 - className="absolute top-2 right-2 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200" 160 - onClick={() => !posting && setPostOpen(false)} 161 - disabled={posting} 162 - aria-label="Close" 163 - > 164 - ร— 165 - </button> 166 - <h2 className="text-lg font-bold mb-2">Create Post</h2> 167 - {postSuccess ? ( 168 - <div className="flex flex-col items-center justify-center py-8"> 169 - <span className="text-green-500 text-4xl mb-2">โœ“</span> 170 - <span className="text-green-600">Posted!</span> 171 - </div> 172 - ) : ( 173 - <> 174 - <textarea 175 - className="w-full border rounded p-2 mb-2 dark:bg-gray-800 dark:border-gray-700" 176 - rows={4} 177 - placeholder="What's on your mind?" 178 - value={postText} 179 - onChange={(e) => setPostText(e.target.value)} 180 - disabled={posting} 181 - autoFocus 182 - /> 183 - {postError && ( 184 - <div className="text-red-500 text-sm mb-2">{postError}</div> 185 - )} 186 - <button 187 - className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50" 188 - onClick={handlePost} 189 - disabled={posting || !postText.trim()} 190 - > 191 - {posting ? "Posting..." : "Post"} 192 - </button> 193 - </> 194 - )} 195 - </div> 196 - </div> 197 - )} 243 + <Composer /> 198 244 199 245 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 200 246 <nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start"> 201 247 <div className="flex items-center gap-3 mb-4"> 202 - <img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" /> 248 + <FluentEmojiHighContrastGlowingStar 249 + className="h-8 w-8" 250 + style={{ 251 + color: 252 + "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))", 253 + }} 254 + /> 203 255 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 204 256 Red Dwarf{" "} 205 257 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 223 275 /> 224 276 225 277 <MaterialNavItem 278 + InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 279 + ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 280 + active={locationEnum === "search"} 281 + onClickCallbback={() => 282 + navigate({ 283 + to: "/search", 284 + //params: { did: agent.assertDid }, 285 + }) 286 + } 287 + text="Explore" 288 + /> 289 + <MaterialNavItem 226 290 InactiveIcon={ 227 291 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 228 292 } ··· 251 315 text="Feeds" 252 316 /> 253 317 <MaterialNavItem 254 - InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 255 - ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 256 - active={locationEnum === "search"} 318 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 319 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 320 + active={locationEnum === "moderation"} 257 321 onClickCallbback={() => 258 322 navigate({ 259 - to: "/search", 323 + to: "/moderation", 260 324 //params: { did: agent.assertDid }, 261 325 }) 262 326 } 263 - text="Search" 327 + text="Moderation" 264 328 /> 265 329 <MaterialNavItem 266 330 InactiveIcon={ ··· 300 364 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 301 365 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 302 366 //active={true} 303 - onClickCallbback={() => setPostOpen(true)} 367 + onClickCallbback={() => setComposerPost({ kind: "root" })} 304 368 text="Post" 305 369 /> 306 370 </div> ··· 438 502 439 503 <nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start"> 440 504 <div className="flex items-center gap-3 mb-4"> 441 - <img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" /> 505 + <FluentEmojiHighContrastGlowingStar 506 + className="h-8 w-8" 507 + style={{ 508 + color: 509 + "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))", 510 + }} 511 + /> 442 512 </div> 443 513 <MaterialNavItem 444 514 small ··· 458 528 459 529 <MaterialNavItem 460 530 small 531 + InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 532 + ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 533 + active={locationEnum === "search"} 534 + onClickCallbback={() => 535 + navigate({ 536 + to: "/search", 537 + //params: { did: agent.assertDid }, 538 + }) 539 + } 540 + text="Explore" 541 + /> 542 + <MaterialNavItem 543 + small 461 544 InactiveIcon={ 462 545 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 463 546 } ··· 488 571 /> 489 572 <MaterialNavItem 490 573 small 491 - InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 492 - ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 493 - active={locationEnum === "search"} 574 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 575 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 576 + active={locationEnum === "moderation"} 494 577 onClickCallbback={() => 495 578 navigate({ 496 - to: "/search", 579 + to: "/moderation", 497 580 //params: { did: agent.assertDid }, 498 581 }) 499 582 } 500 - text="Search" 583 + text="Moderation" 501 584 /> 502 585 <MaterialNavItem 503 586 small ··· 540 623 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 541 624 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 542 625 //active={true} 543 - onClickCallbback={() => setPostOpen(true)} 626 + onClickCallbback={() => setComposerPost({ kind: "root" })} 544 627 text="Post" 545 628 /> 546 629 </div> ··· 550 633 <button 551 634 className="lg:hidden fixed bottom-22 right-4 z-50 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-2xl w-14 h-14 flex items-center justify-center transition-all" 552 635 style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }} 553 - onClick={() => setPostOpen(true)} 636 + onClick={() => setComposerPost({ kind: "root" })} 554 637 type="button" 555 638 aria-label="Create Post" 556 639 > ··· 567 650 </main> 568 651 569 652 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 653 + <div className="px-4 pt-4"> 654 + <Import /> 655 + </div> 570 656 <Login /> 571 657 572 658 <div className="flex-1"></div> 573 659 <p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4"> 574 - Red Dwarf is a bluesky client that uses Constellation and direct PDS 575 - queries. Skylite would be a self-hosted bluesky "instance". Stay 576 - tuned for the release of Skylite. 660 + Red Dwarf is a Bluesky client that does not rely on any Bluesky API 661 + App Servers. Instead, it uses Microcosm to fetch records directly 662 + from each users' PDS (via Slingshot) and connect them using 663 + backlinks (via Constellation) 577 664 </p> 578 665 </aside> 579 666 </div> ··· 622 709 //params: { did: agent.assertDid }, 623 710 }) 624 711 } 625 - text="Search" 712 + text="Explore" 626 713 /> 627 714 {/* <Link 628 715 to="/search" ··· 720 807 <IconMaterialSymbolsSettingsOutline className="w-6 h-6" /> 721 808 } 722 809 ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />} 723 - active={locationEnum === "settings"} 810 + active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"} 724 811 onClickCallbback={() => 725 812 navigate({ 726 813 to: "/settings", ··· 749 836 ) : ( 750 837 <div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10"> 751 838 <div className="flex items-center gap-2"> 752 - <img src="/redstar.png" alt="Red Dwarf Logo" className="w-6 h-6" /> 839 + <FluentEmojiHighContrastGlowingStar 840 + className="h-6 w-6" 841 + style={{ 842 + color: 843 + "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))", 844 + }} 845 + /> 753 846 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 754 847 Red Dwarf{" "} 755 848 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 769 862 ); 770 863 } 771 864 772 - function MaterialNavItem({ 865 + export function MaterialNavItem({ 773 866 InactiveIcon, 774 867 ActiveIcon, 775 868 text,
+18 -1
src/routes/feeds.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 3 + import { Header } from "~/components/Header"; 4 + 3 5 export const Route = createFileRoute("/feeds")({ 4 6 component: Feeds, 5 7 }); 6 8 7 9 export function Feeds() { 8 - return <div className="p-6">Feeds page (coming soon)</div>; 10 + return ( 11 + <div className=""> 12 + <Header 13 + title={`Feeds`} 14 + backButtonCallback={() => { 15 + if (window.history.length > 1) { 16 + window.history.back(); 17 + } else { 18 + window.location.assign("/"); 19 + } 20 + }} 21 + bottomBorderDisabled={true} 22 + /> 23 + Feeds page (coming soon) 24 + </div> 25 + ); 9 26 }
+84 -73
src/routes/index.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 import { useAtom } from "jotai"; 3 3 import * as React from "react"; 4 - import { useEffect, useLayoutEffect } from "react"; 4 + import { useLayoutEffect, useState } from "react"; 5 5 6 6 import { Header } from "~/components/Header"; 7 7 import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed"; 8 8 import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 9 import { 10 - agentAtom, 11 - authedAtom, 12 10 feedScrollPositionsAtom, 13 11 isAtTopAtom, 12 + quickAuthAtom, 14 13 selectedFeedUriAtom, 15 - store, 16 14 } from "~/utils/atoms"; 17 15 //import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 18 16 import { ··· 107 105 } = useAuth(); 108 106 const authed = !!agent?.did; 109 107 110 - useEffect(() => { 111 - if (agent?.did) { 112 - store.set(authedAtom, true); 113 - } else { 114 - store.set(authedAtom, false); 115 - } 116 - }, [status, agent, authed]); 117 - useEffect(() => { 118 - if (agent) { 119 - // eslint-disable-next-line @typescript-eslint/ban-ts-comment 120 - // @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent 121 - store.set(agentAtom, agent); 122 - } else { 123 - store.set(agentAtom, null); 124 - } 125 - }, [status, agent, authed]); 108 + // i dont remember why this is even here 109 + // useEffect(() => { 110 + // if (agent?.did) { 111 + // store.set(authedAtom, true); 112 + // } else { 113 + // store.set(authedAtom, false); 114 + // } 115 + // }, [status, agent, authed]); 116 + // useEffect(() => { 117 + // if (agent) { 118 + // // eslint-disable-next-line @typescript-eslint/ban-ts-comment 119 + // // @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent 120 + // store.set(agentAtom, agent); 121 + // } else { 122 + // store.set(agentAtom, null); 123 + // } 124 + // }, [status, agent, authed]); 126 125 127 126 //const { get, set } = usePersistentStore(); 128 127 // const [feed, setFeed] = React.useState<any[]>([]); ··· 162 161 163 162 // const savedFeeds = savedFeedsPref?.items || []; 164 163 165 - const identityresultmaybe = useQueryIdentity(agent?.did); 164 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 165 + const isAuthRestoring = quickAuth ? status === "loading" : false; 166 + 167 + const identityresultmaybe = useQueryIdentity(!isAuthRestoring ? agent?.did : undefined); 166 168 const identity = identityresultmaybe?.data; 167 169 168 170 const prefsresultmaybe = useQueryPreferences({ 169 - agent: agent ?? undefined, 170 - pdsUrl: identity?.pds, 171 + agent: !isAuthRestoring ? (agent ?? undefined) : undefined, 172 + pdsUrl: !isAuthRestoring ? (identity?.pds) : undefined, 171 173 }); 172 174 const prefs = prefsresultmaybe?.data; 173 175 ··· 178 180 return savedFeedsPref?.items || []; 179 181 }, [prefs]); 180 182 181 - const [persistentSelectedFeed, setPersistentSelectedFeed] = 182 - useAtom(selectedFeedUriAtom); // React.useState<string | null>(null); 183 - const [unauthedSelectedFeed, setUnauthedSelectedFeed] = React.useState( 184 - persistentSelectedFeed 185 - ); // React.useState<string | null>(null); 183 + const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom); 184 + const [unauthedSelectedFeed, setUnauthedSelectedFeed] = useState(persistentSelectedFeed); 186 185 const selectedFeed = agent?.did 187 186 ? persistentSelectedFeed 188 187 : unauthedSelectedFeed; ··· 306 305 }, [scrollPositions]); 307 306 308 307 useLayoutEffect(() => { 308 + if (isAuthRestoring) return; 309 309 const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0; 310 310 311 311 window.scrollTo({ top: savedPosition, behavior: "instant" }); 312 312 // eslint-disable-next-line react-hooks/exhaustive-deps 313 - }, [selectedFeed]); 313 + }, [selectedFeed, isAuthRestoring]); 314 314 315 315 useLayoutEffect(() => { 316 - if (!selectedFeed) return; 316 + if (!selectedFeed || isAuthRestoring) return; 317 317 318 318 const handleScroll = () => { 319 319 scrollPositionsRef.current = { ··· 328 328 329 329 setScrollPositions(scrollPositionsRef.current); 330 330 }; 331 - }, [selectedFeed, setScrollPositions]); 331 + }, [isAuthRestoring, selectedFeed, setScrollPositions]); 332 332 333 - const feedGengetrecordquery = useQueryArbitrary(selectedFeed ?? undefined); 334 - const feedServiceDid = (feedGengetrecordquery?.data?.value as any)?.did; 333 + const feedGengetrecordquery = useQueryArbitrary(!isAuthRestoring ? selectedFeed ?? undefined : undefined); 334 + const feedServiceDid = !isAuthRestoring ? (feedGengetrecordquery?.data?.value as any)?.did as string | undefined : undefined; 335 335 336 336 // const { 337 337 // data: feedData, ··· 347 347 348 348 // const feed = feedData?.feed || []; 349 349 350 - const isReadyForAuthedFeed = 351 - authed && agent && identity?.pds && feedServiceDid; 352 - const isReadyForUnauthedFeed = !authed && selectedFeed; 350 + const isReadyForAuthedFeed = !isAuthRestoring && authed && agent && identity?.pds && feedServiceDid; 351 + const isReadyForUnauthedFeed = !isAuthRestoring && !authed && selectedFeed; 353 352 354 353 355 354 const [isAtTop] = useAtom(isAtTopAtom); ··· 358 357 <div 359 358 className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`} 360 359 > 361 - {savedFeeds.length > 0 ? ( 360 + {!isAuthRestoring && savedFeeds.length > 0 ? ( 362 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`}> 363 - {savedFeeds.map((item: any, idx: number) => { 364 - const label = item.value.split("/").pop() || item.value; 365 - const isActive = selectedFeed === item.value; 366 - return ( 367 - <button 368 - key={item.value || idx} 369 - className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${ 370 - isActive 371 - ? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600" 372 - : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800" 373 - // ? "bg-gray-500 text-white" 374 - // : item.pinned 375 - // ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200" 376 - // : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200" 377 - }`} 378 - onClick={() => setSelectedFeed(item.value)} 379 - title={item.value} 380 - > 381 - {label} 382 - {item.pinned && ( 383 - <span 384 - className={`ml-1 text-xs ${ 385 - isActive 386 - ? "text-gray-900 dark:text-gray-100" 387 - : "text-gray-600 dark:text-gray-400" 388 - }`} 389 - > 390 - โ˜… 391 - </span> 392 - )} 393 - </button> 394 - ); 395 - })} 362 + {savedFeeds.map((item: any, idx: number) => {return <FeedTabOnTop key={item} item={item} idx={idx} />})} 396 363 </div> 397 364 ) : ( 398 365 // <span className="text-xl font-bold ml-2">Home</span> ··· 410 377 /> 411 378 ))} */} 412 379 413 - {authed && (!identity?.pds || !feedServiceDid) && ( 380 + {isAuthRestoring || authed && (!identity?.pds || !feedServiceDid) && ( 414 381 <div className="p-4 text-center text-gray-500"> 415 382 Preparing your feed... 416 383 </div> 417 384 )} 418 385 419 - {isReadyForAuthedFeed || isReadyForUnauthedFeed ? ( 386 + {!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? ( 420 387 <InfiniteCustomFeed 388 + key={selectedFeed!} 421 389 feedUri={selectedFeed!} 422 390 pdsUrl={identity?.pds} 423 391 feedServiceDid={feedServiceDid} 424 392 /> 425 393 ) : ( 426 394 <div className="p-4 text-center text-gray-500"> 427 - Select a feed to get started. 395 + Loading....... 428 396 </div> 429 397 )} 430 398 {/* {false && restoringScrollPosition && ( ··· 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({
+269
src/routes/moderation.tsx
··· 1 + import * as ATPAPI from "@atproto/api"; 2 + import { 3 + isAdultContentPref, 4 + isBskyAppStatePref, 5 + isContentLabelPref, 6 + isFeedViewPref, 7 + isLabelersPref, 8 + isMutedWordsPref, 9 + isSavedFeedsPref, 10 + } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 11 + import { createFileRoute } from "@tanstack/react-router"; 12 + import { useAtom } from "jotai"; 13 + import { Switch } from "radix-ui"; 14 + 15 + import { Header } from "~/components/Header"; 16 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 17 + import { quickAuthAtom } from "~/utils/atoms"; 18 + import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery"; 19 + 20 + import { renderSnack } from "./__root"; 21 + import { NotificationItem } from "./notifications"; 22 + import { SettingHeading } from "./settings"; 23 + 24 + export const Route = createFileRoute("/moderation")({ 25 + component: RouteComponent, 26 + }); 27 + 28 + function RouteComponent() { 29 + const { agent } = useAuth(); 30 + 31 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 32 + const isAuthRestoring = quickAuth ? status === "loading" : false; 33 + 34 + const identityresultmaybe = useQueryIdentity( 35 + !isAuthRestoring ? agent?.did : undefined 36 + ); 37 + const identity = identityresultmaybe?.data; 38 + 39 + const prefsresultmaybe = useQueryPreferences({ 40 + agent: !isAuthRestoring ? (agent ?? undefined) : undefined, 41 + pdsUrl: !isAuthRestoring ? identity?.pds : undefined, 42 + }); 43 + const rawprefs = prefsresultmaybe?.data?.preferences as 44 + | ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"] 45 + | undefined; 46 + 47 + //console.log(JSON.stringify(prefs, null, 2)) 48 + 49 + const parsedPref = parsePreferences(rawprefs); 50 + 51 + return ( 52 + <div> 53 + <Header 54 + title={`Moderation`} 55 + backButtonCallback={() => { 56 + if (window.history.length > 1) { 57 + window.history.back(); 58 + } else { 59 + window.location.assign("/"); 60 + } 61 + }} 62 + bottomBorderDisabled={true} 63 + /> 64 + {/* <SettingHeading title="Moderation Tools" /> 65 + <p> 66 + todo: add all these: 67 + <br /> 68 + - Interaction settings 69 + <br /> 70 + - Muted words & tags 71 + <br /> 72 + - Moderation lists 73 + <br /> 74 + - Muted accounts 75 + <br /> 76 + - Blocked accounts 77 + <br /> 78 + - Verification settings 79 + <br /> 80 + </p> */} 81 + <SettingHeading title="Content Filters" /> 82 + <div> 83 + <div className="flex items-center gap-4 px-4 py-2 border-b"> 84 + <label 85 + htmlFor={`switch-${"hardcoded"}`} 86 + className="flex flex-row flex-1" 87 + > 88 + <div className="flex flex-col"> 89 + <span className="text-md">{"Adult Content"}</span> 90 + <span className="text-sm text-gray-500 dark:text-gray-400"> 91 + {"Enable adult content"} 92 + </span> 93 + </div> 94 + </label> 95 + 96 + <Switch.Root 97 + id={`switch-${"hardcoded"}`} 98 + checked={parsedPref?.adultContentEnabled} 99 + onCheckedChange={(v) => { 100 + renderSnack({ 101 + title: "Sorry... Modifying preferences is not implemented yet", 102 + description: "You can use another app to change preferences", 103 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 104 + }); 105 + }} 106 + className="m3switch root" 107 + > 108 + <Switch.Thumb className="m3switch thumb " /> 109 + </Switch.Root> 110 + </div> 111 + <div className=""> 112 + {Object.entries(parsedPref?.contentLabelPrefs ?? {}).map( 113 + ([label, visibility]) => ( 114 + <div 115 + key={label} 116 + className="flex justify-between border-b py-2 px-4" 117 + > 118 + <label 119 + htmlFor={`switch-${"hardcoded"}`} 120 + className="flex flex-row flex-1" 121 + > 122 + <div className="flex flex-col"> 123 + <span className="text-md">{label}</span> 124 + <span className="text-sm text-gray-500 dark:text-gray-400"> 125 + {"uknown labeler"} 126 + </span> 127 + </div> 128 + </label> 129 + {/* <span className="text-md text-gray-500 dark:text-gray-400"> 130 + {visibility} 131 + </span> */} 132 + <TripleToggle 133 + value={visibility as "ignore" | "warn" | "hide"} 134 + /> 135 + </div> 136 + ) 137 + )} 138 + </div> 139 + </div> 140 + <SettingHeading title="Advanced" /> 141 + {parsedPref?.labelers.map((labeler) => { 142 + return ( 143 + <NotificationItem 144 + key={labeler} 145 + notification={labeler} 146 + labeler={true} 147 + /> 148 + ); 149 + })} 150 + </div> 151 + ); 152 + } 153 + 154 + export function TripleToggle({ 155 + value, 156 + onChange, 157 + }: { 158 + value: "ignore" | "warn" | "hide"; 159 + onChange?: (newValue: "ignore" | "warn" | "hide") => void; 160 + }) { 161 + const options: Array<"ignore" | "warn" | "hide"> = ["ignore", "warn", "hide"]; 162 + return ( 163 + <div className="flex rounded-full bg-gray-200 dark:bg-gray-800 p-1 text-sm"> 164 + {options.map((opt) => { 165 + const isActive = opt === value; 166 + return ( 167 + <button 168 + key={opt} 169 + onClick={() => { 170 + renderSnack({ 171 + title: "Sorry... Modifying preferences is not implemented yet", 172 + description: "You can use another app to change preferences", 173 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 174 + }); 175 + onChange?.(opt); 176 + }} 177 + className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${ 178 + isActive 179 + ? "bg-gray-400 dark:bg-gray-600 text-white" 180 + : "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700" 181 + }`} 182 + > 183 + {" "} 184 + {opt.charAt(0).toUpperCase() + opt.slice(1)} 185 + </button> 186 + ); 187 + })} 188 + </div> 189 + ); 190 + } 191 + 192 + type PrefItem = 193 + ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"][number]; 194 + 195 + export interface NormalizedPreferences { 196 + contentLabelPrefs: Record<string, string>; 197 + mutedWords: string[]; 198 + feedViewPrefs: Record<string, any>; 199 + labelers: string[]; 200 + adultContentEnabled: boolean; 201 + savedFeeds: { 202 + pinned: string[]; 203 + saved: string[]; 204 + }; 205 + nuxs: string[]; 206 + } 207 + 208 + export function parsePreferences( 209 + prefs?: PrefItem[] 210 + ): NormalizedPreferences | undefined { 211 + if (!prefs) return undefined; 212 + const normalized: NormalizedPreferences = { 213 + contentLabelPrefs: {}, 214 + mutedWords: [], 215 + feedViewPrefs: {}, 216 + labelers: [], 217 + adultContentEnabled: false, 218 + savedFeeds: { pinned: [], saved: [] }, 219 + nuxs: [], 220 + }; 221 + 222 + for (const pref of prefs) { 223 + switch (pref.$type) { 224 + case "app.bsky.actor.defs#contentLabelPref": 225 + if (!isContentLabelPref(pref)) break; 226 + normalized.contentLabelPrefs[pref.label] = pref.visibility; 227 + break; 228 + 229 + case "app.bsky.actor.defs#mutedWordsPref": 230 + if (!isMutedWordsPref(pref)) break; 231 + for (const item of pref.items ?? []) { 232 + normalized.mutedWords.push(item.value); 233 + } 234 + break; 235 + 236 + case "app.bsky.actor.defs#feedViewPref": 237 + if (!isFeedViewPref(pref)) break; 238 + normalized.feedViewPrefs[pref.feed] = pref; 239 + break; 240 + 241 + case "app.bsky.actor.defs#labelersPref": 242 + if (!isLabelersPref(pref)) break; 243 + normalized.labelers.push(...(pref.labelers?.map((l) => l.did) ?? [])); 244 + break; 245 + 246 + case "app.bsky.actor.defs#adultContentPref": 247 + if (!isAdultContentPref(pref)) break; 248 + normalized.adultContentEnabled = !!pref.enabled; 249 + break; 250 + 251 + case "app.bsky.actor.defs#savedFeedsPref": 252 + if (!isSavedFeedsPref(pref)) break; 253 + normalized.savedFeeds.pinned.push(...(pref.pinned ?? [])); 254 + normalized.savedFeeds.saved.push(...(pref.saved ?? [])); 255 + break; 256 + 257 + case "app.bsky.actor.defs#bskyAppStatePref": 258 + if (!isBskyAppStatePref(pref)) break; 259 + normalized.nuxs.push(...(pref.nuxs?.map((n) => n.id) ?? [])); 260 + break; 261 + 262 + default: 263 + // unknown pref type โ€” just ignore for now 264 + break; 265 + } 266 + } 267 + 268 + return normalized; 269 + }
+644 -152
src/routes/notifications.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 2 - import React, { useEffect, useRef,useState } from "react"; 1 + import { AtUri } from "@atproto/api"; 2 + import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 5 + import * as React from "react"; 3 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"; 4 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 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"; 5 33 6 - const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 34 + import { FollowButton, Mutual } from "./profile.$did"; 35 + 36 + export function NotificationsComponent() { 37 + return ( 38 + <div className=""> 39 + <Header 40 + title={`Notifications`} 41 + backButtonCallback={() => { 42 + if (window.history.length > 1) { 43 + window.history.back(); 44 + } else { 45 + window.location.assign("/"); 46 + } 47 + }} 48 + bottomBorderDisabled={true} 49 + /> 50 + <NotificationsTabs /> 51 + </div> 52 + ); 53 + } 7 54 8 55 export const Route = createFileRoute("/notifications")({ 9 56 component: NotificationsComponent, 10 57 }); 11 58 12 - function NotificationsComponent() { 13 - // /*mass comment*/ console.log("NotificationsComponent render"); 14 - const { agent, status } = useAuth(); 15 - const authed = !!agent?.did; 16 - const authLoading = status === "loading"; 17 - const [did, setDid] = useState<string | null>(null); 18 - const [resolving, setResolving] = useState(false); 19 - const [error, setError] = useState<string | null>(null); 20 - const [responses, setResponses] = useState<any[]>([null, null, null]); 21 - const [loading, setLoading] = useState(false); 22 - const inputRef = useRef<HTMLInputElement>(null); 59 + export default function NotificationsTabs() { 60 + const [bitesEnabled] = useAtom(enableBitesAtom); 61 + return ( 62 + <ReusableTabRoute 63 + route={`Notifications`} 64 + tabs={{ 65 + Mentions: <MentionsTab />, 66 + Follows: <FollowsTab />, 67 + "Post Interactions": <PostInteractionsTab />, 68 + ...bitesEnabled ? { 69 + Bites: <BitesTab />, 70 + } : {} 71 + }} 72 + /> 73 + ); 74 + } 75 + 76 + function MentionsTab() { 77 + const { agent } = useAuth(); 78 + const [constellationurl] = useAtom(constellationURLAtom); 79 + const infinitequeryresults = useInfiniteQuery({ 80 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 81 + { 82 + constellation: constellationurl, 83 + method: "/links", 84 + target: agent?.did, 85 + collection: "app.bsky.feed.post", 86 + path: ".facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did", 87 + } 88 + ), 89 + enabled: !!agent?.did, 90 + }); 91 + 92 + const { 93 + data: infiniteMentionsData, 94 + fetchNextPage, 95 + hasNextPage, 96 + isFetchingNextPage, 97 + isLoading, 98 + isError, 99 + error, 100 + } = infinitequeryresults; 101 + 102 + const mentionsAturis = React.useMemo(() => { 103 + // Get all replies from the standard infinite query 104 + return ( 105 + infiniteMentionsData?.pages.flatMap( 106 + (page) => 107 + page?.linking_records.map( 108 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 109 + ) ?? [] 110 + ) ?? [] 111 + ); 112 + }, [infiniteMentionsData]); 23 113 24 - useEffect(() => { 25 - if (authLoading) return; 26 - if (authed && agent && agent.assertDid) { 27 - setDid(agent.assertDid); 28 - } 29 - }, [authed, agent, authLoading]); 114 + useReusableTabScrollRestore("Notifications"); 30 115 31 - async function handleSubmit() { 32 - // /*mass comment*/ console.log("handleSubmit called"); 33 - setError(null); 34 - setResponses([null, null, null]); 35 - const value = inputRef.current?.value?.trim() || ""; 36 - if (!value) return; 37 - if (value.startsWith("did:")) { 38 - setDid(value); 39 - setError(null); 40 - return; 41 - } 42 - setResolving(true); 43 - const cacheKey = `handleDid:${value}`; 44 - const now = Date.now(); 45 - const cached = undefined // await get(cacheKey); 46 - // if ( 47 - // cached && 48 - // cached.value && 49 - // cached.time && 50 - // now - cached.time < HANDLE_DID_CACHE_TIMEOUT 51 - // ) { 52 - // try { 53 - // const data = JSON.parse(cached.value); 54 - // setDid(data.did); 55 - // setResolving(false); 56 - // return; 57 - // } catch {} 58 - // } 59 - try { 60 - const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(value)}`; 61 - const res = await fetch(url); 62 - if (!res.ok) throw new Error("Failed to resolve handle"); 63 - const data = await res.json(); 64 - //set(cacheKey, JSON.stringify(data)); 65 - setDid(data.did); 66 - } catch (e: any) { 67 - setError("Failed to resolve handle: " + (e?.message || e)); 68 - } finally { 69 - setResolving(false); 70 - } 71 - } 116 + if (isLoading) return <LoadingState text="Loading mentions..." />; 117 + if (isError) return <ErrorState error={error} />; 118 + 119 + if (!mentionsAturis?.length) return <EmptyState text="No mentions yet." />; 120 + 121 + return ( 122 + <> 123 + {mentionsAturis.map((m) => ( 124 + <UniversalPostRendererATURILoader key={m} atUri={m} /> 125 + ))} 72 126 73 - useEffect(() => { 74 - if (!did) return; 75 - setLoading(true); 76 - setError(null); 77 - const urls = [ 78 - `https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`, 79 - `https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`, 80 - `https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`, 81 - ]; 82 - let ignore = false; 83 - Promise.all( 84 - urls.map(async (url) => { 85 - try { 86 - const r = await fetch(url); 87 - if (!r.ok) throw new Error("Failed to fetch"); 88 - const text = await r.text(); 89 - if (!text) return null; 90 - try { 91 - return JSON.parse(text); 92 - } catch { 93 - return null; 127 + {hasNextPage && ( 128 + <button 129 + onClick={() => fetchNextPage()} 130 + disabled={isFetchingNextPage} 131 + 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" 132 + > 133 + {isFetchingNextPage ? "Loading..." : "Load More"} 134 + </button> 135 + )} 136 + </> 137 + ); 138 + } 139 + 140 + export function FollowsTab({did}:{did?:string}) { 141 + const { agent } = useAuth(); 142 + const userdidunsafe = did ?? agent?.did; 143 + const { data: identity} = useQueryIdentity(userdidunsafe); 144 + const userdid = identity?.did; 145 + 146 + const [constellationurl] = useAtom(constellationURLAtom); 147 + const infinitequeryresults = useInfiniteQuery({ 148 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 149 + { 150 + constellation: constellationurl, 151 + method: "/links", 152 + target: userdid, 153 + collection: "app.bsky.graph.follow", 154 + path: ".subject", 155 + } 156 + ), 157 + enabled: !!userdid, 158 + }); 159 + 160 + const { 161 + data: infiniteFollowsData, 162 + fetchNextPage, 163 + hasNextPage, 164 + isFetchingNextPage, 165 + isLoading, 166 + isError, 167 + error, 168 + } = infinitequeryresults; 169 + 170 + const followsAturis = React.useMemo(() => { 171 + // Get all replies from the standard infinite query 172 + return ( 173 + infiniteFollowsData?.pages.flatMap( 174 + (page) => 175 + page?.linking_records.map( 176 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 177 + ) ?? [] 178 + ) ?? [] 179 + ); 180 + }, [infiniteFollowsData]); 181 + 182 + useReusableTabScrollRestore("Notifications"); 183 + 184 + if (isLoading) return <LoadingState text="Loading follows..." />; 185 + if (isError) return <ErrorState error={error} />; 186 + 187 + if (!followsAturis?.length) return <EmptyState text="No follows yet." />; 188 + 189 + return ( 190 + <> 191 + {followsAturis.map((m) => ( 192 + <NotificationItem key={m} notification={m} /> 193 + ))} 194 + 195 + {hasNextPage && ( 196 + <button 197 + onClick={() => fetchNextPage()} 198 + disabled={isFetchingNextPage} 199 + 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" 200 + > 201 + {isFetchingNextPage ? "Loading..." : "Load More"} 202 + </button> 203 + )} 204 + </> 205 + ); 206 + } 207 + 208 + 209 + export function BitesTab({did}:{did?:string}) { 210 + const { agent } = useAuth(); 211 + const userdidunsafe = did ?? agent?.did; 212 + const { data: identity} = useQueryIdentity(userdidunsafe); 213 + const userdid = identity?.did; 214 + 215 + const [constellationurl] = useAtom(constellationURLAtom); 216 + const infinitequeryresults = useInfiniteQuery({ 217 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 218 + { 219 + constellation: constellationurl, 220 + method: "/links", 221 + target: "at://"+userdid, 222 + collection: "net.wafrn.feed.bite", 223 + path: ".subject", 224 + staleMult: 0 // safe fun 225 + } 226 + ), 227 + enabled: !!userdid, 228 + }); 229 + 230 + const { 231 + data: infiniteFollowsData, 232 + fetchNextPage, 233 + hasNextPage, 234 + isFetchingNextPage, 235 + isLoading, 236 + isError, 237 + error, 238 + } = infinitequeryresults; 239 + 240 + const followsAturis = React.useMemo(() => { 241 + // Get all replies from the standard infinite query 242 + return ( 243 + infiniteFollowsData?.pages.flatMap( 244 + (page) => 245 + page?.linking_records.map( 246 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 247 + ) ?? [] 248 + ) ?? [] 249 + ); 250 + }, [infiniteFollowsData]); 251 + 252 + useReusableTabScrollRestore("Notifications"); 253 + 254 + if (isLoading) return <LoadingState text="Loading bites..." />; 255 + if (isError) return <ErrorState error={error} />; 256 + 257 + if (!followsAturis?.length) return <EmptyState text="No bites yet." />; 258 + 259 + return ( 260 + <> 261 + {followsAturis.map((m) => ( 262 + <NotificationItem key={m} notification={m} /> 263 + ))} 264 + 265 + {hasNextPage && ( 266 + <button 267 + onClick={() => fetchNextPage()} 268 + disabled={isFetchingNextPage} 269 + 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" 270 + > 271 + {isFetchingNextPage ? "Loading..." : "Load More"} 272 + </button> 273 + )} 274 + </> 275 + ); 276 + } 277 + 278 + function PostInteractionsTab() { 279 + const { agent } = useAuth(); 280 + const { data: identity } = useQueryIdentity(agent?.did); 281 + const queryClient = useQueryClient(); 282 + const { 283 + data: postsData, 284 + fetchNextPage, 285 + hasNextPage, 286 + isFetchingNextPage, 287 + isLoading: arePostsLoading, 288 + } = useInfiniteQueryAuthorFeed(agent?.did, identity?.pds); 289 + 290 + React.useEffect(() => { 291 + if (postsData) { 292 + postsData.pages.forEach((page) => { 293 + page.records.forEach((record) => { 294 + if (!queryClient.getQueryData(["post", record.uri])) { 295 + queryClient.setQueryData(["post", record.uri], record); 94 296 } 95 - } catch (e: any) { 96 - return { error: e?.message || String(e) }; 97 - } 98 - }) 99 - ) 100 - .then((results) => { 101 - if (!ignore) setResponses(results); 102 - }) 103 - .catch((e) => { 104 - if (!ignore) 105 - setError("Failed to fetch notifications: " + (e?.message || e)); 106 - }) 107 - .finally(() => { 108 - if (!ignore) setLoading(false); 297 + }); 109 298 }); 110 - return () => { 111 - ignore = true; 112 - }; 113 - }, [did]); 299 + } 300 + }, [postsData, queryClient]); 301 + 302 + const posts = React.useMemo( 303 + () => postsData?.pages.flatMap((page) => page.records) ?? [], 304 + [postsData] 305 + ); 306 + 307 + useReusableTabScrollRestore("Notifications"); 308 + 309 + const [filters] = useAtom(postInteractionsFiltersAtom); 310 + const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts); 114 311 115 312 return ( 116 - <div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800"> 117 - <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"> 118 - <span className="text-xl font-bold ml-2">Notifications</span> 119 - {!authed && ( 120 - <div className="flex items-center gap-2"> 121 - <input 122 - type="text" 123 - placeholder="Enter handle or DID" 124 - ref={inputRef} 125 - 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" 126 - style={{ minWidth: 220 }} 127 - disabled={resolving} 128 - /> 129 - <button 130 - type="button" 131 - className="px-3 py-1 rounded bg-blue-600 text-white font-semibold disabled:opacity-50" 132 - disabled={resolving} 133 - onClick={handleSubmit} 134 - > 135 - {resolving ? "Resolving..." : "Submit"} 136 - </button> 137 - </div> 138 - )} 313 + <> 314 + <PostInteractionsFilterChipBar /> 315 + {!empty && posts.map((m) => ( 316 + <PostInteractionsItem key={m.uri} uri={m.uri} /> 317 + ))} 318 + 319 + {hasNextPage && ( 320 + <button 321 + onClick={() => fetchNextPage()} 322 + disabled={isFetchingNextPage} 323 + 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" 324 + > 325 + {isFetchingNextPage ? "Loading..." : "Load More"} 326 + </button> 327 + )} 328 + </> 329 + ); 330 + } 331 + 332 + function PostInteractionsFilterChipBar() { 333 + const [filters, setFilters] = useAtom(postInteractionsFiltersAtom); 334 + // const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts); 335 + 336 + // useEffect(() => { 337 + // if (empty) { 338 + // setFilters((prev) => ({ 339 + // ...prev, 340 + // likes: true, 341 + // })); 342 + // } 343 + // }, [ 344 + // empty, 345 + // setFilters, 346 + // ]); 347 + 348 + const toggle = (key: keyof typeof filters) => { 349 + setFilters((prev) => ({ 350 + ...prev, 351 + [key]: !prev[key], 352 + })); 353 + }; 354 + 355 + return ( 356 + <div className="flex flex-row flex-wrap gap-2 px-4 pt-4"> 357 + <Chip 358 + state={filters.likes} 359 + text="Likes" 360 + onClick={() => toggle("likes")} 361 + /> 362 + <Chip 363 + state={filters.reposts} 364 + text="Reposts" 365 + onClick={() => toggle("reposts")} 366 + /> 367 + <Chip 368 + state={filters.replies} 369 + text="Replies" 370 + onClick={() => toggle("replies")} 371 + /> 372 + <Chip 373 + state={filters.quotes} 374 + text="Quotes" 375 + onClick={() => toggle("quotes")} 376 + /> 377 + <Chip 378 + state={filters.showAll} 379 + text="Show All Metrics" 380 + onClick={() => toggle("showAll")} 381 + /> 382 + </div> 383 + ); 384 + } 385 + 386 + export function Chip({ 387 + state, 388 + text, 389 + onClick, 390 + }: { 391 + state: boolean; 392 + text: string; 393 + onClick: React.MouseEventHandler<HTMLButtonElement>; 394 + }) { 395 + return ( 396 + <button 397 + onClick={onClick} 398 + className={`relative inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-all 399 + ${ 400 + state 401 + ? "bg-primary/20 text-primary bg-gray-200 dark:bg-gray-800 border border-transparent" 402 + : "bg-surface-container-low text-on-surface-variant border border-outline" 403 + } 404 + hover:bg-primary/30 active:scale-[0.97] 405 + dark:border-outline-variant 406 + `} 407 + > 408 + {state && ( 409 + <IconMdiCheck 410 + className="mr-1.5 inline-block w-4 h-4 rounded-full bg-primary" 411 + aria-hidden 412 + /> 413 + )} 414 + {text} 415 + </button> 416 + ); 417 + } 418 + 419 + function PostInteractionsItem({ uri }: { uri: string }) { 420 + const [filters] = useAtom(postInteractionsFiltersAtom); 421 + const { data: links } = useQueryConstellation({ 422 + method: "/links/all", 423 + target: uri, 424 + }); 425 + 426 + const likes = 427 + links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0; 428 + const replies = 429 + links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0; 430 + const reposts = 431 + links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0; 432 + const quotes1 = 433 + links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0; 434 + const quotes2 = 435 + links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"] 436 + ?.records || 0; 437 + const quotes = quotes1 + quotes2; 438 + 439 + const all = likes + replies + reposts + quotes; 440 + 441 + //const failLikes = filters.likes && likes < 1; 442 + //const failReposts = filters.reposts && reposts < 1; 443 + //const failReplies = filters.replies && replies < 1; 444 + //const failQuotes = filters.quotes && quotes < 1; 445 + 446 + const showLikes = filters.showAll || filters.likes 447 + const showReposts = filters.showAll || filters.reposts 448 + const showReplies = filters.showAll || filters.replies 449 + const showQuotes = filters.showAll || filters.quotes 450 + 451 + //const showNone = !showLikes && !showReposts && !showReplies && !showQuotes; 452 + 453 + //const fail = failLikes || failReposts || failReplies || failQuotes || showNone; 454 + 455 + const matchesLikes = filters.likes && likes > 0; 456 + const matchesReposts = filters.reposts && reposts > 0; 457 + const matchesReplies = filters.replies && replies > 0; 458 + const matchesQuotes = filters.quotes && quotes > 0; 459 + 460 + const matchesAnything = 461 + // filters.showAll || 462 + matchesLikes || 463 + matchesReposts || 464 + matchesReplies || 465 + matchesQuotes; 466 + 467 + if (!matchesAnything) return null; 468 + 469 + //if (fail) return; 470 + 471 + return ( 472 + <div className="flex flex-col"> 473 + {/* <span>fail likes {failLikes ? "true" : "false"}</span> 474 + <span>fail repost {failReposts ? "true" : "false"}</span> 475 + <span>fail reply {failReplies ? "true" : "false"}</span> 476 + <span>fail qupte {failQuotes ? "true" : "false"}</span> */} 477 + <div className="border rounded-xl mx-4 mt-4 overflow-hidden"> 478 + <UniversalPostRendererATURILoader 479 + isQuote 480 + key={uri} 481 + atUri={uri} 482 + nopics={true} 483 + concise={true} 484 + /> 485 + <div className="flex flex-col divide-x"> 486 + {showLikes &&(<InteractionsButton 487 + type={"like"} 488 + uri={uri} 489 + count={likes} 490 + />)} 491 + {showReposts && (<InteractionsButton 492 + type={"repost"} 493 + uri={uri} 494 + count={reposts} 495 + />)} 496 + {showReplies && (<InteractionsButton 497 + type={"reply"} 498 + uri={uri} 499 + count={replies} 500 + />)} 501 + {showQuotes && (<InteractionsButton 502 + type={"quote"} 503 + uri={uri} 504 + count={quotes} 505 + />)} 506 + {!all && ( 507 + <div className="text-center text-gray-500 dark:text-gray-400 pb-3 pt-2 border-t"> 508 + No interactions yet. 509 + </div> 510 + )} 511 + </div> 139 512 </div> 140 - {error && <div className="p-4 text-red-500">{error}</div>} 141 - {loading && ( 142 - <div className="p-4 text-gray-500">Loading notifications...</div> 513 + </div> 514 + ); 515 + } 516 + 517 + function InteractionsButton({ 518 + type, 519 + uri, 520 + count, 521 + }: { 522 + type: "reply" | "repost" | "like" | "quote"; 523 + uri: string; 524 + count: number; 525 + }) { 526 + if (!count) return <></>; 527 + const aturi = new AtUri(uri); 528 + return ( 529 + <Link 530 + to={ 531 + `/profile/$did/post/$rkey` + 532 + (type === "like" 533 + ? "/liked-by" 534 + : type === "repost" 535 + ? "/reposted-by" 536 + : type === "quote" 537 + ? "/quotes" 538 + : "") 539 + } 540 + params={{ 541 + did: aturi.host, 542 + rkey: aturi.rkey, 543 + }} 544 + 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" 545 + > 546 + {type === "like" ? ( 547 + <MdiCardsHeartOutline height={22} width={22} /> 548 + ) : type === "repost" ? ( 549 + <MdiRepeat height={22} width={22} /> 550 + ) : type === "reply" ? ( 551 + <MdiCommentOutline height={22} width={22} /> 552 + ) : type === "quote" ? ( 553 + <IconMdiMessageReplyTextOutline 554 + height={22} 555 + width={22} 556 + className=" text-gray-400" 557 + /> 558 + ) : ( 559 + <></> 560 + )} 561 + {type === "like" 562 + ? "likes" 563 + : type === "reply" 564 + ? "replies" 565 + : type === "quote" 566 + ? "quotes" 567 + : type === "repost" 568 + ? "reposts" 569 + : ""} 570 + <div className="flex-1" /> {count} 571 + </Link> 572 + ); 573 + } 574 + 575 + export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) { 576 + const aturi = new AtUri(notification); 577 + const bite = aturi.collection === "net.wafrn.feed.bite"; 578 + const navigate = useNavigate(); 579 + const { data: identity } = useQueryIdentity(aturi.host); 580 + const resolvedDid = identity?.did; 581 + const profileUri = resolvedDid 582 + ? `at://${resolvedDid}/app.bsky.actor.profile/self` 583 + : undefined; 584 + const { data: profileRecord } = useQueryProfile(profileUri); 585 + const profile = profileRecord?.value; 586 + 587 + const [imgcdn] = useAtom(imgCDNAtom); 588 + 589 + function getAvatarUrl(p: typeof profile) { 590 + const link = p?.avatar?.ref?.["$link"]; 591 + if (!link || !resolvedDid) return null; 592 + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 593 + } 594 + 595 + const avatar = getAvatarUrl(profile); 596 + 597 + return ( 598 + <div 599 + className="flex items-center p-4 cursor-pointer gap-3 justify-around border-b flex-row" 600 + onClick={() => 601 + aturi && 602 + navigate({ 603 + to: "/profile/$did", 604 + params: { did: aturi.host }, 605 + }) 606 + } 607 + > 608 + {/* <div> 609 + {aturi.collection === "app.bsky.graph.follow" ? ( 610 + <IconMdiAccountPlus /> 611 + ) : aturi.collection === "app.bsky.feed.like" ? ( 612 + <MdiCardsHeart /> 613 + ) : ( 614 + <></> 615 + )} 616 + </div> */} 617 + {profile ? ( 618 + <img 619 + src={avatar || defaultpfp} 620 + alt={identity?.handle} 621 + className={`w-10 h-10 ${labeler ? "rounded-md" : "rounded-full"}`} 622 + /> 623 + ) : ( 624 + <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" /> 143 625 )} 144 - {!loading && 145 - !error && 146 - responses.map((resp, i) => ( 147 - <div key={i} className="p-4"> 148 - <div className="font-bold mb-2">Query {i + 1}</div> 149 - {!resp || 150 - (typeof resp === "object" && Object.keys(resp).length === 0) || 151 - (Array.isArray(resp) && resp.length === 0) ? ( 152 - <div className="text-gray-500">No notifications found.</div> 153 - ) : ( 154 - <pre 155 - style={{ 156 - background: "#222", 157 - color: "#eee", 158 - borderRadius: 8, 159 - padding: 12, 160 - fontSize: 13, 161 - overflowX: "auto", 162 - }} 163 - > 164 - {JSON.stringify(resp, null, 2)} 165 - </pre> 166 - )} 167 - </div> 168 - ))} 169 - {/* <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> */} 626 + <div className="flex flex-col min-w-0"> 627 + <div className="flex flex-row gap-2 overflow-hidden text-ellipsis whitespace-nowrap min-w-0"> 628 + <span className="font-medium text-gray-900 dark:text-gray-100 truncate"> 629 + {profile?.displayName || identity?.handle || "Someone"} 630 + </span> 631 + <span className="text-gray-700 dark:text-gray-400 truncate"> 632 + @{identity?.handle} 633 + </span> 634 + </div> 635 + <div className="flex flex-row gap-2"> 636 + {identity?.did && <Mutual targetdidorhandle={identity?.did} />} 637 + {/* <span className="text-sm text-gray-600 dark:text-gray-400"> 638 + followed you 639 + </span> */} 640 + </div> 641 + </div> 642 + <div className="flex-1" /> 643 + {identity?.did && <FollowButton targetdidorhandle={identity?.did} />} 170 644 </div> 171 645 ); 172 646 } 647 + 648 + export const EmptyState = ({ text }: { text: string }) => ( 649 + <div className="py-10 text-center text-gray-500 dark:text-gray-400"> 650 + {text} 651 + </div> 652 + ); 653 + 654 + export const LoadingState = ({ text }: { text: string }) => ( 655 + <div className="py-10 text-center text-gray-500 dark:text-gray-400 italic"> 656 + {text} 657 + </div> 658 + ); 659 + 660 + export const ErrorState = ({ error }: { error: unknown }) => ( 661 + <div className="py-10 text-center text-red-600 dark:text-red-400"> 662 + Error: {(error as Error)?.message || "Something went wrong."} 663 + </div> 664 + );
+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 + }
+1005 -125
src/routes/profile.$did/index.tsx
··· 1 + import { Agent, RichText } from "@atproto/api"; 2 + import * as ATPAPI from "@atproto/api"; 3 + import { TID } from "@atproto/common-web"; 1 4 import { useQueryClient } from "@tanstack/react-query"; 2 - import { createFileRoute } from "@tanstack/react-router"; 3 - import React from "react"; 5 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 6 + import { useAtom } from "jotai"; 7 + import React, { type ReactNode, useEffect, useState } from "react"; 4 8 9 + import defaultpfp from "~/../public/favicon.png"; 5 10 import { Header } from "~/components/Header"; 6 - import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 11 + import { 12 + ReusableTabRoute, 13 + useReusableTabScrollRestore, 14 + } from "~/components/ReusableTabRoute"; 15 + import { 16 + renderTextWithFacets, 17 + UniversalPostRendererATURILoader, 18 + } from "~/components/UniversalPostRenderer"; 7 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 - import { toggleFollow, useGetFollowState } from "~/utils/followState"; 20 + import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 21 + import { 22 + toggleFollow, 23 + useGetFollowState, 24 + useGetOneToOneState, 25 + } from "~/utils/followState"; 26 + import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue"; 9 27 import { 10 28 useInfiniteQueryAuthorFeed, 29 + useQueryArbitrary, 30 + useQueryConstellation, 31 + useQueryConstellationLinksCountDistinctDids, 11 32 useQueryIdentity, 12 33 useQueryProfile, 13 34 } from "~/utils/useQuery"; 35 + import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx"; 36 + 37 + import { renderSnack } from "../__root"; 38 + import { Chip } from "../notifications"; 14 39 15 40 export const Route = createFileRoute("/profile/$did/")({ 16 41 component: ProfileComponent, ··· 19 44 function ProfileComponent() { 20 45 // booo bad this is not always the did it might be a handle, use identity.did instead 21 46 const { did } = Route.useParams(); 47 + const { agent } = useAuth(); 48 + const navigate = useNavigate(); 22 49 const queryClient = useQueryClient(); 23 - const { agent } = useAuth(); 24 50 const { 25 51 data: identity, 26 52 isLoading: isIdentityLoading, 27 53 error: identityError, 28 54 } = useQueryIdentity(did); 29 55 30 - const followRecords = useGetFollowState({ 31 - target: identity?.did || did, 32 - user: agent?.did, 33 - }); 56 + // i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc) 57 + // so instead we should query the labeler profile 58 + 59 + const { data: labelerProfile } = useQueryArbitrary( 60 + identity?.did 61 + ? `at://${identity?.did}/app.bsky.labeler.service/self` 62 + : undefined 63 + ); 64 + 65 + const isLabeler = !!labelerProfile?.cid; 66 + const labelerRecord = isLabeler 67 + ? (labelerProfile?.value as ATPAPI.AppBskyLabelerService.Record) 68 + : undefined; 34 69 35 70 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 36 71 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; ··· 42 77 const { data: profileRecord } = useQueryProfile(profileUri); 43 78 const profile = profileRecord?.value; 44 79 45 - const { 46 - data: postsData, 47 - fetchNextPage, 48 - hasNextPage, 49 - isFetchingNextPage, 50 - isLoading: arePostsLoading, 51 - } = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl); 52 - 53 - React.useEffect(() => { 54 - if (postsData) { 55 - postsData.pages.forEach((page) => { 56 - page.records.forEach((record) => { 57 - if (!queryClient.getQueryData(["post", record.uri])) { 58 - queryClient.setQueryData(["post", record.uri], record); 59 - } 60 - }); 61 - }); 62 - } 63 - }, [postsData, queryClient]); 64 - 65 - const posts = React.useMemo( 66 - () => postsData?.pages.flatMap((page) => page.records) ?? [], 67 - [postsData] 68 - ); 80 + const [imgcdn] = useAtom(imgCDNAtom); 69 81 70 82 function getAvatarUrl(p: typeof profile) { 71 83 const link = p?.avatar?.ref?.["$link"]; 72 84 if (!link || !resolvedDid) return null; 73 - return `https://cdn.bsky.app/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 85 + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 74 86 } 75 87 function getBannerUrl(p: typeof profile) { 76 88 const link = p?.banner?.ref?.["$link"]; 77 89 if (!link || !resolvedDid) return null; 78 - return `https://cdn.bsky.app/img/banner/plain/${resolvedDid}/${link}@jpeg`; 90 + return `https://${imgcdn}/img/banner/plain/${resolvedDid}/${link}@jpeg`; 79 91 } 80 92 81 93 const displayName = ··· 83 95 const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did; 84 96 const description = profile?.description || ""; 85 97 86 - if (isIdentityLoading) { 87 - return ( 88 - <div className="p-4 text-center text-gray-500">Resolving profile...</div> 89 - ); 90 - } 98 + const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; 91 99 92 - if (identityError) { 93 - return ( 94 - <div className="p-4 text-center text-red-500"> 95 - Error: {identityError.message} 96 - </div> 97 - ); 98 - } 100 + const resultwhateversure = useQueryConstellationLinksCountDistinctDids( 101 + resolvedDid 102 + ? { 103 + method: "/links/count/distinct-dids", 104 + collection: "app.bsky.graph.follow", 105 + target: resolvedDid, 106 + path: ".subject", 107 + } 108 + : undefined 109 + ); 99 110 100 - if (!resolvedDid) { 101 - return ( 102 - <div className="p-4 text-center text-gray-500">Profile not found.</div> 103 - ); 104 - } 111 + const followercount = resultwhateversure?.data?.total; 105 112 106 113 return ( 107 - <> 114 + <div className=""> 108 115 <Header 109 116 title={`Profile`} 110 117 backButtonCallback={() => { ··· 114 121 window.location.assign("/"); 115 122 } 116 123 }} 124 + bottomBorderDisabled={true} 117 125 /> 118 126 {/* <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"> 119 127 <Link ··· 148 156 149 157 {/* Avatar (PFP) */} 150 158 <div className="absolute left-[16px] top-[100px] "> 151 - <img 152 - src={getAvatarUrl(profile) || "/favicon.png"} 153 - alt="avatar" 154 - className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700" 155 - /> 159 + {!getAvatarUrl(profile) && isLabeler ? ( 160 + <div 161 + className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} items-center justify-center flex object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`} 162 + > 163 + <IconMdiShieldOutline className="w-20 h-20" /> 164 + </div> 165 + ) : ( 166 + <img 167 + src={getAvatarUrl(profile) || "/favicon.png"} 168 + alt="avatar" 169 + className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`} 170 + /> 171 + )} 156 172 </div> 157 173 158 174 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> 175 + <BiteButton targetdidorhandle={did} /> 159 176 {/* 160 177 todo: full follow and unfollow backfill (along with partial likes backfill, 161 178 just enough for it to be useful) 162 179 also delay the backfill to be on demand because it would be pretty intense 163 180 also save it persistently 164 181 */} 165 - {identity?.did !== agent?.did ? ( 166 - <> 167 - {!(followRecords?.length && followRecords?.length > 0) ? ( 168 - <button 169 - onClick={() => 170 - toggleFollow({ 171 - agent: agent || undefined, 172 - targetDid: identity?.did, 173 - followRecords: followRecords, 174 - queryClient: queryClient, 175 - }) 176 - } 177 - className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 178 - > 179 - Follow 180 - </button> 181 - ) : ( 182 - <button 183 - onClick={() => 184 - toggleFollow({ 185 - agent: agent || undefined, 186 - targetDid: identity?.did, 187 - followRecords: followRecords, 188 - queryClient: queryClient, 189 - }) 190 - } 191 - className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 192 - > 193 - Unfollow 194 - </button> 195 - )} 196 - </> 197 - ) : ( 198 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 199 - Edit Profile 200 - </button> 201 - )} 202 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 182 + <FollowButton targetdidorhandle={did} /> 183 + <button 184 + className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 185 + onClick={(e) => { 186 + renderSnack({ 187 + title: "Not Implemented Yet", 188 + description: "Sorry...", 189 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 190 + }); 191 + }} 192 + > 203 193 ... {/* todo: icon */} 204 194 </button> 205 195 </div> ··· 207 197 {/* Info Card */} 208 198 <div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100"> 209 199 <div className="font-bold text-2xl">{displayName}</div> 210 - <div className="text-gray-500 dark:text-gray-400 text-base mb-3"> 200 + <div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1"> 201 + <Mutual targetdidorhandle={did} /> 211 202 {handle} 212 203 </div> 204 + <div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2"> 205 + <Link to="/profile/$did/followers" params={{ did: did }}> 206 + {followercount && ( 207 + <span className="mr-1 text-gray-900 dark:text-gray-200 font-medium"> 208 + {followercount} 209 + </span> 210 + )} 211 + Followers 212 + </Link> 213 + - 214 + <Link to="/profile/$did/follows" params={{ did: did }}> 215 + Follows 216 + </Link> 217 + </div> 213 218 {description && ( 214 219 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 215 - {description} 220 + {/* {description} */} 221 + <RichTextRenderer key={did} description={description} /> 216 222 </div> 217 223 )} 218 224 </div> 219 225 </div> 220 226 221 - {/* Posts Section */} 222 - <div className="max-w-2xl mx-auto"> 223 - <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 224 - Posts 227 + {/* this should not be rendered until its ready (the top profile layout is stable) */} 228 + {isReady ? ( 229 + <ReusableTabRoute 230 + route={`Profile` + did} 231 + tabs={{ 232 + ...(isLabeler 233 + ? { 234 + Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />, 235 + } 236 + : {}), 237 + ...{ 238 + Posts: <PostsTab did={did} />, 239 + Reposts: <RepostsTab did={did} />, 240 + Feeds: <FeedsTab did={did} />, 241 + Lists: <ListsTab did={did} />, 242 + }, 243 + ...(identity?.did === agent?.did 244 + ? { Likes: <SelfLikesTab did={did} /> } 245 + : {}), 246 + }} 247 + /> 248 + ) : isIdentityLoading ? ( 249 + <div className="p-4 text-center text-gray-500"> 250 + Resolving profile... 251 + </div> 252 + ) : identityError ? ( 253 + <div className="p-4 text-center text-red-500"> 254 + Error: {identityError.message} 255 + </div> 256 + ) : !resolvedDid ? ( 257 + <div className="p-4 text-center text-gray-500">Profile not found.</div> 258 + ) : ( 259 + <div className="p-4 text-center text-gray-500"> 260 + Loading profile content... 225 261 </div> 226 - <div> 227 - {posts.map((post) => ( 262 + )} 263 + </div> 264 + ); 265 + } 266 + 267 + export type ProfilePostsFilter = { 268 + posts: boolean; 269 + replies: boolean; 270 + mediaOnly: boolean; 271 + }; 272 + export const defaultProfilePostsFilter: ProfilePostsFilter = { 273 + posts: true, 274 + replies: true, 275 + mediaOnly: false, 276 + }; 277 + 278 + function ProfilePostsFilterChipBar({ 279 + filters, 280 + toggle, 281 + }: { 282 + filters: ProfilePostsFilter | null; 283 + toggle: (key: keyof ProfilePostsFilter) => void; 284 + }) { 285 + const empty = !filters?.replies && !filters?.posts; 286 + const almostEmpty = !filters?.replies && filters?.posts; 287 + 288 + useEffect(() => { 289 + if (empty) { 290 + toggle("posts"); 291 + } 292 + }, [empty, toggle]); 293 + 294 + return ( 295 + <div className="flex flex-row flex-wrap gap-2 px-4 pt-4"> 296 + <Chip 297 + state={filters?.posts ?? true} 298 + text="Posts" 299 + onClick={() => (almostEmpty ? null : toggle("posts"))} 300 + /> 301 + <Chip 302 + state={filters?.replies ?? true} 303 + text="Replies" 304 + onClick={() => toggle("replies")} 305 + /> 306 + <Chip 307 + state={filters?.mediaOnly ?? false} 308 + text="Media Only" 309 + onClick={() => toggle("mediaOnly")} 310 + /> 311 + </div> 312 + ); 313 + } 314 + 315 + function PostsTab({ did }: { did: string }) { 316 + // todo: this needs to be a (non-persisted is fine) atom to survive navigation 317 + const [filterses, setFilterses] = useAtom(profileChipsAtom); 318 + const filters = filterses?.[did]; 319 + const setFilters = (obj: ProfilePostsFilter) => { 320 + setFilterses((prev) => { 321 + return { 322 + ...prev, 323 + [did]: obj, 324 + }; 325 + }); 326 + }; 327 + useEffect(() => { 328 + if (!filters) { 329 + setFilters(defaultProfilePostsFilter); 330 + } 331 + }); 332 + useReusableTabScrollRestore(`Profile` + did); 333 + const queryClient = useQueryClient(); 334 + const { 335 + data: identity, 336 + isLoading: isIdentityLoading, 337 + error: identityError, 338 + } = useQueryIdentity(did); 339 + 340 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 341 + 342 + const { 343 + data: postsData, 344 + fetchNextPage, 345 + hasNextPage, 346 + isFetchingNextPage, 347 + isLoading: arePostsLoading, 348 + } = useInfiniteQueryAuthorFeed(resolvedDid, identity?.pds); 349 + 350 + React.useEffect(() => { 351 + if (postsData) { 352 + postsData.pages.forEach((page) => { 353 + page.records.forEach((record) => { 354 + if (!queryClient.getQueryData(["post", record.uri])) { 355 + queryClient.setQueryData(["post", record.uri], record); 356 + } 357 + }); 358 + }); 359 + } 360 + }, [postsData, queryClient]); 361 + 362 + const posts = React.useMemo( 363 + () => postsData?.pages.flatMap((page) => page.records) ?? [], 364 + [postsData] 365 + ); 366 + 367 + const toggle = (key: keyof ProfilePostsFilter) => { 368 + setFilterses((prev) => { 369 + const existing = prev[did] ?? { 370 + posts: false, 371 + replies: false, 372 + mediaOnly: false, 373 + }; // default 374 + 375 + return { 376 + ...prev, 377 + [did]: { 378 + ...existing, 379 + [key]: !existing[key], // safely negate 380 + }, 381 + }; 382 + }); 383 + }; 384 + 385 + return ( 386 + <> 387 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 388 + Posts 389 + </div> */} 390 + <ProfilePostsFilterChipBar filters={filters} toggle={toggle} /> 391 + <div> 392 + {posts.map((post) => ( 393 + <UniversalPostRendererATURILoader 394 + key={post.uri} 395 + atUri={post.uri} 396 + feedviewpost={true} 397 + filterNoReplies={!filters?.replies} 398 + filterMustHaveMedia={filters?.mediaOnly} 399 + filterMustBeReply={!filters?.posts} 400 + /> 401 + ))} 402 + </div> 403 + 404 + {/* Loading and "Load More" states */} 405 + {arePostsLoading && posts.length === 0 && ( 406 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 407 + )} 408 + {isFetchingNextPage && ( 409 + <div className="p-4 text-center text-gray-500">Loading more...</div> 410 + )} 411 + {hasNextPage && !isFetchingNextPage && ( 412 + <button 413 + onClick={() => fetchNextPage()} 414 + 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" 415 + > 416 + Load More Posts 417 + </button> 418 + )} 419 + {posts.length === 0 && !arePostsLoading && ( 420 + <div className="p-4 text-center text-gray-500">No posts found.</div> 421 + )} 422 + </> 423 + ); 424 + } 425 + 426 + function RepostsTab({ did }: { did: string }) { 427 + useReusableTabScrollRestore(`Profile` + did); 428 + const { 429 + data: identity, 430 + isLoading: isIdentityLoading, 431 + error: identityError, 432 + } = useQueryIdentity(did); 433 + 434 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 435 + 436 + const { 437 + data: repostsData, 438 + fetchNextPage, 439 + hasNextPage, 440 + isFetchingNextPage, 441 + isLoading: arePostsLoading, 442 + } = useInfiniteQueryAuthorFeed( 443 + resolvedDid, 444 + identity?.pds, 445 + "app.bsky.feed.repost" 446 + ); 447 + 448 + const reposts = React.useMemo( 449 + () => repostsData?.pages.flatMap((page) => page.records) ?? [], 450 + [repostsData] 451 + ); 452 + 453 + return ( 454 + <> 455 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 456 + Reposts 457 + </div> 458 + <div> 459 + {reposts.map((repost) => { 460 + if ( 461 + !repost || 462 + !repost?.value || 463 + !repost?.value?.subject || 464 + // @ts-expect-error blehhhhh 465 + !repost?.value?.subject?.uri 466 + ) 467 + return; 468 + const repostRecord = 469 + repost.value as unknown as ATPAPI.AppBskyFeedRepost.Record; 470 + return ( 228 471 <UniversalPostRendererATURILoader 229 - key={post.uri} 230 - atUri={post.uri} 472 + key={repostRecord.subject.uri} 473 + atUri={repostRecord.subject.uri} 231 474 feedviewpost={true} 475 + repostedby={repost.uri} 232 476 /> 233 - ))} 234 - </div> 477 + ); 478 + })} 479 + </div> 480 + 481 + {/* Loading and "Load More" states */} 482 + {arePostsLoading && reposts.length === 0 && ( 483 + <div className="p-4 text-center text-gray-500">Loading posts...</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 Posts 494 + </button> 495 + )} 496 + {reposts.length === 0 && !arePostsLoading && ( 497 + <div className="p-4 text-center text-gray-500">No posts found.</div> 498 + )} 499 + </> 500 + ); 501 + } 502 + 503 + function FeedsTab({ did }: { did: string }) { 504 + useReusableTabScrollRestore(`Profile` + did); 505 + const { 506 + data: identity, 507 + isLoading: isIdentityLoading, 508 + error: identityError, 509 + } = useQueryIdentity(did); 510 + 511 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 512 + 513 + const { 514 + data: feedsData, 515 + fetchNextPage, 516 + hasNextPage, 517 + isFetchingNextPage, 518 + isLoading: arePostsLoading, 519 + } = useInfiniteQueryAuthorFeed( 520 + resolvedDid, 521 + identity?.pds, 522 + "app.bsky.feed.generator" 523 + ); 235 524 236 - {/* Loading and "Load More" states */} 237 - {arePostsLoading && posts.length === 0 && ( 238 - <div className="p-4 text-center text-gray-500">Loading posts...</div> 239 - )} 240 - {isFetchingNextPage && ( 241 - <div className="p-4 text-center text-gray-500">Loading more...</div> 242 - )} 243 - {hasNextPage && !isFetchingNextPage && ( 244 - <button 245 - onClick={() => fetchNextPage()} 246 - 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" 525 + const feeds = React.useMemo( 526 + () => feedsData?.pages.flatMap((page) => page.records) ?? [], 527 + [feedsData] 528 + ); 529 + 530 + return ( 531 + <> 532 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 533 + Feeds 534 + </div> 535 + <div> 536 + {feeds.map((feed) => { 537 + if (!feed || !feed?.value) return; 538 + const feedGenRecord = 539 + feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record; 540 + return <FeedItemRender feed={feed as any} key={feed.uri} />; 541 + })} 542 + </div> 543 + 544 + {/* Loading and "Load More" states */} 545 + {arePostsLoading && feeds.length === 0 && ( 546 + <div className="p-4 text-center text-gray-500">Loading feeds...</div> 547 + )} 548 + {isFetchingNextPage && ( 549 + <div className="p-4 text-center text-gray-500">Loading more...</div> 550 + )} 551 + {hasNextPage && !isFetchingNextPage && ( 552 + <button 553 + onClick={() => fetchNextPage()} 554 + 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" 555 + > 556 + Load More Feeds 557 + </button> 558 + )} 559 + {feeds.length === 0 && !arePostsLoading && ( 560 + <div className="p-4 text-center text-gray-500">No feeds found.</div> 561 + )} 562 + </> 563 + ); 564 + } 565 + 566 + function LabelsTab({ 567 + did, 568 + labelerRecord, 569 + }: { 570 + did: string; 571 + labelerRecord?: ATPAPI.AppBskyLabelerService.Record; 572 + }) { 573 + useReusableTabScrollRestore(`Profile` + did); 574 + const { agent } = useAuth(); 575 + // const { 576 + // data: identity, 577 + // isLoading: isIdentityLoading, 578 + // error: identityError, 579 + // } = useQueryIdentity(did); 580 + 581 + // const resolvedDid = did.startsWith("did:") ? did : identity?.did; 582 + 583 + const labelMap = new Map( 584 + labelerRecord?.policies?.labelValueDefinitions?.map((def) => { 585 + const locale = def.locales.find((l) => l.lang === "en") ?? def.locales[0]; 586 + return [ 587 + def.identifier, 588 + { 589 + name: locale?.name, 590 + description: locale?.description, 591 + blur: def.blurs, 592 + severity: def.severity, 593 + adultOnly: def.adultOnly, 594 + defaultSetting: def.defaultSetting, 595 + }, 596 + ]; 597 + }) 598 + ); 599 + 600 + return ( 601 + <> 602 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 603 + Labels 604 + </div> 605 + <div> 606 + {[...labelMap.entries()].map(([key, item]) => ( 607 + <div 608 + key={key} 609 + className="border-gray-300 dark:border-gray-700 border-b px-4 py-4" 247 610 > 248 - Load More Posts 249 - </button> 250 - )} 251 - {posts.length === 0 && !arePostsLoading && ( 252 - <div className="p-4 text-center text-gray-500">No posts found.</div> 253 - )} 611 + <div className="font-semibold text-lg">{item.name}</div> 612 + <div className="text-sm text-gray-500 dark:text-gray-400"> 613 + {item.description} 614 + </div> 615 + <div className="mt-1 text-xs text-gray-400"> 616 + {item.blur && <span>Blur: {item.blur} </span>} 617 + {item.severity && <span>โ€ข Severity: {item.severity} </span>} 618 + {item.adultOnly && <span>โ€ข 18+ only</span>} 619 + </div> 620 + </div> 621 + ))} 622 + </div> 623 + 624 + {/* Loading and "Load More" states */} 625 + {!labelerRecord && ( 626 + <div className="p-4 text-center text-gray-500">Loading labels...</div> 627 + )} 628 + {/* {!labelerRecord && ( 629 + <div className="p-4 text-center text-gray-500">Loading more...</div> 630 + )} */} 631 + {/* {hasNextPage && !isFetchingNextPage && ( 632 + <button 633 + onClick={() => fetchNextPage()} 634 + 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" 635 + > 636 + Load More Feeds 637 + </button> 638 + )} 639 + {feeds.length === 0 && !arePostsLoading && ( 640 + <div className="p-4 text-center text-gray-500">No feeds found.</div> 641 + )} */} 642 + </> 643 + ); 644 + } 645 + 646 + export function FeedItemRenderAturiLoader({ 647 + aturi, 648 + listmode, 649 + disableBottomBorder, 650 + disablePropagation, 651 + }: { 652 + aturi: string; 653 + listmode?: boolean; 654 + disableBottomBorder?: boolean; 655 + disablePropagation?: boolean; 656 + }) { 657 + const { data: record } = useQueryArbitrary(aturi); 658 + 659 + if (!record) return; 660 + return ( 661 + <FeedItemRender 662 + listmode={listmode} 663 + feed={record} 664 + disableBottomBorder={disableBottomBorder} 665 + disablePropagation={disablePropagation} 666 + /> 667 + ); 668 + } 669 + 670 + export function FeedItemRender({ 671 + feed, 672 + listmode, 673 + disableBottomBorder, 674 + disablePropagation, 675 + }: { 676 + feed: { uri: string; cid: string; value: any }; 677 + listmode?: boolean; 678 + disableBottomBorder?: boolean; 679 + disablePropagation?: boolean; 680 + }) { 681 + const name = listmode 682 + ? (feed.value?.name as string) 683 + : (feed.value?.displayName as string); 684 + const aturi = new ATPAPI.AtUri(feed.uri); 685 + const { data: identity } = useQueryIdentity(aturi.host); 686 + const resolvedDid = identity?.did; 687 + const [imgcdn] = useAtom(imgCDNAtom); 688 + 689 + function getAvatarThumbnailUrl(f: typeof feed) { 690 + const link = f?.value.avatar?.ref?.["$link"]; 691 + if (!link || !resolvedDid) return null; 692 + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 693 + } 694 + 695 + const { data: likes } = useQueryConstellation( 696 + // @ts-expect-error overloads sucks 697 + !listmode 698 + ? { 699 + target: feed.uri, 700 + method: "/links/count", 701 + collection: "app.bsky.feed.like", 702 + path: ".subject.uri", 703 + } 704 + : undefined 705 + ); 706 + 707 + return ( 708 + <Link 709 + className={`px-4 py-4 ${!disableBottomBorder && "border-b"} flex flex-col gap-1`} 710 + to="/profile/$did/feed/$rkey" 711 + params={{ did: aturi.host, rkey: aturi.rkey }} 712 + onClick={(e) => { 713 + e.stopPropagation(); 714 + }} 715 + > 716 + <div className="flex flex-row gap-3"> 717 + <div className="min-w-10 min-h-10"> 718 + <img 719 + src={getAvatarThumbnailUrl(feed) || defaultpfp} 720 + className="h-10 w-10 rounded border" 721 + /> 722 + </div> 723 + <div className="flex flex-col"> 724 + <span className="">{name}</span> 725 + <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"> 726 + {feed.value.did || aturi.rkey} 727 + </span> 728 + </div> 729 + <div className="flex-1" /> 730 + {/* <div className="button bg-red-500 rounded-full min-w-[60px]" /> */} 731 + </div> 732 + <span className=" text-sm">{feed.value?.description}</span> 733 + {!listmode && ( 734 + <span className=" text-sm dark:text-gray-400 text-gray-500"> 735 + Liked by {((likes as unknown as any)?.total as number) || 0} users 736 + </span> 737 + )} 738 + </Link> 739 + ); 740 + } 741 + 742 + function ListsTab({ did }: { did: string }) { 743 + useReusableTabScrollRestore(`Profile` + did); 744 + const { 745 + data: identity, 746 + isLoading: isIdentityLoading, 747 + error: identityError, 748 + } = useQueryIdentity(did); 749 + 750 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 751 + 752 + const { 753 + data: feedsData, 754 + fetchNextPage, 755 + hasNextPage, 756 + isFetchingNextPage, 757 + isLoading: arePostsLoading, 758 + } = useInfiniteQueryAuthorFeed( 759 + resolvedDid, 760 + identity?.pds, 761 + "app.bsky.graph.list" 762 + ); 763 + 764 + const feeds = React.useMemo( 765 + () => feedsData?.pages.flatMap((page) => page.records) ?? [], 766 + [feedsData] 767 + ); 768 + 769 + return ( 770 + <> 771 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 772 + Feeds 773 + </div> 774 + <div> 775 + {feeds.map((feed) => { 776 + if (!feed || !feed?.value) return; 777 + const feedGenRecord = 778 + feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record; 779 + return ( 780 + <FeedItemRender listmode={true} feed={feed as any} key={feed.uri} /> 781 + ); 782 + })} 783 + </div> 784 + 785 + {/* Loading and "Load More" states */} 786 + {arePostsLoading && feeds.length === 0 && ( 787 + <div className="p-4 text-center text-gray-500">Loading lists...</div> 788 + )} 789 + {isFetchingNextPage && ( 790 + <div className="p-4 text-center text-gray-500">Loading more...</div> 791 + )} 792 + {hasNextPage && !isFetchingNextPage && ( 793 + <button 794 + onClick={() => fetchNextPage()} 795 + 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" 796 + > 797 + Load More Lists 798 + </button> 799 + )} 800 + {feeds.length === 0 && !arePostsLoading && ( 801 + <div className="p-4 text-center text-gray-500">No lists found.</div> 802 + )} 803 + </> 804 + ); 805 + } 806 + 807 + function SelfLikesTab({ did }: { did: string }) { 808 + useReusableTabScrollRestore(`Profile` + did); 809 + const { 810 + data: identity, 811 + isLoading: isIdentityLoading, 812 + error: identityError, 813 + } = useQueryIdentity(did); 814 + 815 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 816 + 817 + const { 818 + data: likesData, 819 + fetchNextPage, 820 + hasNextPage, 821 + isFetchingNextPage, 822 + isLoading: arePostsLoading, 823 + } = useInfiniteQueryAuthorFeed( 824 + resolvedDid, 825 + identity?.pds, 826 + "app.bsky.feed.like" 827 + ); 828 + 829 + const likes = React.useMemo( 830 + () => likesData?.pages.flatMap((page) => page.records) ?? [], 831 + [likesData] 832 + ); 833 + 834 + const { setFastState } = useFastSetLikesFromFeed(); 835 + const seededRef = React.useRef(new Set<string>()); 836 + 837 + useEffect(() => { 838 + for (const like of likes) { 839 + if (!seededRef.current.has(like.uri)) { 840 + seededRef.current.add(like.uri); 841 + const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record; 842 + setFastState(record.subject.uri, { 843 + target: record.subject.uri, 844 + uri: like.uri, 845 + cid: like.cid, 846 + }); 847 + } 848 + } 849 + }, [likes, setFastState]); 850 + 851 + return ( 852 + <> 853 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 854 + Likes 855 + </div> 856 + <div> 857 + {likes.map((like) => { 858 + if ( 859 + !like || 860 + !like?.value || 861 + !like?.value?.subject || 862 + // @ts-expect-error blehhhhh 863 + !like?.value?.subject?.uri 864 + ) 865 + return; 866 + const likeRecord = 867 + like.value as unknown as ATPAPI.AppBskyFeedLike.Record; 868 + return ( 869 + <UniversalPostRendererATURILoader 870 + key={likeRecord.subject.uri} 871 + atUri={likeRecord.subject.uri} 872 + feedviewpost={true} 873 + /> 874 + ); 875 + })} 254 876 </div> 877 + 878 + {/* Loading and "Load More" states */} 879 + {arePostsLoading && likes.length === 0 && ( 880 + <div className="p-4 text-center text-gray-500">Loading likes...</div> 881 + )} 882 + {isFetchingNextPage && ( 883 + <div className="p-4 text-center text-gray-500">Loading more...</div> 884 + )} 885 + {hasNextPage && !isFetchingNextPage && ( 886 + <button 887 + onClick={() => fetchNextPage()} 888 + 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" 889 + > 890 + Load More Likes 891 + </button> 892 + )} 893 + {likes.length === 0 && !arePostsLoading && ( 894 + <div className="p-4 text-center text-gray-500">No likes found.</div> 895 + )} 255 896 </> 256 897 ); 257 898 } 899 + 900 + export function FollowButton({ 901 + targetdidorhandle, 902 + }: { 903 + targetdidorhandle: string; 904 + }) { 905 + const { agent } = useAuth(); 906 + const { data: identity } = useQueryIdentity(targetdidorhandle); 907 + const queryClient = useQueryClient(); 908 + 909 + const followRecords = useGetFollowState({ 910 + target: identity?.did ?? targetdidorhandle, 911 + user: agent?.did, 912 + }); 913 + 914 + return ( 915 + <> 916 + {identity?.did !== agent?.did ? ( 917 + <> 918 + {!(followRecords?.length && followRecords?.length > 0) ? ( 919 + <button 920 + onClick={(e) => { 921 + e.stopPropagation(); 922 + toggleFollow({ 923 + agent: agent || undefined, 924 + targetDid: identity?.did, 925 + followRecords: followRecords, 926 + queryClient: queryClient, 927 + }); 928 + }} 929 + 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]" 930 + > 931 + Follow 932 + </button> 933 + ) : ( 934 + <button 935 + onClick={(e) => { 936 + e.stopPropagation(); 937 + toggleFollow({ 938 + agent: agent || undefined, 939 + targetDid: identity?.did, 940 + followRecords: followRecords, 941 + queryClient: queryClient, 942 + }); 943 + }} 944 + 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]" 945 + > 946 + Unfollow 947 + </button> 948 + )} 949 + </> 950 + ) : ( 951 + <button 952 + 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]" 953 + onClick={(e) => { 954 + renderSnack({ 955 + title: "Not Implemented Yet", 956 + description: "Sorry...", 957 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 958 + }); 959 + }} 960 + > 961 + Edit Profile 962 + </button> 963 + )} 964 + </> 965 + ); 966 + } 967 + 968 + export function BiteButton({ 969 + targetdidorhandle, 970 + }: { 971 + targetdidorhandle: string; 972 + }) { 973 + const { agent } = useAuth(); 974 + const { data: identity } = useQueryIdentity(targetdidorhandle); 975 + const [show] = useAtom(enableBitesAtom); 976 + 977 + if (!show) return; 978 + 979 + return ( 980 + <> 981 + <button 982 + onClick={async (e) => { 983 + e.stopPropagation(); 984 + await sendBite({ 985 + agent: agent || undefined, 986 + targetDid: identity?.did, 987 + }); 988 + }} 989 + 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]" 990 + > 991 + Bite 992 + </button> 993 + </> 994 + ); 995 + } 996 + 997 + async function sendBite({ 998 + agent, 999 + targetDid, 1000 + }: { 1001 + agent?: Agent; 1002 + targetDid?: string; 1003 + }) { 1004 + if (!agent?.did || !targetDid) { 1005 + renderSnack({ 1006 + title: "Bite Failed", 1007 + description: "You must be logged-in to bite someone.", 1008 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 1009 + }); 1010 + return; 1011 + } 1012 + const newRecord = { 1013 + repo: agent.did, 1014 + collection: "net.wafrn.feed.bite", 1015 + rkey: TID.next().toString(), 1016 + record: { 1017 + $type: "net.wafrn.feed.bite", 1018 + subject: "at://" + targetDid, 1019 + createdAt: new Date().toISOString(), 1020 + }, 1021 + }; 1022 + 1023 + try { 1024 + await agent.com.atproto.repo.createRecord(newRecord); 1025 + renderSnack({ 1026 + title: "Bite Sent", 1027 + description: "Your bite was delivered.", 1028 + //button: { label: 'Undo', onClick: () => console.log('Undo clicked') }, 1029 + }); 1030 + } catch (err) { 1031 + console.error("Bite failed:", err); 1032 + renderSnack({ 1033 + title: "Bite Failed", 1034 + description: "Your bite failed to be delivered.", 1035 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 1036 + }); 1037 + } 1038 + } 1039 + 1040 + export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 1041 + const { agent } = useAuth(); 1042 + const { data: identity } = useQueryIdentity(targetdidorhandle); 1043 + 1044 + const theyFollowYouRes = useGetOneToOneState( 1045 + agent?.did 1046 + ? { 1047 + target: agent?.did, 1048 + user: identity?.did ?? targetdidorhandle, 1049 + collection: "app.bsky.graph.follow", 1050 + path: ".subject", 1051 + } 1052 + : undefined 1053 + ); 1054 + 1055 + const youFollowThemRes = useGetFollowState({ 1056 + target: identity?.did ?? targetdidorhandle, 1057 + user: agent?.did, 1058 + }); 1059 + 1060 + const theyFollowYou: boolean = 1061 + !!theyFollowYouRes?.length && theyFollowYouRes.length > 0; 1062 + const youFollowThem: boolean = 1063 + !!youFollowThemRes?.length && youFollowThemRes.length > 0; 1064 + 1065 + return ( 1066 + <> 1067 + {/* if not self */} 1068 + {identity?.did !== agent?.did ? ( 1069 + <> 1070 + {theyFollowYou ? ( 1071 + <> 1072 + {youFollowThem ? ( 1073 + <div 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"> 1074 + mutuals 1075 + </div> 1076 + ) : ( 1077 + <div 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"> 1078 + follows you 1079 + </div> 1080 + )} 1081 + </> 1082 + ) : ( 1083 + <></> 1084 + )} 1085 + </> 1086 + ) : ( 1087 + // lmao can someone be mutuals with themselves ?? 1088 + <></> 1089 + )} 1090 + </> 1091 + ); 1092 + } 1093 + 1094 + export function RichTextRenderer({ description }: { description: string }) { 1095 + const [richDescription, setRichDescription] = useState<string | ReactNode[]>( 1096 + description 1097 + ); 1098 + const { agent } = useAuth(); 1099 + const navigate = useNavigate(); 1100 + 1101 + useEffect(() => { 1102 + let mounted = true; 1103 + 1104 + // setRichDescription(description); 1105 + 1106 + async function processRichText() { 1107 + try { 1108 + if (!agent?.did) return; 1109 + const rt = new RichText({ text: description }); 1110 + await rt.detectFacets(agent); 1111 + 1112 + if (!mounted) return; 1113 + 1114 + if (rt.facets) { 1115 + setRichDescription( 1116 + renderTextWithFacets({ text: rt.text, facets: rt.facets, navigate }) 1117 + ); 1118 + } else { 1119 + setRichDescription(rt.text); 1120 + } 1121 + } catch (error) { 1122 + console.error("Failed to detect facets:", error); 1123 + if (mounted) { 1124 + setRichDescription(description); 1125 + } 1126 + } 1127 + } 1128 + 1129 + processRichText(); 1130 + 1131 + return () => { 1132 + mounted = false; 1133 + }; 1134 + }, [description, agent, navigate]); 1135 + 1136 + return <>{richDescription}</>; 1137 + }
+1 -1
src/routes/profile.$did/post.$rkey.image.$i.tsx
··· 85 85 e.stopPropagation(); 86 86 e.nativeEvent.stopImmediatePropagation(); 87 87 }} 88 - className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white" 88 + className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter disablescroll border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white" 89 89 > 90 90 <ProfilePostComponent 91 91 key={`/profile/${did}/post/${rkey}`}
+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 + }
+254 -141
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"; 4 - import React, { useEffect, useLayoutEffect } from "react"; 3 + import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 5 + import React, { useLayoutEffect } from "react"; 5 6 6 7 import { Header } from "~/components/Header"; 7 8 import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 9 + import { constellationURLAtom, slingshotURLAtom } from "~/utils/atoms"; 8 10 //import { usePersistentStore } from '~/providers/PersistentStoreProvider'; 9 11 import { 10 12 constructPostQuery, 13 + type linksAllResponse, 14 + type linksRecordsResponse, 15 + useQueryConstellation, 11 16 useQueryIdentity, 12 17 useQueryPost, 13 18 yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, ··· 40 45 did, 41 46 rkey, 42 47 nopics, 43 - lightboxCallback 48 + lightboxCallback, 44 49 }: { 45 50 did: string; 46 51 rkey: string; 47 52 nopics?: boolean; 48 - lightboxCallback?: (d:LightboxProps) => void; 53 + lightboxCallback?: (d: LightboxProps) => void; 49 54 }) { 55 + const matchRoute = useMatchRoute() 56 + const showMainPostRoute = !!matchRoute({ to: '/profile/$did/post/$rkey' }) || !!matchRoute({ to: '/profile/$did/post/$rkey/image/$i' }) 57 + 50 58 //const { get, set } = usePersistentStore(); 51 59 const queryClient = useQueryClient(); 52 60 // const [resolvedDid, setResolvedDid] = React.useState<string | null>(null); ··· 185 193 data: identity, 186 194 isLoading: isIdentityLoading, 187 195 error: identityError, 188 - } = useQueryIdentity(did); 196 + } = useQueryIdentity(showMainPostRoute ? did : undefined); 189 197 190 198 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 191 199 192 200 const atUri = React.useMemo( 193 201 () => 194 - resolvedDid 202 + resolvedDid && showMainPostRoute 195 203 ? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}` 196 - : "", 197 - [resolvedDid, rkey] 204 + : undefined, 205 + [resolvedDid, rkey, showMainPostRoute] 198 206 ); 199 207 200 - const { data: mainPost } = useQueryPost(atUri); 208 + const { data: mainPost } = useQueryPost(showMainPostRoute ? atUri : undefined); 209 + 210 + console.log("atUri",atUri) 211 + 212 + const opdid = React.useMemo( 213 + () => 214 + atUri 215 + ? new AtUri(atUri).host 216 + : undefined, 217 + [atUri] 218 + ); 219 + 220 + // @ts-expect-error i hate overloads 221 + const { data: links } = useQueryConstellation(atUri&&showMainPostRoute?{ 222 + method: "/links/all", 223 + target: atUri, 224 + } : { 225 + method: "undefined", 226 + target: "" 227 + })as { data: linksAllResponse | undefined }; 228 + 229 + //const [likes, setLikes] = React.useState<number | null>(null); 230 + //const [reposts, setReposts] = React.useState<number | null>(null); 231 + const [replyCount, setReplyCount] = React.useState<number | null>(null); 232 + 233 + React.useEffect(() => { 234 + // /*mass comment*/ console.log(JSON.stringify(links, null, 2)); 235 + // setLikes( 236 + // links 237 + // ? links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0 238 + // : null 239 + // ); 240 + // setReposts( 241 + // links 242 + // ? links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0 243 + // : null 244 + // ); 245 + setReplyCount( 246 + links 247 + ? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"] 248 + ?.records || 0 249 + : null 250 + ); 251 + }, [links]); 252 + 253 + const { data: opreplies } = useQueryConstellation( 254 + showMainPostRoute && !!opdid && replyCount && replyCount >= 25 255 + ? { 256 + method: "/links", 257 + target: atUri, 258 + // @ts-expect-error overloading sucks so much 259 + collection: "app.bsky.feed.post", 260 + path: ".reply.parent.uri", 261 + //cursor?: string; 262 + dids: [opdid], 263 + } 264 + : { 265 + method: "undefined", 266 + target: "", 267 + } 268 + ) as { data: linksRecordsResponse | undefined }; 269 + 270 + const opReplyAturis = 271 + opreplies?.linking_records.map( 272 + (r) => `at://${r.did}/${r.collection}/${r.rkey}`, 273 + ) ?? []; 274 + 201 275 202 276 // const { data: repliesData } = useQueryConstellation({ 203 277 // method: "/links", ··· 206 280 // path: ".reply.parent.uri", 207 281 // }); 208 282 // const replies = repliesData?.linking_records.slice(0, 50) ?? []; 283 + const [constellationurl] = useAtom(constellationURLAtom) 284 + 209 285 const infinitequeryresults = useInfiniteQuery({ 210 286 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 211 287 { 288 + constellation: constellationurl, 212 289 method: "/links", 213 290 target: atUri, 214 291 collection: "app.bsky.feed.post", 215 292 path: ".reply.parent.uri", 216 293 } 217 294 ), 218 - enabled: !!atUri, 295 + enabled: !!atUri && showMainPostRoute, 219 296 }); 220 297 221 298 const { 222 - data: repliesData, 223 - // fetchNextPage, 224 - // hasNextPage, 225 - // isFetchingNextPage, 299 + data: infiniteRepliesData, 300 + fetchNextPage, 301 + hasNextPage, 302 + isFetchingNextPage, 226 303 } = infinitequeryresults; 227 304 228 - // auto-fetch all pages 229 - useEffect(() => { 230 - if ( 231 - infinitequeryresults.hasNextPage && 232 - !infinitequeryresults.isFetchingNextPage 233 - ) { 234 - console.log("Fetching the next page..."); 235 - infinitequeryresults.fetchNextPage(); 236 - } 237 - }, [infinitequeryresults]); 305 + // // auto-fetch all pages 306 + // useEffect(() => { 307 + // if ( 308 + // infinitequeryresults.hasNextPage && 309 + // !infinitequeryresults.isFetchingNextPage 310 + // ) { 311 + // console.log("Fetching the next page..."); 312 + // infinitequeryresults.fetchNextPage(); 313 + // } 314 + // }, [infinitequeryresults]); 238 315 239 - const replyAturis = repliesData 240 - ? repliesData.pages.flatMap((page) => 241 - page 242 - ? page.linking_records.map((record) => { 243 - const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; 244 - return aturi; 245 - }) 246 - : [] 247 - ) 248 - : []; 316 + // const replyAturis = repliesData 317 + // ? repliesData.pages.flatMap((page) => 318 + // page 319 + // ? page.linking_records.map((record) => { 320 + // const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; 321 + // return aturi; 322 + // }) 323 + // : [] 324 + // ) 325 + // : []; 326 + 327 + const replyAturis = React.useMemo(() => { 328 + // Get all replies from the standard infinite query 329 + const allReplies = 330 + infiniteRepliesData?.pages.flatMap( 331 + (page) => 332 + page?.linking_records.map( 333 + (r) => `at://${r.did}/${r.collection}/${r.rkey}`, 334 + ) ?? [], 335 + ) ?? []; 249 336 250 - const opdid = new AtUri(atUri).host; 337 + if (replyCount && (replyCount < 25)) { 338 + // If count is low, just use the standard list and find the oldest OP reply to move to the top 339 + const opdidFromUri = atUri ? new AtUri(atUri).host : undefined; 340 + const oldestOpsIndex = allReplies.findIndex( 341 + (aturi) => new AtUri(aturi).host === opdidFromUri, 342 + ); 343 + if (oldestOpsIndex > 0) { 344 + const [oldestOpsReply] = allReplies.splice(oldestOpsIndex, 1); 345 + allReplies.unshift(oldestOpsReply); 346 + } 347 + return allReplies; 348 + } else { 349 + // If count is high, prioritize OP replies from the special query 350 + // and filter them out from the main list to avoid duplication. 351 + const opReplySet = new Set(opReplyAturis); 352 + const otherReplies = allReplies.filter((uri) => !opReplySet.has(uri)); 353 + return [...opReplyAturis, ...otherReplies]; 354 + } 355 + }, [infiniteRepliesData, opReplyAturis, replyCount, atUri]); 251 356 252 357 // Find oldest OP reply 253 358 const oldestOpsIndex = replyAturis.findIndex( ··· 260 365 replyAturis.unshift(oldestOpsReply); 261 366 } 262 367 263 - 264 368 const [parents, setParents] = React.useState<any[]>([]); 265 369 const [parentsLoading, setParentsLoading] = React.useState(false); 266 370 267 371 const mainPostRef = React.useRef<HTMLDivElement>(null); 268 - const userHasScrolled = React.useRef(false); 372 + const hasPerformedInitialLayout = React.useRef(false); 269 373 270 - const scrollAnchor = React.useRef<{ top: number } | null>(null); 374 + const [layoutReady, setLayoutReady] = React.useState(false); 271 375 272 - React.useEffect(() => { 273 - const onScroll = () => { 274 - if (window.scrollY > 50) { 275 - userHasScrolled.current = true; 376 + useLayoutEffect(() => { 377 + if (!showMainPostRoute) return 378 + if (parents.length > 0 && !layoutReady && mainPostRef.current) { 379 + const mainPostElement = mainPostRef.current; 276 380 277 - window.removeEventListener("scroll", onScroll); 381 + if (window.scrollY === 0 && !hasPerformedInitialLayout.current) { 382 + const elementTop = mainPostElement.getBoundingClientRect().top; 383 + const headerOffset = 70; 384 + 385 + const targetScrollY = elementTop - headerOffset; 386 + 387 + window.scrollBy(0, targetScrollY); 388 + 389 + hasPerformedInitialLayout.current = true; 278 390 } 279 - }; 391 + 392 + // todo idk what to do with this 393 + // eslint-disable-next-line react-hooks/set-state-in-effect 394 + setLayoutReady(true); 395 + } 396 + }, [parents, layoutReady, showMainPostRoute]); 280 397 281 - if (!userHasScrolled.current) { 282 - window.addEventListener("scroll", onScroll, { passive: true }); 283 - } 284 - return () => window.removeEventListener("scroll", onScroll); 285 - }, []); 286 398 287 - useLayoutEffect(() => { 288 - if (parentsLoading && mainPostRef.current && !userHasScrolled.current) { 289 - scrollAnchor.current = { 290 - top: mainPostRef.current.getBoundingClientRect().top, 291 - }; 399 + const [slingshoturl] = useAtom(slingshotURLAtom) 400 + 401 + React.useEffect(() => { 402 + if (parentsLoading || !showMainPostRoute) { 403 + setLayoutReady(false); 292 404 } 293 - }, [parentsLoading]); 294 405 295 - useLayoutEffect(() => { 296 - if ( 297 - scrollAnchor.current && 298 - mainPostRef.current && 299 - !userHasScrolled.current 300 - ) { 301 - const newTop = mainPostRef.current.getBoundingClientRect().top; 302 - const topDiff = newTop - scrollAnchor.current.top; 303 - if (topDiff > 0) { 304 - window.scrollBy(0, topDiff); 305 - } 306 - scrollAnchor.current = null; 406 + if (!mainPost?.value?.reply?.parent?.uri && !parentsLoading) { 407 + setLayoutReady(true); 408 + hasPerformedInitialLayout.current = true; 307 409 } 308 - }, [parents]); 410 + }, [parentsLoading, mainPost, showMainPostRoute]); 309 411 310 412 React.useEffect(() => { 311 413 if (!mainPost?.value?.reply?.parent?.uri) { ··· 324 426 while (currentParentUri && safetyCounter < MAX_PARENTS) { 325 427 try { 326 428 const parentPost = await queryClient.fetchQuery( 327 - constructPostQuery(currentParentUri) 429 + constructPostQuery(currentParentUri, slingshoturl) 328 430 ); 329 431 if (!parentPost) break; 330 432 parentChain.push(parentPost); ··· 346 448 return () => { 347 449 ignore = true; 348 450 }; 349 - }, [mainPost, queryClient]); 451 + }, [mainPost, queryClient, slingshoturl]); 350 452 351 - if (!did || !rkey) return <div>Invalid post URI</div>; 352 - if (isIdentityLoading) return <div>Resolving handle...</div>; 353 - 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) 354 456 return <div style={{ color: "red" }}>{identityError.message}</div>; 355 - if (!atUri) return <div>Could not construct post URI.</div>; 457 + if (!atUri && showMainPostRoute) return <div>Could not construct post URI.</div>; 356 458 357 459 return ( 358 460 <> 359 461 <Outlet /> 360 - <Header 361 - title={`Post`} 362 - backButtonCallback={() => { 363 - if (window.history.length > 1) { 364 - window.history.back(); 365 - } else { 366 - window.location.assign("/"); 367 - } 368 - }} 369 - /> 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 + /> 370 473 371 - {parentsLoading && ( 372 - <div className="text-center text-gray-500 dark:text-gray-400 flex flex-row"> 373 - <div className="ml-4 w-[42px] flex justify-center"> 374 - <div 375 - style={{ width: 2, height: "100%", opacity: 0.5 }} 376 - className="bg-gray-500 dark:bg-gray-400" 377 - ></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... 378 483 </div> 379 - 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 + ))} 380 497 </div> 381 - )} 382 - 383 - {/* we should use the reply lines here thats provided by UPR*/} 384 - <div style={{ maxWidth: 600, padding: 0 }}> 385 - {parents.map((parent, index) => ( 498 + <div ref={mainPostRef}> 386 499 <UniversalPostRendererATURILoader 387 - key={parent.uri} 388 - atUri={parent.uri} 389 - topReplyLine={index > 0} 390 - bottomReplyLine={true} 391 - bottomBorder={false} 500 + atUri={atUri!} 501 + detailed={true} 502 + topReplyLine={parentsLoading || parents.length > 0} 503 + nopics={!!nopics} 504 + lightboxCallback={lightboxCallback} 392 505 /> 393 - ))} 394 - </div> 395 - <div ref={mainPostRef}> 396 - <UniversalPostRendererATURILoader 397 - atUri={atUri} 398 - detailed={true} 399 - topReplyLine={parentsLoading || parents.length > 0} 400 - nopics={!!nopics} 401 - lightboxCallback={lightboxCallback} 402 - /> 403 - </div> 404 - <div 405 - style={{ 406 - maxWidth: 600, 407 - //margin: "0px auto 0", 408 - padding: 0, 409 - minHeight: "80dvh", 410 - paddingBottom: "20dvh" 411 - }} 412 - > 506 + </div> 413 507 <div 414 - className="text-gray-500 dark:text-gray-400 text-sm font-bold" 415 508 style={{ 416 - fontSize: 18, 417 - margin: "12px 16px 12px 16px", 418 - fontWeight: 600, 509 + maxWidth: 600, 510 + //margin: "0px auto 0", 511 + padding: 0, 512 + minHeight: "80dvh", 513 + paddingBottom: "20dvh", 419 514 }} 420 515 > 421 - Replies 422 - </div> 423 - <div style={{ display: "flex", flexDirection: "column", gap: 0 }}> 424 - {replyAturis.length > 0 && 425 - replyAturis.map((reply) => { 426 - //const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 427 - return ( 428 - <UniversalPostRendererATURILoader 429 - key={reply} 430 - atUri={reply} 431 - maxReplies={4} 432 - /> 433 - ); 434 - })} 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> 435 548 </div> 436 - </div> 549 + </>)} 437 550 </> 438 551 ); 439 552 }
+259 -2
src/routes/search.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 1 + import type { Agent } from "@atproto/api"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { createFileRoute, useSearch } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 5 + import { useEffect,useMemo } from "react"; 6 + 7 + import { Header } from "~/components/Header"; 8 + import { Import } from "~/components/Import"; 9 + import { 10 + ReusableTabRoute, 11 + useReusableTabScrollRestore, 12 + } from "~/components/ReusableTabRoute"; 13 + import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 14 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 15 + import { lycanURLAtom } from "~/utils/atoms"; 16 + import { 17 + constructLycanRequestIndexQuery, 18 + useInfiniteQueryLycanSearch, 19 + useQueryIdentity, 20 + useQueryLycanStatus, 21 + } from "~/utils/useQuery"; 22 + 23 + import { renderSnack } from "./__root"; 24 + import { SliderPrimitive } from "./settings"; 2 25 3 26 export const Route = createFileRoute("/search")({ 4 27 component: Search, 5 28 }); 6 29 7 30 export function Search() { 8 - return <div className="p-6">Search page (coming soon)</div>; 31 + const queryClient = useQueryClient(); 32 + const { agent, status } = useAuth(); 33 + const { data: identity } = useQueryIdentity(agent?.did); 34 + const [lycandomain] = useAtom(lycanURLAtom); 35 + const lycanExists = lycandomain !== ""; 36 + const { data: lycanstatusdata, refetch } = useQueryLycanStatus(); 37 + const lycanIndexed = lycanstatusdata?.status === "finished" || false; 38 + const lycanIndexing = lycanstatusdata?.status === "in_progress" || false; 39 + const lycanIndexingProgress = lycanIndexing 40 + ? lycanstatusdata?.progress 41 + : undefined; 42 + 43 + const authed = status === "signedIn"; 44 + 45 + const lycanReady = lycanExists && lycanIndexed && authed; 46 + 47 + const { q }: { q: string } = useSearch({ from: "/search" }); 48 + 49 + // auto-refetch Lycan status until ready 50 + useEffect(() => { 51 + if (!lycanExists || !authed) return; 52 + if (lycanReady) return; 53 + 54 + const interval = setInterval(() => { 55 + refetch(); 56 + }, 3000); 57 + 58 + return () => clearInterval(interval); 59 + }, [lycanExists, authed, lycanReady, refetch]); 60 + 61 + const maintext = !lycanExists 62 + ? "Sorry we dont have search. But instead, you can load some of these types of content into Red Dwarf:" 63 + : authed 64 + ? lycanReady 65 + ? "Lycan Search is enabled and ready! Type to search posts you've interacted with in the past. You can also load some of these types of content into Red Dwarf:" 66 + : "Sorry, while Lycan Search is enabled, you are not indexed. Index below please. You can load some of these types of content into Red Dwarf:" 67 + : "Sorry, while Lycan Search is enabled, you are unauthed. Please log in to use Lycan. You can load some of these types of content into Red Dwarf:"; 68 + 69 + async function index(opts: { 70 + agent?: Agent; 71 + isAuthed: boolean; 72 + pdsUrl?: string; 73 + feedServiceDid?: string; 74 + }) { 75 + renderSnack({ 76 + title: "Registering account...", 77 + }); 78 + try { 79 + const response = await queryClient.fetchQuery( 80 + constructLycanRequestIndexQuery(opts) 81 + ); 82 + if ( 83 + response?.message !== "Import has already started" && 84 + response?.message !== "Import has been scheduled" 85 + ) { 86 + renderSnack({ 87 + title: "Registration failed!", 88 + description: "Unknown server error (2)", 89 + }); 90 + } else { 91 + renderSnack({ 92 + title: "Succesfully sent registration request!", 93 + description: "Please wait for the server to index your account", 94 + }); 95 + refetch(); 96 + } 97 + } catch { 98 + renderSnack({ 99 + title: "Registration failed!", 100 + description: "Unknown server error (1)", 101 + }); 102 + } 103 + } 104 + 105 + return ( 106 + <> 107 + <Header 108 + title="Explore" 109 + backButtonCallback={() => { 110 + if (window.history.length > 1) { 111 + window.history.back(); 112 + } else { 113 + window.location.assign("/"); 114 + } 115 + }} 116 + /> 117 + <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 118 + <Import optionaltextstring={q} /> 119 + <div className="flex flex-col"> 120 + <p className="text-gray-600 dark:text-gray-400">{maintext}</p> 121 + <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 122 + <li> 123 + Bluesky URLs (from supported clients) (like{" "} 124 + <code className="text-sm">bsky.app</code> or{" "} 125 + <code className="text-sm">deer.social</code>). 126 + </li> 127 + <li> 128 + AT-URIs (e.g.,{" "} 129 + <code className="text-sm">at://did:example/collection/item</code> 130 + ). 131 + </li> 132 + <li> 133 + User Handles (like{" "} 134 + <code className="text-sm">@username.bsky.social</code>). 135 + </li> 136 + <li> 137 + DIDs (Decentralized Identifiers, starting with{" "} 138 + <code className="text-sm">did:</code>). 139 + </li> 140 + </ul> 141 + <p className="mt-2 text-gray-600 dark:text-gray-400"> 142 + Simply paste one of these into the import field above and press 143 + Enter to load the content. 144 + </p> 145 + 146 + {lycanExists && authed && !lycanReady ? ( 147 + !lycanIndexing ? ( 148 + <div className="mt-4 mx-auto"> 149 + <button 150 + onClick={() => 151 + index({ 152 + agent: agent || undefined, 153 + isAuthed: status === "signedIn", 154 + pdsUrl: identity?.pds, 155 + feedServiceDid: "did:web:" + lycandomain, 156 + }) 157 + } 158 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 159 + text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 160 + > 161 + Index my Account 162 + </button> 163 + </div> 164 + ) : ( 165 + <div className="mt-4 gap-2 flex flex-col"> 166 + <span>indexing...</span> 167 + <SliderPrimitive 168 + value={lycanIndexingProgress || 0} 169 + min={0} 170 + max={1} 171 + /> 172 + </div> 173 + ) 174 + ) : ( 175 + <></> 176 + )} 177 + </div> 178 + </div> 179 + {q ? <SearchTabs query={q} /> : <></>} 180 + </> 181 + ); 182 + } 183 + 184 + function SearchTabs({ query }: { query: string }) { 185 + return ( 186 + <div> 187 + <ReusableTabRoute 188 + route={`search` + query} 189 + tabs={{ 190 + Likes: <LycanTab query={query} type={"likes"} key={"likes"} />, 191 + Reposts: <LycanTab query={query} type={"reposts"} key={"reposts"} />, 192 + Quotes: <LycanTab query={query} type={"quotes"} key={"quotes"} />, 193 + Pins: <LycanTab query={query} type={"pins"} key={"pins"} />, 194 + }} 195 + /> 196 + </div> 197 + ); 198 + } 199 + 200 + function LycanTab({ 201 + query, 202 + type, 203 + }: { 204 + query: string; 205 + type: "likes" | "pins" | "reposts" | "quotes"; 206 + }) { 207 + useReusableTabScrollRestore("search" + query); 208 + 209 + const { 210 + data: postsData, 211 + fetchNextPage, 212 + hasNextPage, 213 + isFetchingNextPage, 214 + isLoading: arePostsLoading, 215 + } = useInfiniteQueryLycanSearch({ query: query, type: type }); 216 + 217 + const posts = useMemo( 218 + () => 219 + postsData?.pages.flatMap((page) => { 220 + if (page) { 221 + return page.posts; 222 + } else { 223 + return []; 224 + } 225 + }) ?? [], 226 + [postsData] 227 + ); 228 + 229 + return ( 230 + <> 231 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 232 + Posts 233 + </div> */} 234 + <div> 235 + {posts.map((post) => ( 236 + <UniversalPostRendererATURILoader 237 + key={post} 238 + atUri={post} 239 + feedviewpost={true} 240 + /> 241 + ))} 242 + </div> 243 + 244 + {/* Loading and "Load More" states */} 245 + {arePostsLoading && posts.length === 0 && ( 246 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 247 + )} 248 + {isFetchingNextPage && ( 249 + <div className="p-4 text-center text-gray-500">Loading more...</div> 250 + )} 251 + {hasNextPage && !isFetchingNextPage && ( 252 + <button 253 + onClick={() => fetchNextPage()} 254 + 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" 255 + > 256 + Load More Posts 257 + </button> 258 + )} 259 + {posts.length === 0 && !arePostsLoading && ( 260 + <div className="p-4 text-center text-gray-500">No posts found.</div> 261 + )} 262 + </> 263 + ); 264 + 265 + return <></>; 9 266 }
+336 -2
src/routes/settings.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 1 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 + import { useAtom, useAtomValue, useSetAtom } from "jotai"; 3 + import { Slider, Switch } from "radix-ui"; 4 + import { useEffect, useState } from "react"; 2 5 3 6 import { Header } from "~/components/Header"; 4 7 import Login from "~/components/Login"; 8 + import { 9 + constellationURLAtom, 10 + defaultconstellationURL, 11 + defaulthue, 12 + defaultImgCDN, 13 + defaultLycanURL, 14 + defaultslingshotURL, 15 + defaultVideoCDN, 16 + enableBitesAtom, 17 + enableBridgyTextAtom, 18 + enableWafrnTextAtom, 19 + hueAtom, 20 + imgCDNAtom, 21 + lycanURLAtom, 22 + slingshotURLAtom, 23 + videoCDNAtom, 24 + } from "~/utils/atoms"; 25 + 26 + import { MaterialNavItem } from "./__root"; 5 27 6 28 export const Route = createFileRoute("/settings")({ 7 29 component: Settings, 8 30 }); 9 31 10 32 export function Settings() { 33 + const navigate = useNavigate(); 11 34 return ( 12 35 <> 13 36 <Header ··· 20 43 } 21 44 }} 22 45 /> 23 - <Login /> 46 + <div className="lg:hidden"> 47 + <Login /> 48 + </div> 49 + <div className="sm:hidden flex flex-col justify-around mt-4"> 50 + <SettingHeading title="Other Pages" top /> 51 + <MaterialNavItem 52 + InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />} 53 + ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />} 54 + active={false} 55 + onClickCallbback={() => 56 + navigate({ 57 + to: "/feeds", 58 + //params: { did: agent.assertDid }, 59 + }) 60 + } 61 + text="Feeds" 62 + /> 63 + <MaterialNavItem 64 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 65 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 66 + active={false} 67 + onClickCallbback={() => 68 + navigate({ 69 + to: "/moderation", 70 + //params: { did: agent.assertDid }, 71 + }) 72 + } 73 + text="Moderation" 74 + /> 75 + </div> 76 + <div className="h-4" /> 77 + 78 + <SettingHeading title="Personalization" top /> 79 + <Hue /> 80 + 81 + <SettingHeading title="Network Configuration" /> 82 + <div className="flex flex-col px-4 pb-2"> 83 + <span className="text-md">Service Endpoints</span> 84 + <span className="text-sm text-gray-500 dark:text-gray-400"> 85 + Customize the servers to be used by the app 86 + </span> 87 + </div> 88 + <TextInputSetting 89 + atom={constellationURLAtom} 90 + title={"Constellation"} 91 + description={ 92 + "Customize the Constellation instance to be used by Red Dwarf" 93 + } 94 + init={defaultconstellationURL} 95 + /> 96 + <TextInputSetting 97 + atom={slingshotURLAtom} 98 + title={"Slingshot"} 99 + description={"Customize the Slingshot instance to be used by Red Dwarf"} 100 + init={defaultslingshotURL} 101 + /> 102 + <TextInputSetting 103 + atom={imgCDNAtom} 104 + title={"Image CDN"} 105 + description={ 106 + "Customize the Constellation instance to be used by Red Dwarf" 107 + } 108 + init={defaultImgCDN} 109 + /> 110 + <TextInputSetting 111 + atom={videoCDNAtom} 112 + title={"Video CDN"} 113 + description={"Customize the Slingshot instance to be used by Red Dwarf"} 114 + init={defaultVideoCDN} 115 + /> 116 + <TextInputSetting 117 + atom={lycanURLAtom} 118 + title={"Lycan Search"} 119 + description={"Enable text search across posts you've interacted with"} 120 + init={defaultLycanURL} 121 + /> 122 + 123 + <SettingHeading title="Experimental" /> 124 + <SwitchSetting 125 + atom={enableBitesAtom} 126 + title={"Bites"} 127 + description={"Enable Wafrn Bites to bite and be bitten by other people"} 128 + //init={false} 129 + /> 130 + <div className="h-4" /> 131 + <SwitchSetting 132 + atom={enableBridgyTextAtom} 133 + title={"Bridgy Text"} 134 + description={ 135 + "Show the original text of posts bridged from the Fediverse" 136 + } 137 + //init={false} 138 + /> 139 + <div className="h-4" /> 140 + <SwitchSetting 141 + atom={enableWafrnTextAtom} 142 + title={"Wafrn Text"} 143 + description={"Show the original text of posts from Wafrn instances"} 144 + //init={false} 145 + /> 146 + <p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4"> 147 + Notice: Please restart/refresh the app if changes arent applying 148 + correctly 149 + </p> 24 150 </> 25 151 ); 26 152 } 153 + 154 + export function SettingHeading({ 155 + title, 156 + top, 157 + }: { 158 + title: string; 159 + top?: boolean; 160 + }) { 161 + return ( 162 + <div 163 + className="px-4" 164 + style={{ marginTop: top ? 0 : 18, paddingBottom: 12 }} 165 + > 166 + <span className=" text-sm font-medium text-gray-500 dark:text-gray-400"> 167 + {title} 168 + </span> 169 + </div> 170 + ); 171 + } 172 + 173 + export function SwitchSetting({ 174 + atom, 175 + title, 176 + description, 177 + }: { 178 + atom: typeof enableBitesAtom; 179 + title?: string; 180 + description?: string; 181 + }) { 182 + const value = useAtomValue(atom); 183 + const setValue = useSetAtom(atom); 184 + 185 + const [hydrated, setHydrated] = useState(false); 186 + // eslint-disable-next-line react-hooks/set-state-in-effect 187 + useEffect(() => setHydrated(true), []); 188 + 189 + if (!hydrated) { 190 + // Avoid rendering Switch until we know storage is loaded 191 + return null; 192 + } 193 + 194 + return ( 195 + <div className="flex items-center gap-4 px-4 "> 196 + <label htmlFor={`switch-${title}`} className="flex flex-row flex-1"> 197 + <div className="flex flex-col"> 198 + <span className="text-md">{title}</span> 199 + <span className="text-sm text-gray-500 dark:text-gray-400"> 200 + {description} 201 + </span> 202 + </div> 203 + </label> 204 + 205 + <Switch.Root 206 + id={`switch-${title}`} 207 + checked={value} 208 + onCheckedChange={(v) => setValue(v)} 209 + className="m3switch root" 210 + > 211 + <Switch.Thumb className="m3switch thumb " /> 212 + </Switch.Root> 213 + </div> 214 + ); 215 + } 216 + 217 + function Hue() { 218 + const [hue, setHue] = useAtom(hueAtom); 219 + return ( 220 + <div className="flex flex-col px-4"> 221 + <span className="z-[2] text-md">Hue</span> 222 + <span className="z-[2] text-sm text-gray-500 dark:text-gray-400"> 223 + Change the colors of the app 224 + </span> 225 + <div className="z-[1] flex flex-row items-center gap-4"> 226 + <SliderComponent atom={hueAtom} max={360} /> 227 + <button 228 + onClick={() => setHue(defaulthue ?? 28)} 229 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 230 + text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 231 + > 232 + Reset 233 + </button> 234 + </div> 235 + </div> 236 + ); 237 + } 238 + 239 + export function TextInputSetting({ 240 + atom, 241 + title, 242 + description, 243 + init, 244 + }: { 245 + atom: typeof constellationURLAtom; 246 + title?: string; 247 + description?: string; 248 + init?: string; 249 + }) { 250 + const [value, setValue] = useAtom(atom); 251 + return ( 252 + <div className="flex flex-col gap-2 px-4 py-2"> 253 + {/* <div> 254 + {title && ( 255 + <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100"> 256 + {title} 257 + </h3> 258 + )} 259 + {description && ( 260 + <p className="text-sm text-gray-500 dark:text-gray-400"> 261 + {description} 262 + </p> 263 + )} 264 + </div> */} 265 + 266 + <div className="flex flex-row gap-2 items-center"> 267 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 268 + <input 269 + type="text" 270 + placeholder=" " 271 + value={value} 272 + onChange={(e) => setValue(e.target.value)} 273 + /> 274 + <label>{title}</label> 275 + </div> 276 + {/* <input 277 + type="text" 278 + value={value} 279 + onChange={(e) => setValue(e.target.value)} 280 + className="flex-1 px-3 py-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 281 + text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400 282 + focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600" 283 + placeholder="Enter value..." 284 + /> */} 285 + <button 286 + onClick={() => setValue(init ?? "")} 287 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 288 + text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 289 + > 290 + Reset 291 + </button> 292 + </div> 293 + </div> 294 + ); 295 + } 296 + 297 + interface SliderProps { 298 + atom: typeof hueAtom; 299 + min?: number; 300 + max?: number; 301 + step?: number; 302 + } 303 + 304 + export const SliderComponent: React.FC<SliderProps> = ({ 305 + atom, 306 + min = 0, 307 + max = 100, 308 + step = 1, 309 + }) => { 310 + const [value, setValue] = useAtom(atom); 311 + 312 + return ( 313 + <Slider.Root 314 + className="relative flex items-center w-full h-4" 315 + value={[value]} 316 + min={min} 317 + max={max} 318 + step={step} 319 + onValueChange={(v: number[]) => setValue(v[0])} 320 + > 321 + <Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full"> 322 + <Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" /> 323 + </Slider.Track> 324 + <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" /> 325 + </Slider.Root> 326 + ); 327 + }; 328 + 329 + 330 + interface SliderPProps { 331 + value: number; 332 + min?: number; 333 + max?: number; 334 + step?: number; 335 + } 336 + 337 + 338 + export const SliderPrimitive: React.FC<SliderPProps> = ({ 339 + value, 340 + min = 0, 341 + max = 100, 342 + step = 1, 343 + }) => { 344 + 345 + return ( 346 + <Slider.Root 347 + className="relative flex items-center w-full h-4" 348 + value={[value]} 349 + min={min} 350 + max={max} 351 + step={step} 352 + onValueChange={(v: number[]) => {}} 353 + > 354 + <Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full"> 355 + <Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" /> 356 + </Slider.Track> 357 + <Slider.Thumb className=" hidden 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" /> 358 + </Slider.Root> 359 + ); 360 + };
+283 -14
src/styles/app.css
··· 15 15 --color-gray-950: oklch(0.129 0.050 222.000); 16 16 } */ 17 17 18 + :root { 19 + --safe-hue: var(--tw-gray-hue, 28) 20 + } 21 + 18 22 @theme { 19 - --color-gray-50: oklch(0.984 0.012 28); 20 - --color-gray-100: oklch(0.968 0.017 28); 21 - --color-gray-200: oklch(0.929 0.025 28); 22 - --color-gray-300: oklch(0.869 0.035 28); 23 - --color-gray-400: oklch(0.704 0.05 28); 24 - --color-gray-500: oklch(0.554 0.06 28); 25 - --color-gray-600: oklch(0.446 0.058 28); 26 - --color-gray-700: oklch(0.372 0.058 28); 27 - --color-gray-800: oklch(0.279 0.055 28); 28 - --color-gray-900: oklch(0.208 0.055 28); 29 - --color-gray-950: oklch(0.129 0.055 28); 23 + --color-gray-50: oklch(0.984 0.012 var(--safe-hue)); 24 + --color-gray-100: oklch(0.968 0.017 var(--safe-hue)); 25 + --color-gray-200: oklch(0.929 0.025 var(--safe-hue)); 26 + --color-gray-300: oklch(0.869 0.035 var(--safe-hue)); 27 + --color-gray-400: oklch(0.704 0.05 var(--safe-hue)); 28 + --color-gray-500: oklch(0.554 0.06 var(--safe-hue)); 29 + --color-gray-600: oklch(0.446 0.058 var(--safe-hue)); 30 + --color-gray-700: oklch(0.372 0.058 var(--safe-hue)); 31 + --color-gray-800: oklch(0.279 0.055 var(--safe-hue)); 32 + --color-gray-900: oklch(0.208 0.055 var(--safe-hue)); 33 + --color-gray-950: oklch(0.129 0.055 var(--safe-hue)); 34 + } 35 + 36 + :root { 37 + --link-text-color: oklch(0.5962 0.1987 var(--safe-hue)); 38 + /* max chroma!!! use fallback*/ 39 + /*--link-text-color: oklch(0.6 0.37 var(--safe-hue));*/ 30 40 } 31 41 32 42 @layer base { ··· 48 58 } 49 59 } 50 60 61 + .gutter{ 62 + scrollbar-gutter: stable both-edges; 63 + } 64 + 51 65 @media (width >= 64rem /* 1024px */) { 52 66 html:not(:has(.disablegutter)), 53 67 body:not(:has(.disablegutter)) { 54 68 scrollbar-gutter: stable both-edges !important; 55 69 } 56 - html:has(.disablegutter), 57 - body:has(.disablegutter) { 70 + html:has(.disablescroll), 71 + body:has(.disablescroll) { 58 72 scrollbar-width: none; 59 73 overflow-y: hidden; 60 74 } ··· 76 90 .dangerousFediContent { 77 91 & a[href]{ 78 92 text-decoration: none; 79 - color: rgb(29, 122, 242); 93 + color: var(--link-text-color); 80 94 word-break: break-all; 81 95 } 82 96 } ··· 105 119 :root { 106 120 --shadow-opacity: calc(1 - var(--is-top)); 107 121 --tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15)); 122 + } 123 + 124 + 125 + /* m3 input */ 126 + :root { 127 + --m3input-radius: 6px; 128 + --m3input-border-width: .0625rem; 129 + --m3input-font-size: 16px; 130 + --m3input-transition: 150ms cubic-bezier(.2, .8, .2, 1); 131 + /* light theme */ 132 + --m3input-bg: var(--color-gray-50); 133 + --m3input-border-color: var(--color-gray-400); 134 + --m3input-label-color: var(--color-gray-500); 135 + --m3input-text-color: var(--color-gray-900); 136 + --m3input-focus-color: var(--color-gray-600); 137 + } 138 + 139 + @media (prefers-color-scheme: dark) { 140 + :root { 141 + --m3input-bg: var(--color-gray-950); 142 + --m3input-border-color: var(--color-gray-700); 143 + --m3input-label-color: var(--color-gray-400); 144 + --m3input-text-color: var(--color-gray-50); 145 + --m3input-focus-color: var(--color-gray-400); 146 + } 147 + } 148 + 149 + /* reset page *//* 150 + html, 151 + body { 152 + background: var(--m3input-bg); 153 + margin: 0; 154 + padding: 1rem; 155 + color: var(--m3input-text-color); 156 + font-family: system-ui, sans-serif; 157 + font-size: var(--m3input-font-size); 158 + }*/ 159 + 160 + /* base wrapper */ 161 + .m3input-field.m3input-label.m3input-border { 162 + position: relative; 163 + display: inline-block; 164 + width: 100%; 165 + /*max-width: 400px;*/ 166 + } 167 + 168 + /* size variants */ 169 + .m3input-field.size-sm { 170 + --m3input-h: 40px; 171 + } 172 + 173 + .m3input-field.size-md { 174 + --m3input-h: 48px; 175 + } 176 + 177 + .m3input-field.size-lg { 178 + --m3input-h: 56px; 179 + } 180 + 181 + .m3input-field.size-xl { 182 + --m3input-h: 64px; 183 + } 184 + 185 + .m3input-field.m3input-label.m3input-border:not(.size-sm):not(.size-md):not(.size-lg):not(.size-xl) { 186 + --m3input-h: 48px; 187 + } 188 + 189 + /* outlined input */ 190 + .m3input-field.m3input-label.m3input-border input { 191 + width: 100%; 192 + height: var(--m3input-h); 193 + border: var(--m3input-border-width) solid var(--m3input-border-color); 194 + border-radius: var(--m3input-radius); 195 + background: var(--m3input-bg); 196 + color: var(--m3input-text-color); 197 + font-size: var(--m3input-font-size); 198 + padding: 0 12px; 199 + box-sizing: border-box; 200 + outline: none; 201 + transition: border-color var(--m3input-transition), box-shadow var(--m3input-transition); 202 + } 203 + 204 + /* focus ring */ 205 + .m3input-field.m3input-label.m3input-border input:focus { 206 + /*border-color: var(--m3input-focus-color);*/ 207 + border-color: var(--m3input-focus-color); 208 + box-shadow: 0 0 0 1px var(--m3input-focus-color); 209 + /*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/ 210 + } 211 + 212 + /* label */ 213 + .m3input-field.m3input-label.m3input-border label { 214 + position: absolute; 215 + left: 12px; 216 + top: 50%; 217 + transform: translateY(-50%); 218 + background: var(--m3input-bg); 219 + padding: 0 .25em; 220 + color: var(--m3input-label-color); 221 + pointer-events: none; 222 + transition: all var(--m3input-transition); 223 + } 224 + 225 + /* float on focus or when filled */ 226 + .m3input-field.m3input-label.m3input-border input:focus+label, 227 + .m3input-field.m3input-label.m3input-border input:not(:placeholder-shown)+label { 228 + top: 0; 229 + transform: translateY(-50%) scale(.78); 230 + left: 0; 231 + color: var(--m3input-focus-color); 232 + } 233 + 234 + /* placeholder trick */ 235 + .m3input-field.m3input-label.m3input-border input::placeholder { 236 + color: transparent; 237 + } 238 + 239 + /* radix i love you but like cmon man */ 240 + body[data-scroll-locked]{ 241 + margin-left: var(--removed-body-scroll-bar-size) !important; 242 + } 243 + 244 + /* radix tabs */ 245 + 246 + .m3tab[data-radix-collection-item] { 247 + flex: 1; 248 + display: flex; 249 + padding: 12px 8px; 250 + align-items: center; 251 + justify-content: center; 252 + color: var(--color-gray-500); 253 + font-weight: 500; 254 + &:hover { 255 + background-color: var(--color-gray-100); 256 + cursor: pointer; 257 + } 258 + &[aria-selected="true"] { 259 + color: var(--color-gray-950); 260 + &::before{ 261 + content: ""; 262 + position: absolute; 263 + width: min(80px, 80%); 264 + border-radius: 99px 99px 0px 0px ; 265 + height: 3px; 266 + bottom: 0; 267 + background-color: var(--color-gray-400); 268 + } 269 + } 270 + } 271 + 272 + @media (prefers-color-scheme: dark) { 273 + .m3tab[data-radix-collection-item] { 274 + color: var(--color-gray-400); 275 + &:hover { 276 + background-color: var(--color-gray-900); 277 + cursor: pointer; 278 + } 279 + &[aria-selected="true"] { 280 + color: var(--color-gray-50); 281 + &::before{ 282 + background-color: var(--color-gray-500); 283 + } 284 + } 285 + } 286 + } 287 + 288 + :root{ 289 + --thumb-size: 2rem; 290 + --root-size: 3.25rem; 291 + 292 + --switch-off-border: var(--color-gray-400); 293 + --switch-off-bg: var(--color-gray-200); 294 + --switch-off-thumb: var(--color-gray-400); 295 + 296 + 297 + --switch-on-bg: var(--color-gray-500); 298 + --switch-on-thumb: var(--color-gray-50); 299 + 300 + } 301 + @media (prefers-color-scheme: dark) { 302 + :root { 303 + --switch-off-border: var(--color-gray-500); 304 + --switch-off-bg: var(--color-gray-800); 305 + --switch-off-thumb: var(--color-gray-500); 306 + 307 + 308 + --switch-on-bg: var(--color-gray-400); 309 + --switch-on-thumb: var(--color-gray-700); 310 + } 311 + } 312 + 313 + .m3switch.root{ 314 + /*w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-gray-500 transition-colors*/ 315 + /*width: 40px; 316 + height: 24px;*/ 317 + 318 + inline-size: var(--root-size); 319 + block-size: 2rem; 320 + border-radius: 99999px; 321 + 322 + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; 323 + transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */ 324 + transition-duration: var(--default-transition-duration); /* 150ms */ 325 + 326 + .m3switch.thumb{ 327 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 328 + 329 + height: var(--thumb-size); 330 + width: var(--thumb-size); 331 + display: inline-block; 332 + border-radius: 9999px; 333 + 334 + transform-origin: center; 335 + 336 + transition-property: transform, translate, scale, rotate; 337 + transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */ 338 + transition-duration: var(--default-transition-duration); /* 150ms */ 339 + 340 + } 341 + 342 + &[aria-checked="true"] { 343 + box-shadow: inset 0px 0px 0px 1.8px transparent; 344 + background-color: var(--switch-on-bg); 345 + 346 + .m3switch.thumb{ 347 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 348 + 349 + background-color: var(--switch-on-thumb); 350 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.72); 351 + &:active { 352 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88); 353 + } 354 + 355 + } 356 + &:active .m3switch.thumb{ 357 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88); 358 + } 359 + } 360 + 361 + &[aria-checked="false"] { 362 + box-shadow: inset 0px 0px 0px 1.8px var(--switch-off-border); 363 + background-color: var(--switch-off-bg); 364 + .m3switch.thumb{ 365 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 366 + 367 + background-color: var(--switch-off-thumb); 368 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.5); 369 + &:active { 370 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88); 371 + } 372 + } 373 + &:active .m3switch.thumb{ 374 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88); 375 + } 376 + } 108 377 }
+136 -8
src/utils/atoms.ts
··· 1 - import type Agent from "@atproto/api"; 2 - import { atom, createStore } from "jotai"; 3 - import { atomWithStorage } from 'jotai/utils'; 1 + import { atom, createStore, useAtomValue } from "jotai"; 2 + import { atomWithStorage } from "jotai/utils"; 3 + import { useEffect } from "react"; 4 + 5 + import { type ProfilePostsFilter } from "~/routes/profile.$did"; 4 6 5 7 export const store = createStore(); 6 8 9 + export const quickAuthAtom = atomWithStorage<string | null>( 10 + "quickAuth", 11 + null 12 + ); 13 + 7 14 export const selectedFeedUriAtom = atomWithStorage<string | null>( 8 - 'selectedFeedUri', 15 + "selectedFeedUri", 9 16 null 10 17 ); 11 18 12 19 //export const feedScrollPositionsAtom = atom<Record<string, number>>({}); 13 20 14 21 export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>( 15 - 'feedscrollpositions', 22 + "feedscrollpositions", 16 23 {} 17 24 ); 18 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 + 19 59 export const likedPostsAtom = atomWithStorage<Record<string, string>>( 20 - 'likedPosts', 60 + "likedPosts", 21 61 {} 22 62 ); 23 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>>({}) 76 + 77 + export const defaultconstellationURL = "constellation.microcosm.blue"; 78 + export const constellationURLAtom = atomWithStorage<string>( 79 + "constellationURL", 80 + defaultconstellationURL 81 + ); 82 + export const defaultslingshotURL = "slingshot.microcosm.blue"; 83 + export const slingshotURLAtom = atomWithStorage<string>( 84 + "slingshotURL", 85 + defaultslingshotURL 86 + ); 87 + export const defaultImgCDN = "cdn.bsky.app"; 88 + export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN); 89 + export const defaultVideoCDN = "video.bsky.app"; 90 + export const videoCDNAtom = atomWithStorage<string>( 91 + "videocdnurl", 92 + defaultVideoCDN 93 + ); 94 + 95 + export const defaultLycanURL = ""; 96 + export const lycanURLAtom = atomWithStorage<string>( 97 + "lycanURL", 98 + defaultLycanURL 99 + ); 100 + 101 + export const defaulthue = 28; 102 + export const hueAtom = atomWithStorage<number>("hue", defaulthue); 103 + 24 104 export const isAtTopAtom = atom<boolean>(true); 25 105 26 - export const agentAtom = atom<Agent|null>(null); 27 - export const authedAtom = atom<boolean>(false); 106 + type ComposerState = 107 + | { kind: "closed" } 108 + | { kind: "root" } 109 + | { kind: "reply"; parent: string } 110 + | { kind: "quote"; subject: string }; 111 + export const composerAtom = atom<ComposerState>({ kind: "closed" }); 112 + 113 + //export const agentAtom = atom<Agent | null>(null); 114 + //export const authedAtom = atom<boolean>(false); 115 + 116 + export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) { 117 + const value = useAtomValue(atom); 118 + 119 + useEffect(() => { 120 + document.documentElement.style.setProperty(cssVar, value.toString()); 121 + }, [value, cssVar]); 122 + 123 + useEffect(() => { 124 + document.documentElement.style.setProperty(cssVar, value.toString()); 125 + }, []); 126 + } 127 + 128 + hueAtom.onMount = (setAtom) => { 129 + const stored = localStorage.getItem("hue"); 130 + if (stored != null) setAtom(Number(stored)); 131 + }; 132 + // export function initAtomToCssVar(atom: typeof hueAtom, cssVar: string) { 133 + // const initial = store.get(atom); 134 + // console.log("atom get ", initial); 135 + // document.documentElement.style.setProperty(cssVar, initial.toString()); 136 + // } 137 + 138 + 139 + 140 + // fun stuff 141 + 142 + export const enableBitesAtom = atomWithStorage<boolean>( 143 + "enableBitesAtom", 144 + false 145 + ); 146 + 147 + export const enableBridgyTextAtom = atomWithStorage<boolean>( 148 + "enableBridgyTextAtom", 149 + false 150 + ); 151 + 152 + export const enableWafrnTextAtom = atomWithStorage<boolean>( 153 + "enableWafrnTextAtom", 154 + false 155 + );
+37 -3
src/utils/followState.ts
··· 1 - import { AtUri, type Agent } from "@atproto/api"; 2 - import { useQueryConstellation, type linksRecordsResponse } from "./useQuery"; 1 + import { type Agent,AtUri } from "@atproto/api"; 2 + import { TID } from "@atproto/common-web"; 3 3 import type { QueryClient } from "@tanstack/react-query"; 4 - import { TID } from "@atproto/common-web"; 4 + 5 + import { type linksRecordsResponse,useQueryConstellation } from "./useQuery"; 5 6 6 7 export function useGetFollowState({ 7 8 target, ··· 127 128 }; 128 129 }); 129 130 } 131 + 132 + 133 + 134 + export function useGetOneToOneState(params?: { 135 + target: string; 136 + user: string; 137 + collection: string; 138 + path: string; 139 + }): string[] | undefined { 140 + const { data: arbitrarydata } = useQueryConstellation( 141 + params && params.user 142 + ? { 143 + method: "/links", 144 + target: params.target, 145 + // @ts-expect-error overloading sucks so much 146 + collection: params.collection, 147 + path: params.path, 148 + dids: [params.user], 149 + } 150 + : { method: "undefined", target: "whatever" } 151 + // overloading sucks so much 152 + ) as { data: linksRecordsResponse | undefined }; 153 + if (!params || !params.user) return undefined; 154 + const data = arbitrarydata?.linking_records.slice(0, 50) ?? []; 155 + 156 + if (data.length > 0) { 157 + return data.map((linksRecord) => { 158 + return `at://${linksRecord.did}/${linksRecord.collection}/${linksRecord.rkey}`; 159 + }); 160 + } 161 + 162 + return undefined; 163 + }
+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
+53 -23
src/utils/useHydrated.ts
··· 9 9 AppBskyFeedPost, 10 10 AtUri, 11 11 } from "@atproto/api"; 12 + import { useAtom } from "jotai"; 12 13 import { useMemo } from "react"; 13 14 14 - import { useQueryIdentity,useQueryPost, useQueryProfile } from "./useQuery"; 15 + import { imgCDNAtom, videoCDNAtom } from "./atoms"; 16 + import { useQueryIdentity, useQueryPost, useQueryProfile } from "./useQuery"; 15 17 16 - type QueryResultData<T extends (...args: any) => any> = ReturnType<T> extends 17 - | { data: infer D } 18 - | undefined 19 - ? D 20 - : never; 18 + type QueryResultData<T extends (...args: any) => any> = 19 + ReturnType<T> extends { data: infer D } | undefined ? D : never; 21 20 22 21 function asTyped<T extends { $type: string }>(obj: T): $Typed<T> { 23 22 return obj as $Typed<T>; ··· 26 25 export function hydrateEmbedImages( 27 26 embed: AppBskyEmbedImages.Main, 28 27 did: string, 28 + cdn: string 29 29 ): $Typed<AppBskyEmbedImages.View> { 30 30 return asTyped({ 31 31 $type: "app.bsky.embed.images#view" as const, ··· 34 34 const link = img.image.ref?.["$link"]; 35 35 if (!link) return null; 36 36 return { 37 - thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${link}@jpeg`, 38 - fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${link}@jpeg`, 37 + thumb: `https://${cdn}/img/feed_thumbnail/plain/${did}/${link}@jpeg`, 38 + fullsize: `https://${cdn}/img/feed_fullsize/plain/${did}/${link}@jpeg`, 39 39 alt: img.alt || "", 40 40 aspectRatio: img.aspectRatio, 41 41 }; ··· 47 47 export function hydrateEmbedExternal( 48 48 embed: AppBskyEmbedExternal.Main, 49 49 did: string, 50 + cdn: string 50 51 ): $Typed<AppBskyEmbedExternal.View> { 51 52 return asTyped({ 52 53 $type: "app.bsky.embed.external#view" as const, ··· 55 56 title: embed.external.title, 56 57 description: embed.external.description, 57 58 thumb: embed.external.thumb?.ref?.$link 58 - ? `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg` 59 + ? `https://${cdn}/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg` 59 60 : undefined, 60 61 }, 61 62 }); ··· 64 65 export function hydrateEmbedVideo( 65 66 embed: AppBskyEmbedVideo.Main, 66 67 did: string, 68 + videocdn: string 67 69 ): $Typed<AppBskyEmbedVideo.View> { 68 70 const videoLink = embed.video.ref.$link; 69 71 return asTyped({ 70 72 $type: "app.bsky.embed.video#view" as const, 71 - playlist: `https://video.bsky.app/watch/${did}/${videoLink}/playlist.m3u8`, 72 - thumbnail: `https://video.bsky.app/watch/${did}/${videoLink}/thumbnail.jpg`, 73 + playlist: `https://${videocdn}/watch/${did}/${videoLink}/playlist.m3u8`, 74 + thumbnail: `https://${videocdn}/watch/${did}/${videoLink}/thumbnail.jpg`, 73 75 aspectRatio: embed.aspectRatio, 74 76 cid: videoLink, 75 77 }); ··· 80 82 quotedPost: QueryResultData<typeof useQueryPost>, 81 83 quotedProfile: QueryResultData<typeof useQueryProfile>, 82 84 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 85 + cdn: string 83 86 ): $Typed<AppBskyEmbedRecord.View> | undefined { 84 87 if (!quotedPost || !quotedProfile || !quotedIdentity) { 85 88 return undefined; ··· 91 94 handle: quotedIdentity.handle, 92 95 displayName: quotedProfile.value.displayName ?? quotedIdentity.handle, 93 96 avatar: quotedProfile.value.avatar?.ref?.$link 94 - ? `https://cdn.bsky.app/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg` 97 + ? `https://${cdn}/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg` 95 98 : undefined, 96 99 viewer: {}, 97 100 labels: [], ··· 122 125 quotedPost: QueryResultData<typeof useQueryPost>, 123 126 quotedProfile: QueryResultData<typeof useQueryProfile>, 124 127 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 128 + cdn: string 125 129 ): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined { 126 130 const hydratedRecord = hydrateEmbedRecord( 127 131 embed.record, 128 132 quotedPost, 129 133 quotedProfile, 130 134 quotedIdentity, 135 + cdn 131 136 ); 132 137 133 138 if (!hydratedRecord) return undefined; ··· 148 153 149 154 export function useHydratedEmbed( 150 155 embed: AppBskyFeedPost.Record["embed"], 151 - postAuthorDid: string | undefined, 156 + postAuthorDid: string | undefined 152 157 ) { 153 158 const recordInfo = useMemo(() => { 154 159 if (AppBskyEmbedRecordWithMedia.isMain(embed)) { ··· 181 186 error: profileError, 182 187 } = useQueryProfile(profileUri); 183 188 189 + const [imgcdn] = useAtom(imgCDNAtom); 190 + const [videocdn] = useAtom(videoCDNAtom); 191 + 184 192 const queryidentityresult = useQueryIdentity(quotedAuthorDid); 185 193 186 194 const hydratedEmbed: HydratedEmbedView | undefined = (() => { 187 195 if (!embed || !postAuthorDid) return undefined; 188 196 189 - if (isRecordType && (!usequerypostresults?.data || !quotedProfile || !queryidentityresult?.data)) { 197 + if ( 198 + isRecordType && 199 + (!usequerypostresults?.data || 200 + !quotedProfile || 201 + !queryidentityresult?.data) 202 + ) { 190 203 return undefined; 191 204 } 192 205 193 206 try { 194 207 if (AppBskyEmbedImages.isMain(embed)) { 195 - return hydrateEmbedImages(embed, postAuthorDid); 208 + return hydrateEmbedImages(embed, postAuthorDid, imgcdn); 196 209 } else if (AppBskyEmbedExternal.isMain(embed)) { 197 - return hydrateEmbedExternal(embed, postAuthorDid); 210 + return hydrateEmbedExternal(embed, postAuthorDid, imgcdn); 198 211 } else if (AppBskyEmbedVideo.isMain(embed)) { 199 - return hydrateEmbedVideo(embed, postAuthorDid); 212 + return hydrateEmbedVideo(embed, postAuthorDid, videocdn); 200 213 } else if (AppBskyEmbedRecord.isMain(embed)) { 201 214 return hydrateEmbedRecord( 202 215 embed, 203 216 usequerypostresults?.data, 204 217 quotedProfile, 205 218 queryidentityresult?.data, 219 + imgcdn 206 220 ); 207 221 } else if (AppBskyEmbedRecordWithMedia.isMain(embed)) { 208 222 let hydratedMedia: ··· 212 226 | undefined; 213 227 214 228 if (AppBskyEmbedImages.isMain(embed.media)) { 215 - hydratedMedia = hydrateEmbedImages(embed.media, postAuthorDid); 229 + hydratedMedia = hydrateEmbedImages( 230 + embed.media, 231 + postAuthorDid, 232 + imgcdn 233 + ); 216 234 } else if (AppBskyEmbedExternal.isMain(embed.media)) { 217 - hydratedMedia = hydrateEmbedExternal(embed.media, postAuthorDid); 235 + hydratedMedia = hydrateEmbedExternal( 236 + embed.media, 237 + postAuthorDid, 238 + imgcdn 239 + ); 218 240 } else if (AppBskyEmbedVideo.isMain(embed.media)) { 219 - hydratedMedia = hydrateEmbedVideo(embed.media, postAuthorDid); 241 + hydratedMedia = hydrateEmbedVideo( 242 + embed.media, 243 + postAuthorDid, 244 + videocdn 245 + ); 220 246 } 221 247 222 248 if (hydratedMedia) { ··· 226 252 usequerypostresults?.data, 227 253 quotedProfile, 228 254 queryidentityresult?.data, 255 + imgcdn 229 256 ); 230 257 } 231 258 } ··· 236 263 })(); 237 264 238 265 const isLoading = isRecordType 239 - ? usequerypostresults?.isLoading || isLoadingProfile || queryidentityresult?.isLoading 266 + ? usequerypostresults?.isLoading || 267 + isLoadingProfile || 268 + queryidentityresult?.isLoading 240 269 : false; 241 270 242 - const error = usequerypostresults?.error || profileError || queryidentityresult?.error; 271 + const error = 272 + usequerypostresults?.error || profileError || queryidentityresult?.error; 243 273 244 274 return { data: hydratedEmbed, isLoading, error }; 245 - } 275 + }
+430 -170
src/utils/useQuery.ts
··· 5 5 queryOptions, 6 6 useInfiniteQuery, 7 7 useQuery, 8 - type UseQueryResult} from "@tanstack/react-query"; 8 + type UseQueryResult, 9 + } from "@tanstack/react-query"; 10 + import { useAtom } from "jotai"; 9 11 10 - export function constructIdentityQuery(didorhandle?: string) { 12 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 13 + 14 + import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms"; 15 + 16 + export function constructIdentityQuery( 17 + didorhandle?: string, 18 + slingshoturl?: string 19 + ) { 11 20 return queryOptions({ 12 21 queryKey: ["identity", didorhandle], 13 22 queryFn: async () => { 14 - if (!didorhandle) return undefined as undefined 23 + if (!didorhandle) return undefined as undefined; 15 24 const res = await fetch( 16 - `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 25 + `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 17 26 ); 18 27 if (!res.ok) throw new Error("Failed to fetch post"); 19 28 try { ··· 28 37 } 29 38 }, 30 39 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 31 - gcTime: /*0//*/5 * 60 * 1000, 40 + gcTime: /*0//*/ 5 * 60 * 1000, 32 41 }); 33 42 } 34 43 export function useQueryIdentity(didorhandle: string): UseQueryResult< ··· 40 49 }, 41 50 Error 42 51 >; 43 - export function useQueryIdentity(): UseQueryResult< 44 - undefined, 45 - Error 46 - > 47 - export function useQueryIdentity(didorhandle?: string): 48 - UseQueryResult< 49 - { 50 - did: string; 51 - handle: string; 52 - pds: string; 53 - signing_key: string; 54 - } | undefined, 55 - Error 56 - > 52 + export function useQueryIdentity(): UseQueryResult<undefined, Error>; 53 + export function useQueryIdentity(didorhandle?: string): UseQueryResult< 54 + | { 55 + did: string; 56 + handle: string; 57 + pds: string; 58 + signing_key: string; 59 + } 60 + | undefined, 61 + Error 62 + >; 57 63 export function useQueryIdentity(didorhandle?: string) { 58 - return useQuery(constructIdentityQuery(didorhandle)); 64 + const [slingshoturl] = useAtom(slingshotURLAtom); 65 + return useQuery(constructIdentityQuery(didorhandle, slingshoturl)); 59 66 } 60 67 61 - export function constructPostQuery(uri?: string) { 68 + export function constructPostQuery(uri?: string, slingshoturl?: string) { 62 69 return queryOptions({ 63 70 queryKey: ["post", uri], 64 71 queryFn: async () => { 65 - if (!uri) return undefined as undefined 72 + if (!uri) return undefined as undefined; 66 73 const res = await fetch( 67 - `https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 74 + `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 68 75 ); 69 76 let data: any; 70 77 try { ··· 73 80 return undefined; 74 81 } 75 82 if (res.status === 400) return undefined; 76 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 83 + if ( 84 + data?.error === "InvalidRequest" && 85 + data.message?.includes("Could not find repo") 86 + ) { 77 87 return undefined; // cache โ€œnot foundโ€ 78 88 } 79 89 try { 80 90 if (!res.ok) throw new Error("Failed to fetch post"); 81 - return (data) as { 91 + return data as { 82 92 uri: string; 83 93 cid: string; 84 94 value: any; ··· 93 103 return failureCount < 2; 94 104 }, 95 105 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 96 - gcTime: /*0//*/5 * 60 * 1000, 106 + gcTime: /*0//*/ 5 * 60 * 1000, 97 107 }); 98 108 } 99 109 export function useQueryPost(uri: string): UseQueryResult< ··· 104 114 }, 105 115 Error 106 116 >; 107 - export function useQueryPost(): UseQueryResult< 108 - undefined, 109 - Error 110 - > 111 - export function useQueryPost(uri?: string): 112 - UseQueryResult< 113 - { 114 - uri: string; 115 - cid: string; 116 - value: ATPAPI.AppBskyFeedPost.Record; 117 - } | undefined, 118 - Error 119 - > 117 + export function useQueryPost(): UseQueryResult<undefined, Error>; 118 + export function useQueryPost(uri?: string): UseQueryResult< 119 + | { 120 + uri: string; 121 + cid: string; 122 + value: ATPAPI.AppBskyFeedPost.Record; 123 + } 124 + | undefined, 125 + Error 126 + >; 120 127 export function useQueryPost(uri?: string) { 121 - return useQuery(constructPostQuery(uri)); 128 + const [slingshoturl] = useAtom(slingshotURLAtom); 129 + return useQuery(constructPostQuery(uri, slingshoturl)); 122 130 } 123 131 124 - export function constructProfileQuery(uri?: string) { 132 + export function constructProfileQuery(uri?: string, slingshoturl?: string) { 125 133 return queryOptions({ 126 134 queryKey: ["profile", uri], 127 135 queryFn: async () => { 128 - if (!uri) return undefined as undefined 136 + if (!uri) return undefined as undefined; 129 137 const res = await fetch( 130 - `https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 138 + `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 131 139 ); 132 140 let data: any; 133 141 try { ··· 136 144 return undefined; 137 145 } 138 146 if (res.status === 400) return undefined; 139 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 147 + if ( 148 + data?.error === "InvalidRequest" && 149 + data.message?.includes("Could not find repo") 150 + ) { 140 151 return undefined; // cache โ€œnot foundโ€ 141 152 } 142 153 try { 143 154 if (!res.ok) throw new Error("Failed to fetch post"); 144 - return (data) as { 155 + return data as { 145 156 uri: string; 146 157 cid: string; 147 158 value: any; ··· 156 167 return failureCount < 2; 157 168 }, 158 169 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 159 - gcTime: /*0//*/5 * 60 * 1000, 170 + gcTime: /*0//*/ 5 * 60 * 1000, 160 171 }); 161 172 } 162 173 export function useQueryProfile(uri: string): UseQueryResult< ··· 167 178 }, 168 179 Error 169 180 >; 170 - export function useQueryProfile(): UseQueryResult< 171 - undefined, 172 - Error 173 - >; 174 - export function useQueryProfile(uri?: string): 175 - UseQueryResult< 176 - { 181 + export function useQueryProfile(): UseQueryResult<undefined, Error>; 182 + export function useQueryProfile(uri?: string): UseQueryResult< 183 + | { 177 184 uri: string; 178 185 cid: string; 179 186 value: ATPAPI.AppBskyActorProfile.Record; 180 - } | undefined, 181 - Error 182 - > 187 + } 188 + | undefined, 189 + Error 190 + >; 183 191 export function useQueryProfile(uri?: string) { 184 - return useQuery(constructProfileQuery(uri)); 192 + const [slingshoturl] = useAtom(slingshotURLAtom); 193 + return useQuery(constructProfileQuery(uri, slingshoturl)); 185 194 } 186 195 187 196 // export function constructConstellationQuery( ··· 216 225 // method: "/links/all", 217 226 // target: string 218 227 // ): QueryOptions<linksAllResponse, Error>; 219 - export function constructConstellationQuery(query?:{ 228 + export function constructConstellationQuery(query?: { 229 + constellation: string; 220 230 method: 221 231 | "/links" 222 232 | "/links/distinct-dids" 223 233 | "/links/count" 224 234 | "/links/count/distinct-dids" 225 235 | "/links/all" 226 - | "undefined", 227 - target: string, 228 - collection?: string, 229 - path?: string, 230 - cursor?: string, 231 - dids?: string[] 232 - } 233 - ) { 236 + | "undefined"; 237 + target: string; 238 + collection?: string; 239 + path?: string; 240 + cursor?: string; 241 + dids?: string[]; 242 + }) { 234 243 // : QueryOptions< 235 244 // | linksRecordsResponse 236 245 // | linksDidsResponse ··· 240 249 // Error 241 250 // > 242 251 return queryOptions({ 243 - queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const, 252 + queryKey: [ 253 + "constellation", 254 + query?.method, 255 + query?.target, 256 + query?.collection, 257 + query?.path, 258 + query?.cursor, 259 + query?.dids, 260 + ] as const, 244 261 queryFn: async () => { 245 - if (!query || query.method === "undefined") return undefined as undefined 246 - const method = query.method 247 - const target = query.target 248 - const collection = query?.collection 249 - const path = query?.path 250 - const cursor = query.cursor 251 - const dids = query?.dids 262 + if (!query || query.method === "undefined") return undefined as undefined; 263 + const method = query.method; 264 + const target = query.target; 265 + const collection = query?.collection; 266 + const path = query?.path; 267 + const cursor = query.cursor; 268 + const dids = query?.dids; 252 269 const res = await fetch( 253 - `https://constellation.microcosm.blue${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}` 270 + `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}` 254 271 ); 255 272 if (!res.ok) throw new Error("Failed to fetch post"); 256 273 try { ··· 274 291 }, 275 292 // enforce short lifespan 276 293 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 277 - gcTime: /*0//*/5 * 60 * 1000, 294 + gcTime: /*0//*/ 5 * 60 * 1000, 278 295 }); 279 296 } 297 + // todo do more of these instead of overloads since overloads sucks so much apparently 298 + export function useQueryConstellationLinksCountDistinctDids(query?: { 299 + method: "/links/count/distinct-dids"; 300 + target: string; 301 + collection: string; 302 + path: string; 303 + cursor?: string; 304 + }): UseQueryResult<linksCountResponse, Error> | undefined { 305 + //if (!query) return; 306 + const [constellationurl] = useAtom(constellationURLAtom); 307 + const queryres = useQuery( 308 + constructConstellationQuery( 309 + query && { constellation: constellationurl, ...query } 310 + ) 311 + ) as unknown as UseQueryResult<linksCountResponse, Error>; 312 + if (!query) { 313 + return undefined as undefined; 314 + } 315 + return queryres as UseQueryResult<linksCountResponse, Error>; 316 + } 317 + 280 318 export function useQueryConstellation(query: { 281 319 method: "/links"; 282 320 target: string; ··· 339 377 > 340 378 | undefined { 341 379 //if (!query) return; 380 + const [constellationurl] = useAtom(constellationURLAtom); 342 381 return useQuery( 343 - constructConstellationQuery(query) 382 + constructConstellationQuery( 383 + query && { constellation: constellationurl, ...query } 384 + ) 344 385 ); 345 386 } 346 387 347 - type linksRecord = { 388 + export type linksRecord = { 348 389 did: string; 349 390 collection: string; 350 391 rkey: string; ··· 362 403 type linksCountResponse = { 363 404 total: string; 364 405 }; 365 - type linksAllResponse = { 406 + export type linksAllResponse = { 366 407 links: Record< 367 408 string, 368 409 Record< ··· 384 425 }) { 385 426 return queryOptions({ 386 427 // The query key includes all dependencies to ensure it refetches when they change 387 - queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }], 428 + queryKey: [ 429 + "feedSkeleton", 430 + options?.feedUri, 431 + { isAuthed: options?.isAuthed, did: options?.agent?.did }, 432 + ], 388 433 queryFn: async () => { 389 - if (!options) return undefined as undefined 434 + if (!options) return undefined as undefined; 390 435 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 391 436 if (isAuthed) { 392 437 // Authenticated flow 393 438 if (!agent || !pdsUrl || !feedServiceDid) { 394 - throw new Error("Missing required info for authenticated feed fetch."); 439 + throw new Error( 440 + "Missing required info for authenticated feed fetch." 441 + ); 395 442 } 396 443 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 397 444 const res = await agent.fetchHandler(url, { ··· 401 448 "Content-Type": "application/json", 402 449 }, 403 450 }); 404 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 451 + if (!res.ok) 452 + throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 405 453 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 406 454 } else { 407 455 // Unauthenticated flow (using a public PDS/AppView) 408 456 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 409 457 const res = await fetch(url); 410 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 458 + if (!res.ok) 459 + throw new Error(`Public feed fetch failed: ${res.statusText}`); 411 460 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 412 461 } 413 462 }, ··· 425 474 return useQuery(constructFeedSkeletonQuery(options)); 426 475 } 427 476 428 - export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { 477 + export function constructPreferencesQuery( 478 + agent?: ATPAPI.Agent | undefined, 479 + pdsUrl?: string | undefined 480 + ) { 429 481 return queryOptions({ 430 - queryKey: ['preferences', agent?.did], 482 + queryKey: ["preferences", agent?.did], 431 483 queryFn: async () => { 432 484 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 433 485 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; ··· 438 490 }); 439 491 } 440 492 export function useQueryPreferences(options: { 441 - agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined 493 + agent?: ATPAPI.Agent | undefined; 494 + pdsUrl?: string | undefined; 442 495 }) { 443 496 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 444 497 } 445 498 446 - 447 - 448 - export function constructArbitraryQuery(uri?: string) { 499 + export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { 449 500 return queryOptions({ 450 501 queryKey: ["arbitrary", uri], 451 502 queryFn: async () => { 452 - if (!uri) return undefined as undefined 503 + if (!uri) return undefined as undefined; 453 504 const res = await fetch( 454 - `https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 505 + `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 455 506 ); 456 507 let data: any; 457 508 try { ··· 460 511 return undefined; 461 512 } 462 513 if (res.status === 400) return undefined; 463 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 514 + if ( 515 + data?.error === "InvalidRequest" && 516 + data.message?.includes("Could not find repo") 517 + ) { 464 518 return undefined; // cache โ€œnot foundโ€ 465 519 } 466 520 try { 467 521 if (!res.ok) throw new Error("Failed to fetch post"); 468 - return (data) as { 522 + return data as { 469 523 uri: string; 470 524 cid: string; 471 525 value: any; ··· 480 534 return failureCount < 2; 481 535 }, 482 536 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 483 - gcTime: /*0//*/5 * 60 * 1000, 537 + gcTime: /*0//*/ 5 * 60 * 1000, 484 538 }); 485 539 } 486 540 export function useQueryArbitrary(uri: string): UseQueryResult< ··· 491 545 }, 492 546 Error 493 547 >; 494 - export function useQueryArbitrary(): UseQueryResult< 495 - undefined, 496 - Error 497 - >; 548 + export function useQueryArbitrary(): UseQueryResult<undefined, Error>; 498 549 export function useQueryArbitrary(uri?: string): UseQueryResult< 499 - { 500 - uri: string; 501 - cid: string; 502 - value: any; 503 - } | undefined, 550 + | { 551 + uri: string; 552 + cid: string; 553 + value: any; 554 + } 555 + | undefined, 504 556 Error 505 557 >; 506 558 export function useQueryArbitrary(uri?: string) { 507 - return useQuery(constructArbitraryQuery(uri)); 559 + const [slingshoturl] = useAtom(slingshotURLAtom); 560 + return useQuery(constructArbitraryQuery(uri, slingshoturl)); 508 561 } 509 562 510 - export function constructFallbackNothingQuery(){ 563 + export function constructFallbackNothingQuery() { 511 564 return queryOptions({ 512 565 queryKey: ["nothing"], 513 566 queryFn: async () => { 514 - return undefined 567 + return undefined; 515 568 }, 516 569 }); 517 570 } ··· 525 578 }[]; 526 579 }; 527 580 528 - export function constructAuthorFeedQuery(did: string, pdsUrl: string) { 581 + export function constructAuthorFeedQuery( 582 + did: string, 583 + pdsUrl: string, 584 + collection: string = "app.bsky.feed.post" 585 + ) { 529 586 return queryOptions({ 530 - queryKey: ['authorFeed', did], 587 + queryKey: ["authorFeed", did, collection], 531 588 queryFn: async ({ pageParam }: QueryFunctionContext) => { 532 589 const limit = 25; 533 - 590 + 534 591 const cursor = pageParam as string | undefined; 535 - const cursorParam = cursor ? `&cursor=${cursor}` : ''; 536 - 537 - const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`; 538 - 592 + const cursorParam = cursor ? `&cursor=${cursor}` : ""; 593 + 594 + const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`; 595 + 539 596 const res = await fetch(url); 540 597 if (!res.ok) throw new Error("Failed to fetch author's posts"); 541 - 598 + 542 599 return res.json() as Promise<ListRecordsResponse>; 543 600 }, 544 601 }); 545 602 } 546 603 547 - export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) { 548 - const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!); 549 - 604 + export function useInfiniteQueryAuthorFeed( 605 + did: string | undefined, 606 + pdsUrl: string | undefined, 607 + collection?: string 608 + ) { 609 + const { queryKey, queryFn } = constructAuthorFeedQuery( 610 + did!, 611 + pdsUrl!, 612 + collection 613 + ); 614 + 550 615 return useInfiniteQuery({ 551 616 queryKey, 552 617 queryFn, ··· 564 629 isAuthed: boolean; 565 630 pdsUrl?: string; 566 631 feedServiceDid?: string; 632 + // todo the hell is a unauthedfeedurl 633 + unauthedfeedurl?: string; 567 634 }) { 568 - const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 569 - 635 + const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = 636 + options; 637 + 570 638 return queryOptions({ 571 639 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 572 - 573 - queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 640 + 641 + queryFn: async ({ 642 + pageParam, 643 + }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 574 644 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 575 - 576 - if (isAuthed) { 645 + 646 + if (isAuthed && !unauthedfeedurl) { 577 647 if (!agent || !pdsUrl || !feedServiceDid) { 578 - throw new Error("Missing required info for authenticated feed fetch."); 648 + throw new Error( 649 + "Missing required info for authenticated feed fetch." 650 + ); 579 651 } 580 652 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 581 653 const res = await agent.fetchHandler(url, { ··· 585 657 "Content-Type": "application/json", 586 658 }, 587 659 }); 588 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 660 + if (!res.ok) 661 + throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 589 662 return (await res.json()) as FeedSkeletonPage; 590 663 } else { 591 - const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 664 + const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 592 665 const res = await fetch(url); 593 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 666 + if (!res.ok) 667 + throw new Error(`Public feed fetch failed: ${res.statusText}`); 594 668 return (await res.json()) as FeedSkeletonPage; 595 669 } 596 670 }, ··· 603 677 isAuthed: boolean; 604 678 pdsUrl?: string; 605 679 feedServiceDid?: string; 680 + unauthedfeedurl?: string; 606 681 }) { 607 682 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 608 - 609 - return useInfiniteQuery({ 610 - queryKey, 611 - queryFn, 612 - initialPageParam: undefined as never, 613 - getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 614 - staleTime: Infinity, 615 - refetchOnWindowFocus: false, 616 - enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 617 - }); 683 + 684 + return { 685 + ...useInfiniteQuery({ 686 + queryKey, 687 + queryFn, 688 + initialPageParam: undefined as never, 689 + getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 690 + staleTime: Infinity, 691 + refetchOnWindowFocus: false, 692 + enabled: 693 + !!options.feedUri && 694 + (options.isAuthed 695 + ? ((!!options.agent && !!options.pdsUrl) || 696 + !!options.unauthedfeedurl) && 697 + !!options.feedServiceDid 698 + : true), 699 + }), 700 + queryKey: queryKey, 701 + }; 618 702 } 619 703 620 - 621 704 export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 622 - method: '/links' 623 - target?: string 624 - collection: string 625 - path: string 705 + constellation: string; 706 + method: "/links"; 707 + target?: string; 708 + collection: string; 709 + path: string; 710 + staleMult?: number; 626 711 }) { 627 - const constellationHost = 'constellation.microcosm.blue' 628 - console.log( 629 - 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 630 - query, 631 - ) 712 + const safemult = query?.staleMult ?? 1; 713 + // console.log( 714 + // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 715 + // query, 716 + // ) 632 717 633 718 return infiniteQueryOptions({ 634 719 enabled: !!query?.target, 635 720 queryKey: [ 636 - 'reddwarf_constellation', 721 + "reddwarf_constellation", 637 722 query?.method, 638 723 query?.target, 639 724 query?.collection, 640 725 query?.path, 641 726 ] as const, 642 727 643 - queryFn: async ({pageParam}: {pageParam?: string}) => { 644 - if (!query || !query?.target) return undefined 728 + queryFn: async ({ pageParam }: { pageParam?: string }) => { 729 + if (!query || !query?.target) return undefined; 645 730 646 - const method = query.method 647 - const target = query.target 648 - const collection = query.collection 649 - const path = query.path 650 - const cursor = pageParam 731 + const method = query.method; 732 + const target = query.target; 733 + const collection = query.collection; 734 + const path = query.path; 735 + const cursor = pageParam; 651 736 652 737 const res = await fetch( 653 - `https://${constellationHost}${method}?target=${encodeURIComponent(target)}${ 654 - collection ? `&collection=${encodeURIComponent(collection)}` : '' 655 - }${path ? `&path=${encodeURIComponent(path)}` : ''}${ 656 - cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' 657 - }`, 658 - ) 738 + `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${ 739 + collection ? `&collection=${encodeURIComponent(collection)}` : "" 740 + }${path ? `&path=${encodeURIComponent(path)}` : ""}${ 741 + cursor ? `&cursor=${encodeURIComponent(cursor)}` : "" 742 + }` 743 + ); 659 744 660 - if (!res.ok) throw new Error('Failed to fetch') 745 + if (!res.ok) throw new Error("Failed to fetch"); 661 746 662 - return (await res.json()) as linksRecordsResponse 747 + return (await res.json()) as linksRecordsResponse; 663 748 }, 664 749 665 - getNextPageParam: lastPage => { 666 - return (lastPage as any)?.cursor ?? undefined 750 + getNextPageParam: (lastPage) => { 751 + return (lastPage as any)?.cursor ?? undefined; 667 752 }, 668 753 initialPageParam: undefined, 669 - staleTime: 5 * 60 * 1000, 670 - gcTime: 5 * 60 * 1000, 671 - }) 672 - } 754 + staleTime: 5 * 60 * 1000 * safemult, 755 + gcTime: 5 * 60 * 1000 * safemult, 756 + }); 757 + } 758 + 759 + export function useQueryLycanStatus() { 760 + const [lycanurl] = useAtom(lycanURLAtom); 761 + const { agent, status } = useAuth(); 762 + const { data: identity } = useQueryIdentity(agent?.did); 763 + return useQuery( 764 + constructLycanStatusCheckQuery({ 765 + agent: agent || undefined, 766 + isAuthed: status === "signedIn", 767 + pdsUrl: identity?.pds, 768 + feedServiceDid: "did:web:"+lycanurl, 769 + }) 770 + ); 771 + } 772 + 773 + export function constructLycanStatusCheckQuery(options: { 774 + agent?: ATPAPI.Agent; 775 + isAuthed: boolean; 776 + pdsUrl?: string; 777 + feedServiceDid?: string; 778 + }) { 779 + const { agent, isAuthed, pdsUrl, feedServiceDid } = options; 780 + 781 + return queryOptions({ 782 + queryKey: ["lycanStatus", { isAuthed, did: agent?.did }], 783 + 784 + queryFn: async () => { 785 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 786 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.getImportStatus`; 787 + const res = await agent.fetchHandler(url, { 788 + method: "GET", 789 + headers: { 790 + "atproto-proxy": `${feedServiceDid}#lycan`, 791 + "Content-Type": "application/json", 792 + }, 793 + }); 794 + if (!res.ok) 795 + throw new Error( 796 + `Authenticated lycan status fetch failed: ${res.statusText}` 797 + ); 798 + return (await res.json()) as statuschek; 799 + } 800 + return undefined; 801 + }, 802 + }); 803 + } 804 + 805 + type statuschek = { 806 + [key: string]: unknown; 807 + error?: "MethodNotImplemented"; 808 + message?: "Method Not Implemented"; 809 + status?: "finished" | "in_progress"; 810 + position?: string, 811 + progress?: number, 812 + 813 + }; 814 + 815 + //{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268} 816 + type importtype = { 817 + message?: "Import has already started" | "Import has been scheduled" 818 + } 819 + 820 + export function constructLycanRequestIndexQuery(options: { 821 + agent?: ATPAPI.Agent; 822 + isAuthed: boolean; 823 + pdsUrl?: string; 824 + feedServiceDid?: string; 825 + }) { 826 + const { agent, isAuthed, pdsUrl, feedServiceDid } = options; 827 + 828 + return queryOptions({ 829 + queryKey: ["lycanIndex", { isAuthed, did: agent?.did }], 830 + 831 + queryFn: async () => { 832 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 833 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.startImport`; 834 + const res = await agent.fetchHandler(url, { 835 + method: "POST", 836 + headers: { 837 + "atproto-proxy": `${feedServiceDid}#lycan`, 838 + "Content-Type": "application/json", 839 + }, 840 + }); 841 + if (!res.ok) 842 + throw new Error( 843 + `Authenticated lycan status fetch failed: ${res.statusText}` 844 + ); 845 + return await res.json() as importtype; 846 + } 847 + return undefined; 848 + }, 849 + }); 850 + } 851 + 852 + type LycanSearchPage = { 853 + terms: string[]; 854 + posts: string[]; 855 + cursor?: string; 856 + }; 857 + 858 + 859 + export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) { 860 + 861 + 862 + const [lycanurl] = useAtom(lycanURLAtom); 863 + const { agent, status } = useAuth(); 864 + const { data: identity } = useQueryIdentity(agent?.did); 865 + 866 + const { queryKey, queryFn } = constructLycanSearchQuery({ 867 + agent: agent || undefined, 868 + isAuthed: status === "signedIn", 869 + pdsUrl: identity?.pds, 870 + feedServiceDid: "did:web:"+lycanurl, 871 + query: options.query, 872 + type: options.type, 873 + }) 874 + 875 + return { 876 + ...useInfiniteQuery({ 877 + queryKey, 878 + queryFn, 879 + initialPageParam: undefined as never, 880 + getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 881 + //staleTime: Infinity, 882 + refetchOnWindowFocus: false, 883 + // enabled: 884 + // !!options.feedUri && 885 + // (options.isAuthed 886 + // ? ((!!options.agent && !!options.pdsUrl) || 887 + // !!options.unauthedfeedurl) && 888 + // !!options.feedServiceDid 889 + // : true), 890 + }), 891 + queryKey: queryKey, 892 + }; 893 + } 894 + 895 + 896 + export function constructLycanSearchQuery(options: { 897 + agent?: ATPAPI.Agent; 898 + isAuthed: boolean; 899 + pdsUrl?: string; 900 + feedServiceDid?: string; 901 + type: "likes" | "pins" | "reposts" | "quotes"; 902 + query: string; 903 + }) { 904 + const { agent, isAuthed, pdsUrl, feedServiceDid, type, query } = options; 905 + 906 + return infiniteQueryOptions({ 907 + queryKey: ["lycanSearch", query, type, { isAuthed, did: agent?.did }], 908 + 909 + queryFn: async ({ 910 + pageParam, 911 + }: QueryFunctionContext): Promise<LycanSearchPage | undefined> => { 912 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 913 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.searchPosts?query=${query}&collection=${type}${pageParam ? `&cursor=${pageParam}` : ""}`; 914 + const res = await agent.fetchHandler(url, { 915 + method: "GET", 916 + headers: { 917 + "atproto-proxy": `${feedServiceDid}#lycan`, 918 + "Content-Type": "application/json", 919 + }, 920 + }); 921 + if (!res.ok) 922 + throw new Error( 923 + `Authenticated lycan status fetch failed: ${res.statusText}` 924 + ); 925 + return (await res.json()) as LycanSearchPage; 926 + } 927 + return undefined; 928 + }, 929 + initialPageParam: undefined as never, 930 + getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 931 + }); 932 + }
+6 -1
vite.config.ts
··· 10 10 11 11 import { generateMetadataPlugin } from "./oauthdev.mts"; 12 12 13 - const PROD_URL = "https://reddwarf.whey.party" 13 + const PROD_URL = "https://reddwarf.app" 14 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 + 16 + const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party" 17 + const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social" 15 18 16 19 function shp(url: string): string { 17 20 return url.replace(/^https?:\/\//, ''); ··· 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({