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
+9
README.md
··· 15 15 16 16 run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder) 17 17 18 + 19 + 20 + you probably dont need to change these 21 + ```ts 22 + const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party" 23 + const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social" 24 + ``` 25 + if you do want to change these, i recommend changing both of these to your own PDS url. i separate the prod and dev urls so that you can change it as needed. here i separated it because if the prod resolver and prod url shares the same domain itll error and prevent logins 26 + 18 27 ## useQuery 19 28 Red Dwarf has been upgraded from its original bespoke caching system to Tanstack Query (react query). this migration was done to achieve a more robust and maintainable approach to data fetching and caching and state synchronization. ive seen serious performance gains from this switch! 20 29
+62 -30
oauthdev.mts
··· 1 - import fs from 'fs'; 2 - import path from 'path'; 1 + import fs from "fs"; 2 + import path from "path"; 3 3 //import { generateClientMetadata } from './src/helpers/oauthClient' 4 4 export const generateClientMetadata = (appOrigin: string) => { 5 - const callbackPath = '/callback'; 5 + const callbackPath = "/callback"; 6 6 7 7 return { 8 - "client_id": `${appOrigin}/client-metadata.json`, 9 - "client_name": "ForumTest", 10 - "client_uri": appOrigin, 11 - "logo_uri": `${appOrigin}/logo192.png`, 12 - "tos_uri": `${appOrigin}/terms-of-service`, 13 - "policy_uri": `${appOrigin}/privacy-policy`, 14 - "redirect_uris": [`${appOrigin}${callbackPath}`] as [string, ...string[]], 15 - "scope": "atproto transition:generic", 16 - "grant_types": ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"], 17 - "response_types": ["code"] as ["code"], 18 - "token_endpoint_auth_method": "none" as "none", 19 - "application_type": "web" as "web", 20 - "dpop_bound_access_tokens": true 21 - }; 22 - } 23 - 8 + client_id: `${appOrigin}/client-metadata.json`, 9 + client_name: "ForumTest", 10 + client_uri: appOrigin, 11 + logo_uri: `${appOrigin}/logo192.png`, 12 + tos_uri: `${appOrigin}/terms-of-service`, 13 + policy_uri: `${appOrigin}/privacy-policy`, 14 + redirect_uris: [`${appOrigin}${callbackPath}`] as [string, ...string[]], 15 + scope: "atproto transition:generic", 16 + grant_types: ["authorization_code", "refresh_token"] as [ 17 + "authorization_code", 18 + "refresh_token", 19 + ], 20 + response_types: ["code"] as ["code"], 21 + token_endpoint_auth_method: "none" as "none", 22 + application_type: "web" as "web", 23 + dpop_bound_access_tokens: true, 24 + }; 25 + }; 24 26 25 - export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) { 27 + export function generateMetadataPlugin({ 28 + prod, 29 + dev, 30 + prodResolver = "https://bsky.social", 31 + devResolver = prodResolver, 32 + }: { 33 + prod: string; 34 + dev: string; 35 + prodResolver?: string; 36 + devResolver?: string; 37 + }) { 26 38 return { 27 - name: 'vite-plugin-generate-metadata', 39 + name: "vite-plugin-generate-metadata", 28 40 config(_config: any, { mode }: any) { 29 - let appOrigin; 30 - if (mode === 'production') { 31 - appOrigin = prod 32 - if (!appOrigin || !appOrigin.startsWith('https://')) { 33 - throw new Error('VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build.'); 41 + console.log('๐Ÿ’ก vite mode =', mode) 42 + let appOrigin, resolver; 43 + if (mode === "production") { 44 + appOrigin = prod; 45 + resolver = prodResolver; 46 + if (!appOrigin || !appOrigin.startsWith("https://")) { 47 + throw new Error( 48 + "VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build." 49 + ); 34 50 } 35 51 } else { 36 52 appOrigin = dev; 53 + resolver = devResolver; 37 54 } 38 - 39 - 55 + 40 56 const metadata = generateClientMetadata(appOrigin); 41 - const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json'); 57 + const outputPath = path.resolve( 58 + process.cwd(), 59 + "public", 60 + "client-metadata.json" 61 + ); 42 62 43 63 fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2)); 44 64 65 + const resolvers = { 66 + resolver: resolver, 67 + }; 68 + const resolverOutPath = path.resolve( 69 + process.cwd(), 70 + "public", 71 + "resolvers.json" 72 + ); 73 + 74 + fs.writeFileSync(resolverOutPath, JSON.stringify(resolvers, null, 2)); 75 + 76 + 45 77 // /*mass comment*/ console.log(`โœ… Generated client-metadata.json for ${appOrigin}`); 46 78 }, 47 79 }; 48 - } 80 + }
+10
package-lock.json
··· 29 29 "react": "^19.0.0", 30 30 "react-dom": "^19.0.0", 31 31 "react-player": "^3.3.2", 32 + "sonner": "^2.0.7", 32 33 "tailwindcss": "^4.0.6", 33 34 "tanstack-router-keepalive": "^1.0.0" 34 35 }, ··· 12543 12544 "csstype": "^3.1.0", 12544 12545 "seroval": "~1.3.0", 12545 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" 12546 12556 } 12547 12557 }, 12548 12558 "node_modules/source-map": {
+1
package.json
··· 33 33 "react": "^19.0.0", 34 34 "react-dom": "^19.0.0", 35 35 "react-player": "^3.3.2", 36 + "sonner": "^2.0.7", 36 37 "tailwindcss": "^4.0.6", 37 38 "tanstack-router-keepalive": "^1.0.0" 38 39 },
+3
src/auto-imports.d.ts
··· 20 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 21 const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 22 const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default 23 + const IconMdiClose: typeof import('~icons/mdi/close.jsx').default 23 24 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 24 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 25 28 }
+37 -3
src/components/Import.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 2 import { useNavigate, type UseNavigateResult } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 3 4 import { useState } from "react"; 5 + 6 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { lycanURLAtom } from "~/utils/atoms"; 8 + import { useQueryLycanStatus } from "~/utils/useQuery"; 4 9 5 10 /** 6 11 * Basically the best equivalent to Search that i can do 7 12 */ 8 - export function Import() { 9 - const [textInput, setTextInput] = useState<string | undefined>(); 13 + export function Import({ 14 + optionaltextstring, 15 + }: { 16 + optionaltextstring?: string; 17 + }) { 18 + const [textInput, setTextInput] = useState<string | undefined>( 19 + optionaltextstring 20 + ); 10 21 const navigate = useNavigate(); 11 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 + 12 36 const handleEnter = () => { 13 37 if (!textInput) return; 14 38 handleImport({ 15 39 text: textInput, 16 40 navigate, 41 + lycanReady: 42 + lycanReady || (!!lycanIndexingProgress && lycanIndexingProgress > 0), 17 43 }); 18 44 }; 45 + 46 + const placeholder = lycanReady ? "Search..." : "Import..."; 19 47 20 48 return ( 21 49 <div className="w-full relative"> ··· 23 51 24 52 <input 25 53 type="text" 26 - placeholder="Import..." 54 + placeholder={placeholder} 27 55 value={textInput} 28 56 onChange={(e) => setTextInput(e.target.value)} 29 57 onKeyDown={(e) => { ··· 38 66 function handleImport({ 39 67 text, 40 68 navigate, 69 + lycanReady, 41 70 }: { 42 71 text: string; 43 72 navigate: UseNavigateResult<string>; 73 + lycanReady?: boolean; 44 74 }) { 45 75 const trimmed = text.trim(); 46 76 // parse text ··· 147 177 // } catch { 148 178 // // continue 149 179 // } 180 + 181 + if (lycanReady) { 182 + navigate({ to: "/search", search: { q: text } }); 183 + } 150 184 }
+136 -27
src/components/UniversalPostRenderer.tsx
··· 1 - import * as ATPAPI from "@atproto/api" 1 + import * as ATPAPI from "@atproto/api"; 2 2 import { useNavigate } from "@tanstack/react-router"; 3 3 import DOMPurify from "dompurify"; 4 4 import { useAtom } from "jotai"; ··· 10 10 import { 11 11 composerAtom, 12 12 constellationURLAtom, 13 + enableBridgyTextAtom, 14 + enableWafrnTextAtom, 13 15 imgCDNAtom, 14 16 } from "~/utils/atoms"; 15 17 import { useHydratedEmbed } from "~/utils/useHydrated"; ··· 162 164 isQuote, 163 165 filterNoReplies, 164 166 filterMustHaveMedia, 165 - filterMustBeReply 167 + filterMustBeReply, 166 168 }: UniversalPostRendererATURILoaderProps) { 167 169 // todo remove this once tree rendering is implemented, use a prop like isTree 168 170 const TEMPLINEAR = true; ··· 526 528 ? true 527 529 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 528 530 ? false 529 - : (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine 531 + : maxReplies === 0 && (!replies || (!!replies && replies === 0)) 532 + ? false 533 + : bottomReplyLine 530 534 } 531 535 topReplyLine={topReplyLine} 532 536 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 553 557 filterMustBeReply={filterMustBeReply} 554 558 /> 555 559 <> 556 - {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( 560 + {maxReplies && maxReplies === 0 && replies && replies > 0 ? ( 557 561 <> 558 - {/* <div>hello</div> */} 559 - <MoreReplies atUri={atUri} /> 562 + {/* <div>hello</div> */} 563 + <MoreReplies atUri={atUri} /> 560 564 </> 561 - ) : (<></>)} 565 + ) : ( 566 + <></> 567 + )} 562 568 </> 563 569 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 564 570 <> ··· 755 761 const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; 756 762 const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; 757 763 const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia"; 758 - const isQuotewithImages = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === "app.bsky.embed.images"; 759 - const isQuotewithVideo = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === "app.bsky.embed.video"; 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"; 760 772 761 - const hasMedia = hasEmbed && (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo); 773 + const hasMedia = 774 + hasEmbed && 775 + (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo); 762 776 763 777 const { 764 778 data: hydratedEmbed, ··· 854 868 // }, [fakepost, get, set]); 855 869 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 856 870 ?.uri; 857 - const feedviewpostreplydid = thereply&&!filterNoReplies ? new AtUri(thereply).host : undefined; 871 + const feedviewpostreplydid = 872 + thereply && !filterNoReplies ? new AtUri(thereply).host : undefined; 858 873 const replyhookvalue = useQueryIdentity( 859 874 feedviewpost ? feedviewpostreplydid : undefined 860 875 ); ··· 1237 1252 1238 1253 import defaultpfp from "~/../public/favicon.png"; 1239 1254 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1240 - import { FeedItemRenderAturiLoader, FollowButton, Mutual } from "~/routes/profile.$did"; 1255 + import { renderSnack } from "~/routes/__root"; 1256 + import { 1257 + FeedItemRenderAturiLoader, 1258 + FollowButton, 1259 + Mutual, 1260 + } from "~/routes/profile.$did"; 1241 1261 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1242 1262 import { useFastLike } from "~/utils/likeMutationQueue"; 1243 1263 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; ··· 1446 1466 : undefined; 1447 1467 1448 1468 const emergencySalt = randomString(); 1449 - 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 }) 1450 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); 1451 1507 1452 1508 /* fuck you */ 1453 1509 const isMainItem = false; ··· 1586 1642 {post.author.displayName || post.author.handle}{" "} 1587 1643 </div> 1588 1644 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1589 - <Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "} 1645 + <Mutual targetdidorhandle={post.author.did} />@ 1646 + {post.author.handle}{" "} 1590 1647 </div> 1591 1648 </div> 1592 1649 {uprrrsauthor?.description && ( ··· 1834 1891 </div> 1835 1892 </> 1836 1893 )} 1837 - <div style={{ paddingTop: post.embed && !concise && depth < 1 ? 4 : 0 }}> 1894 + <div 1895 + style={{ 1896 + paddingTop: post.embed && !concise && depth < 1 ? 4 : 0, 1897 + }} 1898 + > 1838 1899 <> 1839 1900 {expanded && ( 1840 1901 <div ··· 1952 2013 "/post/" + 1953 2014 post.uri.split("/").pop() 1954 2015 ); 2016 + renderSnack({ 2017 + title: "Copied to clipboard!", 2018 + }); 1955 2019 } catch (_e) { 1956 2020 // idk 2021 + renderSnack({ 2022 + title: "Failed to copy link", 2023 + }); 1957 2024 } 1958 2025 }} 1959 2026 style={{ ··· 1962 2029 > 1963 2030 <MdiShareVariant /> 1964 2031 </HitSlopButton> 1965 - <span style={btnstyle}> 1966 - <MdiMoreHoriz /> 1967 - </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> 1968 2043 </div> 1969 2044 </div> 1970 2045 )} ··· 2203 2278 // <MaybeFeedCard view={embed.record} /> 2204 2279 // </div> 2205 2280 // ) 2206 - } else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.feed.generator") { 2207 - return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder/></div> 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 + ); 2208 2291 } 2209 2292 2210 2293 // list embed ··· 2216 2299 // <MaybeListCard view={embed.record} /> 2217 2300 // </div> 2218 2301 // ) 2219 - } else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.graph.list") { 2220 - return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder listmode disablePropagation /></div> 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 + ); 2221 2317 } 2222 2318 2223 2319 // starter pack embed ··· 2229 2325 // <StarterPackCard starterPack={embed.record} /> 2230 2326 // </div> 2231 2327 // ) 2232 - } else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.graph.starterpack") { 2233 - return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder listmode disablePropagation /></div> 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 + ); 2234 2343 } 2235 2344 2236 2345 // quote post ··· 2724 2833 className="link" 2725 2834 style={{ 2726 2835 textDecoration: "none", 2727 - color: "rgb(29, 122, 242)", 2836 + color: "var(--link-text-color)", 2728 2837 wordBreak: "break-all", 2729 2838 }} 2730 2839 target="_blank" ··· 2744 2853 result.push( 2745 2854 <span 2746 2855 key={start} 2747 - style={{ color: "rgb(29, 122, 242)" }} 2856 + style={{ color: "var(--link-text-color)" }} 2748 2857 className=" cursor-pointer" 2749 2858 onClick={(e) => { 2750 2859 e.stopPropagation(); ··· 2762 2871 result.push( 2763 2872 <span 2764 2873 key={start} 2765 - style={{ color: "rgb(29, 122, 242)" }} 2874 + style={{ color: "var(--link-text-color)" }} 2766 2875 onClick={(e) => { 2767 2876 e.stopPropagation(); 2768 2877 }}
+6
src/providers/LikeMutationQueueProvider.tsx
··· 5 5 import React, { createContext, use, useCallback, useEffect, useRef } from "react"; 6 6 7 7 import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 + import { renderSnack } from "~/routes/__root"; 8 9 import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms"; 9 10 import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery"; 10 11 ··· 125 126 } 126 127 } catch (err) { 127 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 + }) 128 134 if (mutation.type === 'like') { 129 135 setFastState(mutation.target, null); 130 136 } else if (mutation.type === 'unlike') {
+21
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' ··· 42 43 const NotificationsRoute = NotificationsRouteImport.update({ 43 44 id: '/notifications', 44 45 path: '/notifications', 46 + getParentRoute: () => rootRouteImport, 47 + } as any) 48 + const ModerationRoute = ModerationRouteImport.update({ 49 + id: '/moderation', 50 + path: '/moderation', 45 51 getParentRoute: () => rootRouteImport, 46 52 } as any) 47 53 const FeedsRoute = FeedsRouteImport.update({ ··· 133 139 export interface FileRoutesByFullPath { 134 140 '/': typeof IndexRoute 135 141 '/feeds': typeof FeedsRoute 142 + '/moderation': typeof ModerationRoute 136 143 '/notifications': typeof NotificationsRoute 137 144 '/search': typeof SearchRoute 138 145 '/settings': typeof SettingsRoute ··· 152 159 export interface FileRoutesByTo { 153 160 '/': typeof IndexRoute 154 161 '/feeds': typeof FeedsRoute 162 + '/moderation': typeof ModerationRoute 155 163 '/notifications': typeof NotificationsRoute 156 164 '/search': typeof SearchRoute 157 165 '/settings': typeof SettingsRoute ··· 173 181 '/': typeof IndexRoute 174 182 '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren 175 183 '/feeds': typeof FeedsRoute 184 + '/moderation': typeof ModerationRoute 176 185 '/notifications': typeof NotificationsRoute 177 186 '/search': typeof SearchRoute 178 187 '/settings': typeof SettingsRoute ··· 195 204 fullPaths: 196 205 | '/' 197 206 | '/feeds' 207 + | '/moderation' 198 208 | '/notifications' 199 209 | '/search' 200 210 | '/settings' ··· 214 224 to: 215 225 | '/' 216 226 | '/feeds' 227 + | '/moderation' 217 228 | '/notifications' 218 229 | '/search' 219 230 | '/settings' ··· 234 245 | '/' 235 246 | '/_pathlessLayout' 236 247 | '/feeds' 248 + | '/moderation' 237 249 | '/notifications' 238 250 | '/search' 239 251 | '/settings' ··· 256 268 IndexRoute: typeof IndexRoute 257 269 PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren 258 270 FeedsRoute: typeof FeedsRoute 271 + ModerationRoute: typeof ModerationRoute 259 272 NotificationsRoute: typeof NotificationsRoute 260 273 SearchRoute: typeof SearchRoute 261 274 SettingsRoute: typeof SettingsRoute ··· 288 301 path: '/notifications' 289 302 fullPath: '/notifications' 290 303 preLoaderRoute: typeof NotificationsRouteImport 304 + parentRoute: typeof rootRouteImport 305 + } 306 + '/moderation': { 307 + id: '/moderation' 308 + path: '/moderation' 309 + fullPath: '/moderation' 310 + preLoaderRoute: typeof ModerationRouteImport 291 311 parentRoute: typeof rootRouteImport 292 312 } 293 313 '/feeds': { ··· 456 476 IndexRoute: IndexRoute, 457 477 PathlessLayoutRoute: PathlessLayoutRouteWithChildren, 458 478 FeedsRoute: FeedsRoute, 479 + ModerationRoute: ModerationRoute, 459 480 NotificationsRoute: NotificationsRoute, 460 481 SearchRoute: SearchRoute, 461 482 SettingsRoute: SettingsRoute,
+170 -11
src/routes/__root.tsx
··· 14 14 import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 15 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 19 21 import { Composer } from "~/components/Composer"; ··· 83 85 <LikeMutationQueueProvider> 84 86 <RootDocument> 85 87 <KeepAliveProvider> 88 + <AppToaster /> 86 89 <KeepAliveOutlet /> 87 90 </KeepAliveProvider> 88 91 </RootDocument> ··· 91 94 ); 92 95 } 93 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 + 94 201 function RootDocument({ children }: { children: React.ReactNode }) { 95 202 useAtomCssVar(hueAtom, "--tw-gray-hue"); 96 203 const location = useLocation(); ··· 106 213 const isSettings = location.pathname.startsWith("/settings"); 107 214 const isSearch = location.pathname.startsWith("/search"); 108 215 const isFeeds = location.pathname.startsWith("/feeds"); 216 + const isModeration = location.pathname.startsWith("/moderation"); 109 217 110 218 const locationEnum: 111 219 | "feeds" ··· 113 221 | "settings" 114 222 | "notifications" 115 223 | "profile" 224 + | "moderation" 116 225 | "home" = isFeeds 117 226 ? "feeds" 118 227 : isSearch ··· 123 232 ? "notifications" 124 233 : isProfile 125 234 ? "profile" 126 - : "home"; 235 + : isModeration 236 + ? "moderation" 237 + : "home"; 127 238 128 239 const [, setComposerPost] = useAtom(composerAtom); 129 240 ··· 134 245 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 135 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"> 136 247 <div className="flex items-center gap-3 mb-4"> 137 - <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 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 + /> 138 255 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 139 256 Red Dwarf{" "} 140 257 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 198 315 text="Feeds" 199 316 /> 200 317 <MaterialNavItem 318 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 319 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 320 + active={locationEnum === "moderation"} 321 + onClickCallbback={() => 322 + navigate({ 323 + to: "/moderation", 324 + //params: { did: agent.assertDid }, 325 + }) 326 + } 327 + text="Moderation" 328 + /> 329 + <MaterialNavItem 201 330 InactiveIcon={ 202 331 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 203 332 } ··· 235 364 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 236 365 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 237 366 //active={true} 238 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 367 + onClickCallbback={() => setComposerPost({ kind: "root" })} 239 368 text="Post" 240 369 /> 241 370 </div> ··· 373 502 374 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"> 375 504 <div className="flex items-center gap-3 mb-4"> 376 - <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 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 + /> 377 512 </div> 378 513 <MaterialNavItem 379 514 small ··· 436 571 /> 437 572 <MaterialNavItem 438 573 small 574 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 575 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 576 + active={locationEnum === "moderation"} 577 + onClickCallbback={() => 578 + navigate({ 579 + to: "/moderation", 580 + //params: { did: agent.assertDid }, 581 + }) 582 + } 583 + text="Moderation" 584 + /> 585 + <MaterialNavItem 586 + small 439 587 InactiveIcon={ 440 588 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 441 589 } ··· 475 623 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 476 624 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 477 625 //active={true} 478 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 626 + onClickCallbback={() => setComposerPost({ kind: "root" })} 479 627 text="Post" 480 628 /> 481 629 </div> ··· 485 633 <button 486 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" 487 635 style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }} 488 - onClick={() => setComposerPost({ kind: 'root' })} 636 + onClick={() => setComposerPost({ kind: "root" })} 489 637 type="button" 490 638 aria-label="Create Post" 491 639 > ··· 502 650 </main> 503 651 504 652 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 505 - <div className="px-4 pt-4"><Import /></div> 653 + <div className="px-4 pt-4"> 654 + <Import /> 655 + </div> 506 656 <Login /> 507 657 508 658 <div className="flex-1"></div> 509 659 <p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4"> 510 - Red Dwarf is a Bluesky client that does not rely on any Bluesky API App Servers. Instead, it uses Microcosm to fetch records directly from each users' PDS (via Slingshot) and connect them using backlinks (via Constellation) 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) 511 664 </p> 512 665 </aside> 513 666 </div> ··· 654 807 <IconMaterialSymbolsSettingsOutline className="w-6 h-6" /> 655 808 } 656 809 ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />} 657 - active={locationEnum === "settings"} 810 + active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"} 658 811 onClickCallbback={() => 659 812 navigate({ 660 813 to: "/settings", ··· 683 836 ) : ( 684 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"> 685 838 <div className="flex items-center gap-2"> 686 - <FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 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 + /> 687 846 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 688 847 Red Dwarf{" "} 689 848 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 703 862 ); 704 863 } 705 864 706 - function MaterialNavItem({ 865 + export function MaterialNavItem({ 707 866 InactiveIcon, 708 867 ActiveIcon, 709 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 }
+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 + }
+2 -2
src/routes/notifications.tsx
··· 572 572 ); 573 573 } 574 574 575 - export function NotificationItem({ notification }: { notification: string }) { 575 + export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) { 576 576 const aturi = new AtUri(notification); 577 577 const bite = aturi.collection === "net.wafrn.feed.bite"; 578 578 const navigate = useNavigate(); ··· 618 618 <img 619 619 src={avatar || defaultpfp} 620 620 alt={identity?.handle} 621 - className="w-10 h-10 rounded-full" 621 + className={`w-10 h-10 ${labeler ? "rounded-md" : "rounded-full"}`} 622 622 /> 623 623 ) : ( 624 624 <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
+218 -48
src/routes/profile.$did/index.tsx
··· 32 32 useQueryIdentity, 33 33 useQueryProfile, 34 34 } from "~/utils/useQuery"; 35 + import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx"; 35 36 37 + import { renderSnack } from "../__root"; 36 38 import { Chip } from "../notifications"; 37 39 38 40 export const Route = createFileRoute("/profile/$did/")({ ··· 51 53 error: identityError, 52 54 } = useQueryIdentity(did); 53 55 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; 69 + 54 70 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 55 71 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; 56 72 const pdsUrl = identity?.pds; ··· 81 97 82 98 const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; 83 99 84 - const resultwhateversure = useQueryConstellationLinksCountDistinctDids(resolvedDid ? { 85 - method: "/links/count/distinct-dids", 86 - collection: "app.bsky.graph.follow", 87 - target: resolvedDid, 88 - path: ".subject" 89 - } : undefined) 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 + ); 90 110 91 111 const followercount = resultwhateversure?.data?.total; 92 112 ··· 136 156 137 157 {/* Avatar (PFP) */} 138 158 <div className="absolute left-[16px] top-[100px] "> 139 - <img 140 - src={getAvatarUrl(profile) || "/favicon.png"} 141 - alt="avatar" 142 - className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700" 143 - /> 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 + )} 144 172 </div> 145 173 146 174 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> ··· 152 180 also save it persistently 153 181 */} 154 182 <FollowButton targetdidorhandle={did} /> 155 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 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 + > 156 193 ... {/* todo: icon */} 157 194 </button> 158 195 </div> ··· 165 202 {handle} 166 203 </div> 167 204 <div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2"> 168 - <Link to="/profile/$did/followers" params={{did: did}}>{followercount && (<span className="mr-1 text-gray-900 dark:text-gray-200 font-medium">{followercount}</span>)}Followers</Link> 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> 169 213 - 170 - <Link to="/profile/$did/follows" params={{did: did}}>Follows</Link> 214 + <Link to="/profile/$did/follows" params={{ did: did }}> 215 + Follows 216 + </Link> 171 217 </div> 172 218 {description && ( 173 219 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> ··· 183 229 <ReusableTabRoute 184 230 route={`Profile` + did} 185 231 tabs={{ 186 - Posts: <PostsTab did={did} />, 187 - Reposts: <RepostsTab did={did} />, 188 - Feeds: <FeedsTab did={did} />, 189 - Lists: <ListsTab did={did} />, 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 + }, 190 243 ...(identity?.did === agent?.did 191 244 ? { Likes: <SelfLikesTab did={did} /> } 192 245 : {}), ··· 212 265 } 213 266 214 267 export type ProfilePostsFilter = { 215 - posts: boolean, 216 - replies: boolean, 217 - mediaOnly: boolean, 218 - } 268 + posts: boolean; 269 + replies: boolean; 270 + mediaOnly: boolean; 271 + }; 219 272 export const defaultProfilePostsFilter: ProfilePostsFilter = { 220 273 posts: true, 221 274 replies: true, 222 275 mediaOnly: false, 223 - } 276 + }; 224 277 225 - function ProfilePostsFilterChipBar({filters, toggle}:{filters: ProfilePostsFilter | null, toggle: (key: keyof ProfilePostsFilter) => void}) { 226 - const empty = (!filters?.replies && !filters?.posts); 227 - const almostEmpty = (!filters?.replies && filters?.posts); 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; 228 287 229 288 useEffect(() => { 230 289 if (empty) { 231 - toggle("posts") 290 + toggle("posts"); 232 291 } 233 292 }, [empty, toggle]); 234 293 ··· 237 296 <Chip 238 297 state={filters?.posts ?? true} 239 298 text="Posts" 240 - onClick={() => almostEmpty ? null : toggle("posts")} 299 + onClick={() => (almostEmpty ? null : toggle("posts"))} 241 300 /> 242 301 <Chip 243 302 state={filters?.replies ?? true} ··· 258 317 const [filterses, setFilterses] = useAtom(profileChipsAtom); 259 318 const filters = filterses?.[did]; 260 319 const setFilters = (obj: ProfilePostsFilter) => { 261 - setFilterses((prev)=>{ 262 - return{ 320 + setFilterses((prev) => { 321 + return { 263 322 ...prev, 264 - [did]: obj 265 - } 266 - }) 267 - } 268 - useEffect(()=>{ 323 + [did]: obj, 324 + }; 325 + }); 326 + }; 327 + useEffect(() => { 269 328 if (!filters) { 270 329 setFilters(defaultProfilePostsFilter); 271 330 } 272 - }) 331 + }); 273 332 useReusableTabScrollRestore(`Profile` + did); 274 333 const queryClient = useQueryClient(); 275 334 const { ··· 306 365 ); 307 366 308 367 const toggle = (key: keyof ProfilePostsFilter) => { 309 - setFilterses(prev => { 310 - const existing = prev[did] ?? { posts: false, replies: false, mediaOnly: false }; // default 368 + setFilterses((prev) => { 369 + const existing = prev[did] ?? { 370 + posts: false, 371 + replies: false, 372 + mediaOnly: false, 373 + }; // default 311 374 312 375 return { 313 376 ...prev, ··· 500 563 ); 501 564 } 502 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" 610 + > 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 + 503 646 export function FeedItemRenderAturiLoader({ 504 647 aturi, 505 648 listmode, ··· 805 948 )} 806 949 </> 807 950 ) : ( 808 - <button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"> 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 + > 809 961 Edit Profile 810 962 </button> 811 963 )} ··· 822 974 const { data: identity } = useQueryIdentity(targetdidorhandle); 823 975 const [show] = useAtom(enableBitesAtom); 824 976 825 - if (!show) return 977 + if (!show) return; 826 978 827 979 return ( 828 980 <> 829 981 <button 830 - onClick={(e) => { 982 + onClick={async (e) => { 831 983 e.stopPropagation(); 832 - sendBite({ 984 + await sendBite({ 833 985 agent: agent || undefined, 834 986 targetDid: identity?.did, 835 987 }); ··· 842 994 ); 843 995 } 844 996 845 - function sendBite({ 997 + async function sendBite({ 846 998 agent, 847 999 targetDid, 848 1000 }: { 849 1001 agent?: Agent; 850 1002 targetDid?: string; 851 1003 }) { 852 - if (!agent?.did || !targetDid) return; 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 + } 853 1012 const newRecord = { 854 1013 repo: agent.did, 855 1014 collection: "net.wafrn.feed.bite", 856 1015 rkey: TID.next().toString(), 857 1016 record: { 858 1017 $type: "net.wafrn.feed.bite", 859 - subject: "at://"+targetDid, 1018 + subject: "at://" + targetDid, 860 1019 createdAt: new Date().toISOString(), 861 1020 }, 862 1021 }; 863 1022 864 - agent.com.atproto.repo.createRecord(newRecord).catch((err) => { 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) { 865 1031 console.error("Bite failed:", err); 866 - }); 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 + } 867 1038 } 868 - 869 1039 870 1040 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 871 1041 const { agent } = useAuth();
+217 -9
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"; 2 6 3 7 import { Header } from "~/components/Header"; 4 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"; 5 25 6 26 export const Route = createFileRoute("/search")({ 7 27 component: Search, 8 28 }); 9 29 10 30 export function Search() { 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 + 11 105 return ( 12 106 <> 13 107 <Header ··· 21 115 }} 22 116 /> 23 117 <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 24 - <Import /> 118 + <Import optionaltextstring={q} /> 25 119 <div className="flex flex-col"> 26 - <p className="text-gray-600 dark:text-gray-400"> 27 - Sorry we dont have search. But instead, you can load some of these 28 - types of content into Red Dwarf: 29 - </p> 120 + <p className="text-gray-600 dark:text-gray-400">{maintext}</p> 30 121 <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 31 122 <li> 32 - Bluesky URLs from supported clients (like{" "} 123 + Bluesky URLs (from supported clients) (like{" "} 33 124 <code className="text-sm">bsky.app</code> or{" "} 34 125 <code className="text-sm">deer.social</code>). 35 126 </li> ··· 39 130 ). 40 131 </li> 41 132 <li> 42 - Plain handles (like{" "} 133 + User Handles (like{" "} 43 134 <code className="text-sm">@username.bsky.social</code>). 44 135 </li> 45 136 <li> 46 - Direct DIDs (Decentralized Identifiers, starting with{" "} 137 + DIDs (Decentralized Identifiers, starting with{" "} 47 138 <code className="text-sm">did:</code>). 48 139 </li> 49 140 </ul> ··· 51 142 Simply paste one of these into the import field above and press 52 143 Enter to load the content. 53 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 + )} 54 177 </div> 55 178 </div> 179 + {q ? <SearchTabs query={q} /> : <></>} 56 180 </> 57 181 ); 58 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 <></>; 266 + }
+148 -30
src/routes/settings.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 1 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 2 import { useAtom, useAtomValue, useSetAtom } from "jotai"; 3 3 import { Slider, Switch } from "radix-ui"; 4 - import { useEffect,useState } from "react"; 4 + import { useEffect, useState } from "react"; 5 5 6 6 import { Header } from "~/components/Header"; 7 7 import Login from "~/components/Login"; ··· 10 10 defaultconstellationURL, 11 11 defaulthue, 12 12 defaultImgCDN, 13 + defaultLycanURL, 13 14 defaultslingshotURL, 14 15 defaultVideoCDN, 15 16 enableBitesAtom, 17 + enableBridgyTextAtom, 18 + enableWafrnTextAtom, 16 19 hueAtom, 17 20 imgCDNAtom, 21 + lycanURLAtom, 18 22 slingshotURLAtom, 19 23 videoCDNAtom, 20 24 } from "~/utils/atoms"; 21 25 26 + import { MaterialNavItem } from "./__root"; 27 + 22 28 export const Route = createFileRoute("/settings")({ 23 29 component: Settings, 24 30 }); 25 31 26 32 export function Settings() { 33 + const navigate = useNavigate(); 27 34 return ( 28 35 <> 29 36 <Header ··· 39 46 <div className="lg:hidden"> 40 47 <Login /> 41 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> 42 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> 43 88 <TextInputSetting 44 89 atom={constellationURLAtom} 45 90 title={"Constellation"} ··· 68 113 description={"Customize the Slingshot instance to be used by Red Dwarf"} 69 114 init={defaultVideoCDN} 70 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 + /> 71 122 72 - <Hue /> 123 + <SettingHeading title="Experimental" /> 73 124 <SwitchSetting 74 125 atom={enableBitesAtom} 75 126 title={"Bites"} 76 - description={"Enable Wafrn Bites"} 127 + description={"Enable Wafrn Bites to bite and be bitten by other people"} 77 128 //init={false} 78 - /> 79 - <p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm"> 80 - please restart/refresh the app if changes arent applying correctly 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 81 149 </p> 82 150 </> 83 151 ); 84 152 } 85 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 + 86 173 export function SwitchSetting({ 87 174 atom, 88 175 title, ··· 105 192 } 106 193 107 194 return ( 108 - <div className="flex items-center gap-4 px-4 py-2"> 109 - <div className="flex flex-col"> 110 - <label htmlFor="switch-demo" className="text-lg"> 111 - {title} 112 - </label> 113 - <span className="text-sm">{description}</span> 114 - </div> 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> 115 204 116 205 <Switch.Root 117 - id="switch-demo" 206 + id={`switch-${title}`} 118 207 checked={value} 119 208 onCheckedChange={(v) => setValue(v)} 120 - className="w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-blue-500 transition-colors" 209 + className="m3switch root" 121 210 > 122 - <Switch.Thumb 123 - className="block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]" 124 - /> 211 + <Switch.Thumb className="m3switch thumb " /> 125 212 </Switch.Root> 126 213 </div> 127 214 ); ··· 130 217 function Hue() { 131 218 const [hue, setHue] = useAtom(hueAtom); 132 219 return ( 133 - <div className="flex flex-col px-4 mt-4 "> 134 - <span className="z-10">Hue</span> 135 - <div className="flex flex-row items-center gap-4"> 136 - <SliderComponent 137 - atom={hueAtom} 138 - max={360} 139 - /> 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} /> 140 227 <button 141 228 onClick={() => setHue(defaulthue ?? 28)} 142 229 className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 ··· 207 294 ); 208 295 } 209 296 210 - 211 297 interface SliderProps { 212 298 atom: typeof hueAtom; 213 299 min?: number; ··· 221 307 max = 100, 222 308 step = 1, 223 309 }) => { 224 - 225 - const [value, setValue] = useAtom(atom) 310 + const [value, setValue] = useAtom(atom); 226 311 227 312 return ( 228 313 <Slider.Root ··· 239 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" /> 240 325 </Slider.Root> 241 326 ); 242 - }; 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 + };
+98 -1
src/styles/app.css
··· 33 33 --color-gray-950: oklch(0.129 0.055 var(--safe-hue)); 34 34 } 35 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));*/ 40 + } 41 + 36 42 @layer base { 37 43 html { 38 44 color-scheme: light dark; ··· 84 90 .dangerousFediContent { 85 91 & a[href]{ 86 92 text-decoration: none; 87 - color: rgb(29, 122, 242); 93 + color: var(--link-text-color); 88 94 word-break: break-all; 89 95 } 90 96 } ··· 275 281 &::before{ 276 282 background-color: var(--color-gray-500); 277 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); 278 375 } 279 376 } 280 377 }
+16
src/utils/atoms.ts
··· 92 92 defaultVideoCDN 93 93 ); 94 94 95 + export const defaultLycanURL = ""; 96 + export const lycanURLAtom = atomWithStorage<string>( 97 + "lycanURL", 98 + defaultLycanURL 99 + ); 100 + 95 101 export const defaulthue = 28; 96 102 export const hueAtom = atomWithStorage<number>("hue", defaulthue); 97 103 ··· 137 143 "enableBitesAtom", 138 144 false 139 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 + );
+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
+384 -157
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"; 9 10 import { useAtom } from "jotai"; 10 11 11 - import { constellationURLAtom, slingshotURLAtom } from "./atoms"; 12 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 13 + 14 + import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms"; 12 15 13 - export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) { 16 + export function constructIdentityQuery( 17 + didorhandle?: string, 18 + slingshoturl?: string 19 + ) { 14 20 return queryOptions({ 15 21 queryKey: ["identity", didorhandle], 16 22 queryFn: async () => { 17 - if (!didorhandle) return undefined as undefined 23 + if (!didorhandle) return undefined as undefined; 18 24 const res = await fetch( 19 25 `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 20 26 ); ··· 31 37 } 32 38 }, 33 39 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 34 - gcTime: /*0//*/5 * 60 * 1000, 40 + gcTime: /*0//*/ 5 * 60 * 1000, 35 41 }); 36 42 } 37 43 export function useQueryIdentity(didorhandle: string): UseQueryResult< ··· 43 49 }, 44 50 Error 45 51 >; 46 - export function useQueryIdentity(): UseQueryResult< 47 - undefined, 48 - Error 49 - > 50 - export function useQueryIdentity(didorhandle?: string): 51 - UseQueryResult< 52 - { 53 - did: string; 54 - handle: string; 55 - pds: string; 56 - signing_key: string; 57 - } | undefined, 58 - Error 59 - > 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 + >; 60 63 export function useQueryIdentity(didorhandle?: string) { 61 - const [slingshoturl] = useAtom(slingshotURLAtom) 64 + const [slingshoturl] = useAtom(slingshotURLAtom); 62 65 return useQuery(constructIdentityQuery(didorhandle, slingshoturl)); 63 66 } 64 67 ··· 66 69 return queryOptions({ 67 70 queryKey: ["post", uri], 68 71 queryFn: async () => { 69 - if (!uri) return undefined as undefined 72 + if (!uri) return undefined as undefined; 70 73 const res = await fetch( 71 74 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 72 75 ); ··· 77 80 return undefined; 78 81 } 79 82 if (res.status === 400) return undefined; 80 - 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 + ) { 81 87 return undefined; // cache โ€œnot foundโ€ 82 88 } 83 89 try { 84 90 if (!res.ok) throw new Error("Failed to fetch post"); 85 - return (data) as { 91 + return data as { 86 92 uri: string; 87 93 cid: string; 88 94 value: any; ··· 97 103 return failureCount < 2; 98 104 }, 99 105 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 100 - gcTime: /*0//*/5 * 60 * 1000, 106 + gcTime: /*0//*/ 5 * 60 * 1000, 101 107 }); 102 108 } 103 109 export function useQueryPost(uri: string): UseQueryResult< ··· 108 114 }, 109 115 Error 110 116 >; 111 - export function useQueryPost(): UseQueryResult< 112 - undefined, 113 - Error 114 - > 115 - export function useQueryPost(uri?: string): 116 - UseQueryResult< 117 - { 118 - uri: string; 119 - cid: string; 120 - value: ATPAPI.AppBskyFeedPost.Record; 121 - } | undefined, 122 - Error 123 - > 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 + >; 124 127 export function useQueryPost(uri?: string) { 125 - const [slingshoturl] = useAtom(slingshotURLAtom) 128 + const [slingshoturl] = useAtom(slingshotURLAtom); 126 129 return useQuery(constructPostQuery(uri, slingshoturl)); 127 130 } 128 131 ··· 130 133 return queryOptions({ 131 134 queryKey: ["profile", uri], 132 135 queryFn: async () => { 133 - if (!uri) return undefined as undefined 136 + if (!uri) return undefined as undefined; 134 137 const res = await fetch( 135 138 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 136 139 ); ··· 141 144 return undefined; 142 145 } 143 146 if (res.status === 400) return undefined; 144 - 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 + ) { 145 151 return undefined; // cache โ€œnot foundโ€ 146 152 } 147 153 try { 148 154 if (!res.ok) throw new Error("Failed to fetch post"); 149 - return (data) as { 155 + return data as { 150 156 uri: string; 151 157 cid: string; 152 158 value: any; ··· 161 167 return failureCount < 2; 162 168 }, 163 169 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 164 - gcTime: /*0//*/5 * 60 * 1000, 170 + gcTime: /*0//*/ 5 * 60 * 1000, 165 171 }); 166 172 } 167 173 export function useQueryProfile(uri: string): UseQueryResult< ··· 172 178 }, 173 179 Error 174 180 >; 175 - export function useQueryProfile(): UseQueryResult< 176 - undefined, 177 - Error 178 - >; 179 - export function useQueryProfile(uri?: string): 180 - UseQueryResult< 181 - { 181 + export function useQueryProfile(): UseQueryResult<undefined, Error>; 182 + export function useQueryProfile(uri?: string): UseQueryResult< 183 + | { 182 184 uri: string; 183 185 cid: string; 184 186 value: ATPAPI.AppBskyActorProfile.Record; 185 - } | undefined, 186 - Error 187 - > 187 + } 188 + | undefined, 189 + Error 190 + >; 188 191 export function useQueryProfile(uri?: string) { 189 - const [slingshoturl] = useAtom(slingshotURLAtom) 192 + const [slingshoturl] = useAtom(slingshotURLAtom); 190 193 return useQuery(constructProfileQuery(uri, slingshoturl)); 191 194 } 192 195 ··· 222 225 // method: "/links/all", 223 226 // target: string 224 227 // ): QueryOptions<linksAllResponse, Error>; 225 - export function constructConstellationQuery(query?:{ 226 - constellation: string, 228 + export function constructConstellationQuery(query?: { 229 + constellation: string; 227 230 method: 228 231 | "/links" 229 232 | "/links/distinct-dids" 230 233 | "/links/count" 231 234 | "/links/count/distinct-dids" 232 235 | "/links/all" 233 - | "undefined", 234 - target: string, 235 - collection?: string, 236 - path?: string, 237 - cursor?: string, 238 - dids?: string[] 239 - } 240 - ) { 236 + | "undefined"; 237 + target: string; 238 + collection?: string; 239 + path?: string; 240 + cursor?: string; 241 + dids?: string[]; 242 + }) { 241 243 // : QueryOptions< 242 244 // | linksRecordsResponse 243 245 // | linksDidsResponse ··· 247 249 // Error 248 250 // > 249 251 return queryOptions({ 250 - 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, 251 261 queryFn: async () => { 252 - if (!query || query.method === "undefined") return undefined as undefined 253 - const method = query.method 254 - const target = query.target 255 - const collection = query?.collection 256 - const path = query?.path 257 - const cursor = query.cursor 258 - 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; 259 269 const res = await fetch( 260 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("") : ""}` 261 271 ); ··· 281 291 }, 282 292 // enforce short lifespan 283 293 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 284 - gcTime: /*0//*/5 * 60 * 1000, 294 + gcTime: /*0//*/ 5 * 60 * 1000, 285 295 }); 286 296 } 287 297 // todo do more of these instead of overloads since overloads sucks so much apparently ··· 293 303 cursor?: string; 294 304 }): UseQueryResult<linksCountResponse, Error> | undefined { 295 305 //if (!query) return; 296 - const [constellationurl] = useAtom(constellationURLAtom) 306 + const [constellationurl] = useAtom(constellationURLAtom); 297 307 const queryres = useQuery( 298 - constructConstellationQuery(query && {constellation: constellationurl, ...query}) 308 + constructConstellationQuery( 309 + query && { constellation: constellationurl, ...query } 310 + ) 299 311 ) as unknown as UseQueryResult<linksCountResponse, Error>; 300 312 if (!query) { 301 - return undefined as undefined; 313 + return undefined as undefined; 302 314 } 303 315 return queryres as UseQueryResult<linksCountResponse, Error>; 304 316 } ··· 365 377 > 366 378 | undefined { 367 379 //if (!query) return; 368 - const [constellationurl] = useAtom(constellationURLAtom) 380 + const [constellationurl] = useAtom(constellationURLAtom); 369 381 return useQuery( 370 - constructConstellationQuery(query && {constellation: constellationurl, ...query}) 382 + constructConstellationQuery( 383 + query && { constellation: constellationurl, ...query } 384 + ) 371 385 ); 372 386 } 373 387 ··· 411 425 }) { 412 426 return queryOptions({ 413 427 // The query key includes all dependencies to ensure it refetches when they change 414 - 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 + ], 415 433 queryFn: async () => { 416 - if (!options) return undefined as undefined 434 + if (!options) return undefined as undefined; 417 435 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 418 436 if (isAuthed) { 419 437 // Authenticated flow 420 438 if (!agent || !pdsUrl || !feedServiceDid) { 421 - throw new Error("Missing required info for authenticated feed fetch."); 439 + throw new Error( 440 + "Missing required info for authenticated feed fetch." 441 + ); 422 442 } 423 443 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 424 444 const res = await agent.fetchHandler(url, { ··· 428 448 "Content-Type": "application/json", 429 449 }, 430 450 }); 431 - 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}`); 432 453 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 433 454 } else { 434 455 // Unauthenticated flow (using a public PDS/AppView) 435 456 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 436 457 const res = await fetch(url); 437 - 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}`); 438 460 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 439 461 } 440 462 }, ··· 452 474 return useQuery(constructFeedSkeletonQuery(options)); 453 475 } 454 476 455 - export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { 477 + export function constructPreferencesQuery( 478 + agent?: ATPAPI.Agent | undefined, 479 + pdsUrl?: string | undefined 480 + ) { 456 481 return queryOptions({ 457 - queryKey: ['preferences', agent?.did], 482 + queryKey: ["preferences", agent?.did], 458 483 queryFn: async () => { 459 484 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 460 485 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; ··· 465 490 }); 466 491 } 467 492 export function useQueryPreferences(options: { 468 - agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined 493 + agent?: ATPAPI.Agent | undefined; 494 + pdsUrl?: string | undefined; 469 495 }) { 470 496 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 471 497 } 472 498 473 - 474 - 475 499 export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { 476 500 return queryOptions({ 477 501 queryKey: ["arbitrary", uri], 478 502 queryFn: async () => { 479 - if (!uri) return undefined as undefined 503 + if (!uri) return undefined as undefined; 480 504 const res = await fetch( 481 505 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 482 506 ); ··· 487 511 return undefined; 488 512 } 489 513 if (res.status === 400) return undefined; 490 - 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 + ) { 491 518 return undefined; // cache โ€œnot foundโ€ 492 519 } 493 520 try { 494 521 if (!res.ok) throw new Error("Failed to fetch post"); 495 - return (data) as { 522 + return data as { 496 523 uri: string; 497 524 cid: string; 498 525 value: any; ··· 507 534 return failureCount < 2; 508 535 }, 509 536 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 510 - gcTime: /*0//*/5 * 60 * 1000, 537 + gcTime: /*0//*/ 5 * 60 * 1000, 511 538 }); 512 539 } 513 540 export function useQueryArbitrary(uri: string): UseQueryResult< ··· 518 545 }, 519 546 Error 520 547 >; 521 - export function useQueryArbitrary(): UseQueryResult< 522 - undefined, 523 - Error 524 - >; 548 + export function useQueryArbitrary(): UseQueryResult<undefined, Error>; 525 549 export function useQueryArbitrary(uri?: string): UseQueryResult< 526 - { 527 - uri: string; 528 - cid: string; 529 - value: any; 530 - } | undefined, 550 + | { 551 + uri: string; 552 + cid: string; 553 + value: any; 554 + } 555 + | undefined, 531 556 Error 532 557 >; 533 558 export function useQueryArbitrary(uri?: string) { 534 - const [slingshoturl] = useAtom(slingshotURLAtom) 559 + const [slingshoturl] = useAtom(slingshotURLAtom); 535 560 return useQuery(constructArbitraryQuery(uri, slingshoturl)); 536 561 } 537 562 538 - export function constructFallbackNothingQuery(){ 563 + export function constructFallbackNothingQuery() { 539 564 return queryOptions({ 540 565 queryKey: ["nothing"], 541 566 queryFn: async () => { 542 - return undefined 567 + return undefined; 543 568 }, 544 569 }); 545 570 } ··· 553 578 }[]; 554 579 }; 555 580 556 - export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") { 581 + export function constructAuthorFeedQuery( 582 + did: string, 583 + pdsUrl: string, 584 + collection: string = "app.bsky.feed.post" 585 + ) { 557 586 return queryOptions({ 558 - queryKey: ['authorFeed', did, collection], 587 + queryKey: ["authorFeed", did, collection], 559 588 queryFn: async ({ pageParam }: QueryFunctionContext) => { 560 589 const limit = 25; 561 - 590 + 562 591 const cursor = pageParam as string | undefined; 563 - const cursorParam = cursor ? `&cursor=${cursor}` : ''; 564 - 592 + const cursorParam = cursor ? `&cursor=${cursor}` : ""; 593 + 565 594 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`; 566 - 595 + 567 596 const res = await fetch(url); 568 597 if (!res.ok) throw new Error("Failed to fetch author's posts"); 569 - 598 + 570 599 return res.json() as Promise<ListRecordsResponse>; 571 600 }, 572 601 }); 573 602 } 574 603 575 - export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) { 576 - const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection); 577 - 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 + 578 615 return useInfiniteQuery({ 579 616 queryKey, 580 617 queryFn, ··· 595 632 // todo the hell is a unauthedfeedurl 596 633 unauthedfeedurl?: string; 597 634 }) { 598 - const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = options; 599 - 635 + const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = 636 + options; 637 + 600 638 return queryOptions({ 601 639 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 602 - 603 - queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 640 + 641 + queryFn: async ({ 642 + pageParam, 643 + }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 604 644 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 605 - 645 + 606 646 if (isAuthed && !unauthedfeedurl) { 607 647 if (!agent || !pdsUrl || !feedServiceDid) { 608 - throw new Error("Missing required info for authenticated feed fetch."); 648 + throw new Error( 649 + "Missing required info for authenticated feed fetch." 650 + ); 609 651 } 610 652 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 611 653 const res = await agent.fetchHandler(url, { ··· 615 657 "Content-Type": "application/json", 616 658 }, 617 659 }); 618 - 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}`); 619 662 return (await res.json()) as FeedSkeletonPage; 620 663 } else { 621 664 const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 622 665 const res = await fetch(url); 623 - 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}`); 624 668 return (await res.json()) as FeedSkeletonPage; 625 669 } 626 670 }, ··· 636 680 unauthedfeedurl?: string; 637 681 }) { 638 682 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 639 - 640 - return {...useInfiniteQuery({ 641 - queryKey, 642 - queryFn, 643 - initialPageParam: undefined as never, 644 - getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 645 - staleTime: Infinity, 646 - refetchOnWindowFocus: false, 647 - enabled: !!options.feedUri && (options.isAuthed ? (!!options.agent && !!options.pdsUrl || !!options.unauthedfeedurl) && !!options.feedServiceDid : true), 648 - }), queryKey: queryKey}; 649 - } 650 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 + }; 702 + } 651 703 652 704 export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 653 - constellation: string, 654 - method: '/links' 655 - target?: string 656 - collection: string 657 - path: string, 658 - staleMult?: number 705 + constellation: string; 706 + method: "/links"; 707 + target?: string; 708 + collection: string; 709 + path: string; 710 + staleMult?: number; 659 711 }) { 660 712 const safemult = query?.staleMult ?? 1; 661 713 // console.log( ··· 666 718 return infiniteQueryOptions({ 667 719 enabled: !!query?.target, 668 720 queryKey: [ 669 - 'reddwarf_constellation', 721 + "reddwarf_constellation", 670 722 query?.method, 671 723 query?.target, 672 724 query?.collection, 673 725 query?.path, 674 726 ] as const, 675 727 676 - queryFn: async ({pageParam}: {pageParam?: string}) => { 677 - if (!query || !query?.target) return undefined 728 + queryFn: async ({ pageParam }: { pageParam?: string }) => { 729 + if (!query || !query?.target) return undefined; 678 730 679 - const method = query.method 680 - const target = query.target 681 - const collection = query.collection 682 - const path = query.path 683 - 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; 684 736 685 737 const res = await fetch( 686 738 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${ 687 - collection ? `&collection=${encodeURIComponent(collection)}` : '' 688 - }${path ? `&path=${encodeURIComponent(path)}` : ''}${ 689 - cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' 690 - }`, 691 - ) 739 + collection ? `&collection=${encodeURIComponent(collection)}` : "" 740 + }${path ? `&path=${encodeURIComponent(path)}` : ""}${ 741 + cursor ? `&cursor=${encodeURIComponent(cursor)}` : "" 742 + }` 743 + ); 692 744 693 - if (!res.ok) throw new Error('Failed to fetch') 745 + if (!res.ok) throw new Error("Failed to fetch"); 694 746 695 - return (await res.json()) as linksRecordsResponse 747 + return (await res.json()) as linksRecordsResponse; 696 748 }, 697 749 698 - getNextPageParam: lastPage => { 699 - return (lastPage as any)?.cursor ?? undefined 750 + getNextPageParam: (lastPage) => { 751 + return (lastPage as any)?.cursor ?? undefined; 700 752 }, 701 753 initialPageParam: undefined, 702 754 staleTime: 5 * 60 * 1000 * safemult, 703 755 gcTime: 5 * 60 * 1000 * safemult, 704 - }) 705 - } 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 + }
+5
vite.config.ts
··· 13 13 const PROD_URL = "https://reddwarf.app" 14 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 15 16 + const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party" 17 + const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social" 18 + 16 19 function shp(url: string): string { 17 20 return url.replace(/^https?:\/\//, ''); 18 21 } ··· 23 26 generateMetadataPlugin({ 24 27 prod: PROD_URL, 25 28 dev: DEV_URL, 29 + prodResolver: PROD_HANDLE_RESOLVER_PDS, 30 + devResolver: DEV_HANDLE_RESOLVER_PDS, 26 31 }), 27 32 TanStackRouterVite({ autoCodeSplitting: true }), 28 33 viteReact({