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 .env 8 .nitro 9 .tanstack 10 - public/client-metadata.json
··· 7 .env 8 .nitro 9 .tanstack 10 + public/client-metadata.json 11 + public/resolvers.json
+9
README.md
··· 15 16 run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder) 17 18 ## useQuery 19 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
··· 15 16 run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder) 17 18 + 19 + 20 + you probably dont need to change these 21 + ```ts 22 + const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party" 23 + const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social" 24 + ``` 25 + if you do want to change these, i recommend changing both of these to your own PDS url. i separate the prod and dev urls so that you can change it as needed. here i separated it because if the prod resolver and prod url shares the same domain itll error and prevent logins 26 + 27 ## useQuery 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! 29
+62 -30
oauthdev.mts
··· 1 - import fs from 'fs'; 2 - import path from 'path'; 3 //import { generateClientMetadata } from './src/helpers/oauthClient' 4 export const generateClientMetadata = (appOrigin: string) => { 5 - const callbackPath = '/callback'; 6 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 - 24 25 - export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) { 26 return { 27 - name: 'vite-plugin-generate-metadata', 28 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.'); 34 } 35 } else { 36 appOrigin = dev; 37 } 38 - 39 - 40 const metadata = generateClientMetadata(appOrigin); 41 - const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json'); 42 43 fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2)); 44 45 // /*mass comment*/ console.log(`โœ… Generated client-metadata.json for ${appOrigin}`); 46 }, 47 }; 48 - }
··· 1 + import fs from "fs"; 2 + import path from "path"; 3 //import { generateClientMetadata } from './src/helpers/oauthClient' 4 export const generateClientMetadata = (appOrigin: string) => { 5 + const callbackPath = "/callback"; 6 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 [ 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 + }; 26 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 + }) { 38 return { 39 + name: "vite-plugin-generate-metadata", 40 config(_config: any, { mode }: any) { 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 + ); 50 } 51 } else { 52 appOrigin = dev; 53 + resolver = devResolver; 54 } 55 + 56 const metadata = generateClientMetadata(appOrigin); 57 + const outputPath = path.resolve( 58 + process.cwd(), 59 + "public", 60 + "client-metadata.json" 61 + ); 62 63 fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2)); 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 + 77 // /*mass comment*/ console.log(`โœ… Generated client-metadata.json for ${appOrigin}`); 78 }, 79 }; 80 + }
+10
package-lock.json
··· 29 "react": "^19.0.0", 30 "react-dom": "^19.0.0", 31 "react-player": "^3.3.2", 32 "tailwindcss": "^4.0.6", 33 "tanstack-router-keepalive": "^1.0.0" 34 }, ··· 12543 "csstype": "^3.1.0", 12544 "seroval": "~1.3.0", 12545 "seroval-plugins": "~1.3.0" 12546 } 12547 }, 12548 "node_modules/source-map": {
··· 29 "react": "^19.0.0", 30 "react-dom": "^19.0.0", 31 "react-player": "^3.3.2", 32 + "sonner": "^2.0.7", 33 "tailwindcss": "^4.0.6", 34 "tanstack-router-keepalive": "^1.0.0" 35 }, ··· 12544 "csstype": "^3.1.0", 12545 "seroval": "~1.3.0", 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" 12556 } 12557 }, 12558 "node_modules/source-map": {
+1
package.json
··· 33 "react": "^19.0.0", 34 "react-dom": "^19.0.0", 35 "react-player": "^3.3.2", 36 "tailwindcss": "^4.0.6", 37 "tanstack-router-keepalive": "^1.0.0" 38 },
··· 33 "react": "^19.0.0", 34 "react-dom": "^19.0.0", 35 "react-player": "^3.3.2", 36 + "sonner": "^2.0.7", 37 "tailwindcss": "^4.0.6", 38 "tanstack-router-keepalive": "^1.0.0" 39 },
+3
src/auto-imports.d.ts
··· 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default 23 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 24 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 25 }
··· 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default 23 + const IconMdiClose: typeof import('~icons/mdi/close.jsx').default 24 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 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 28 }
+37 -3
src/components/Import.tsx
··· 1 import { AtUri } from "@atproto/api"; 2 import { useNavigate, type UseNavigateResult } from "@tanstack/react-router"; 3 import { useState } from "react"; 4 5 /** 6 * Basically the best equivalent to Search that i can do 7 */ 8 - export function Import() { 9 - const [textInput, setTextInput] = useState<string | undefined>(); 10 const navigate = useNavigate(); 11 12 const handleEnter = () => { 13 if (!textInput) return; 14 handleImport({ 15 text: textInput, 16 navigate, 17 }); 18 }; 19 20 return ( 21 <div className="w-full relative"> ··· 23 24 <input 25 type="text" 26 - placeholder="Import..." 27 value={textInput} 28 onChange={(e) => setTextInput(e.target.value)} 29 onKeyDown={(e) => { ··· 38 function handleImport({ 39 text, 40 navigate, 41 }: { 42 text: string; 43 navigate: UseNavigateResult<string>; 44 }) { 45 const trimmed = text.trim(); 46 // parse text ··· 147 // } catch { 148 // // continue 149 // } 150 }
··· 1 import { AtUri } from "@atproto/api"; 2 import { useNavigate, type UseNavigateResult } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 import { useState } from "react"; 5 + 6 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { lycanURLAtom } from "~/utils/atoms"; 8 + import { useQueryLycanStatus } from "~/utils/useQuery"; 9 10 /** 11 * Basically the best equivalent to Search that i can do 12 */ 13 + export function Import({ 14 + optionaltextstring, 15 + }: { 16 + optionaltextstring?: string; 17 + }) { 18 + const [textInput, setTextInput] = useState<string | undefined>( 19 + optionaltextstring 20 + ); 21 const navigate = useNavigate(); 22 23 + const { status } = useAuth(); 24 + const [lycandomain] = useAtom(lycanURLAtom); 25 + const lycanExists = lycandomain !== ""; 26 + const { data: lycanstatusdata } = useQueryLycanStatus(); 27 + const lycanIndexed = lycanstatusdata?.status === "finished" || false; 28 + const lycanIndexing = lycanstatusdata?.status === "in_progress" || false; 29 + const lycanIndexingProgress = lycanIndexing 30 + ? lycanstatusdata?.progress 31 + : undefined; 32 + const authed = status === "signedIn"; 33 + 34 + const lycanReady = lycanExists && lycanIndexed && authed; 35 + 36 const handleEnter = () => { 37 if (!textInput) return; 38 handleImport({ 39 text: textInput, 40 navigate, 41 + lycanReady: 42 + lycanReady || (!!lycanIndexingProgress && lycanIndexingProgress > 0), 43 }); 44 }; 45 + 46 + const placeholder = lycanReady ? "Search..." : "Import..."; 47 48 return ( 49 <div className="w-full relative"> ··· 51 52 <input 53 type="text" 54 + placeholder={placeholder} 55 value={textInput} 56 onChange={(e) => setTextInput(e.target.value)} 57 onKeyDown={(e) => { ··· 66 function handleImport({ 67 text, 68 navigate, 69 + lycanReady, 70 }: { 71 text: string; 72 navigate: UseNavigateResult<string>; 73 + lycanReady?: boolean; 74 }) { 75 const trimmed = text.trim(); 76 // parse text ··· 177 // } catch { 178 // // continue 179 // } 180 + 181 + if (lycanReady) { 182 + navigate({ to: "/search", search: { q: text } }); 183 + } 184 }
+136 -27
src/components/UniversalPostRenderer.tsx
··· 1 - import * as ATPAPI from "@atproto/api" 2 import { useNavigate } from "@tanstack/react-router"; 3 import DOMPurify from "dompurify"; 4 import { useAtom } from "jotai"; ··· 10 import { 11 composerAtom, 12 constellationURLAtom, 13 imgCDNAtom, 14 } from "~/utils/atoms"; 15 import { useHydratedEmbed } from "~/utils/useHydrated"; ··· 162 isQuote, 163 filterNoReplies, 164 filterMustHaveMedia, 165 - filterMustBeReply 166 }: UniversalPostRendererATURILoaderProps) { 167 // todo remove this once tree rendering is implemented, use a prop like isTree 168 const TEMPLINEAR = true; ··· 526 ? true 527 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 528 ? false 529 - : (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine 530 } 531 topReplyLine={topReplyLine} 532 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 553 filterMustBeReply={filterMustBeReply} 554 /> 555 <> 556 - {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( 557 <> 558 - {/* <div>hello</div> */} 559 - <MoreReplies atUri={atUri} /> 560 </> 561 - ) : (<></>)} 562 </> 563 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 564 <> ··· 755 const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; 756 const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; 757 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"; 760 761 - const hasMedia = hasEmbed && (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo); 762 763 const { 764 data: hydratedEmbed, ··· 854 // }, [fakepost, get, set]); 855 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 856 ?.uri; 857 - const feedviewpostreplydid = thereply&&!filterNoReplies ? new AtUri(thereply).host : undefined; 858 const replyhookvalue = useQueryIdentity( 859 feedviewpost ? feedviewpostreplydid : undefined 860 ); ··· 1237 1238 import defaultpfp from "~/../public/favicon.png"; 1239 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1240 - import { FeedItemRenderAturiLoader, FollowButton, Mutual } from "~/routes/profile.$did"; 1241 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1242 import { useFastLike } from "~/utils/likeMutationQueue"; 1243 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; ··· 1446 : undefined; 1447 1448 const emergencySalt = randomString(); 1449 - const fedi = (post.record as { bridgyOriginalText?: string }) 1450 .bridgyOriginalText; 1451 1452 /* fuck you */ 1453 const isMainItem = false; ··· 1586 {post.author.displayName || post.author.handle}{" "} 1587 </div> 1588 <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}{" "} 1590 </div> 1591 </div> 1592 {uprrrsauthor?.description && ( ··· 1834 </div> 1835 </> 1836 )} 1837 - <div style={{ paddingTop: post.embed && !concise && depth < 1 ? 4 : 0 }}> 1838 <> 1839 {expanded && ( 1840 <div ··· 1952 "/post/" + 1953 post.uri.split("/").pop() 1954 ); 1955 } catch (_e) { 1956 // idk 1957 } 1958 }} 1959 style={{ ··· 1962 > 1963 <MdiShareVariant /> 1964 </HitSlopButton> 1965 - <span style={btnstyle}> 1966 - <MdiMoreHoriz /> 1967 - </span> 1968 </div> 1969 </div> 1970 )} ··· 2203 // <MaybeFeedCard view={embed.record} /> 2204 // </div> 2205 // ) 2206 - } else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.feed.generator") { 2207 - return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder/></div> 2208 } 2209 2210 // list embed ··· 2216 // <MaybeListCard view={embed.record} /> 2217 // </div> 2218 // ) 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> 2221 } 2222 2223 // starter pack embed ··· 2229 // <StarterPackCard starterPack={embed.record} /> 2230 // </div> 2231 // ) 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> 2234 } 2235 2236 // quote post ··· 2724 className="link" 2725 style={{ 2726 textDecoration: "none", 2727 - color: "rgb(29, 122, 242)", 2728 wordBreak: "break-all", 2729 }} 2730 target="_blank" ··· 2744 result.push( 2745 <span 2746 key={start} 2747 - style={{ color: "rgb(29, 122, 242)" }} 2748 className=" cursor-pointer" 2749 onClick={(e) => { 2750 e.stopPropagation(); ··· 2762 result.push( 2763 <span 2764 key={start} 2765 - style={{ color: "rgb(29, 122, 242)" }} 2766 onClick={(e) => { 2767 e.stopPropagation(); 2768 }}
··· 1 + import * as ATPAPI from "@atproto/api"; 2 import { useNavigate } from "@tanstack/react-router"; 3 import DOMPurify from "dompurify"; 4 import { useAtom } from "jotai"; ··· 10 import { 11 composerAtom, 12 constellationURLAtom, 13 + enableBridgyTextAtom, 14 + enableWafrnTextAtom, 15 imgCDNAtom, 16 } from "~/utils/atoms"; 17 import { useHydratedEmbed } from "~/utils/useHydrated"; ··· 164 isQuote, 165 filterNoReplies, 166 filterMustHaveMedia, 167 + filterMustBeReply, 168 }: UniversalPostRendererATURILoaderProps) { 169 // todo remove this once tree rendering is implemented, use a prop like isTree 170 const TEMPLINEAR = true; ··· 528 ? true 529 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 530 ? false 531 + : maxReplies === 0 && (!replies || (!!replies && replies === 0)) 532 + ? false 533 + : bottomReplyLine 534 } 535 topReplyLine={topReplyLine} 536 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 557 filterMustBeReply={filterMustBeReply} 558 /> 559 <> 560 + {maxReplies && maxReplies === 0 && replies && replies > 0 ? ( 561 <> 562 + {/* <div>hello</div> */} 563 + <MoreReplies atUri={atUri} /> 564 </> 565 + ) : ( 566 + <></> 567 + )} 568 </> 569 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 570 <> ··· 761 const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; 762 const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; 763 const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia"; 764 + const isQuotewithImages = 765 + isquotewithmedia && 766 + (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 767 + "app.bsky.embed.images"; 768 + const isQuotewithVideo = 769 + isquotewithmedia && 770 + (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 771 + "app.bsky.embed.video"; 772 773 + const hasMedia = 774 + hasEmbed && 775 + (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo); 776 777 const { 778 data: hydratedEmbed, ··· 868 // }, [fakepost, get, set]); 869 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 870 ?.uri; 871 + const feedviewpostreplydid = 872 + thereply && !filterNoReplies ? new AtUri(thereply).host : undefined; 873 const replyhookvalue = useQueryIdentity( 874 feedviewpost ? feedviewpostreplydid : undefined 875 ); ··· 1252 1253 import defaultpfp from "~/../public/favicon.png"; 1254 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1255 + import { renderSnack } from "~/routes/__root"; 1256 + import { 1257 + FeedItemRenderAturiLoader, 1258 + FollowButton, 1259 + Mutual, 1260 + } from "~/routes/profile.$did"; 1261 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1262 import { useFastLike } from "~/utils/likeMutationQueue"; 1263 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; ··· 1466 : undefined; 1467 1468 const emergencySalt = randomString(); 1469 + 1470 + const [showBridgyText] = useAtom(enableBridgyTextAtom); 1471 + const [showWafrnText] = useAtom(enableWafrnTextAtom); 1472 + 1473 + const unfedibridgy = (post.record as { bridgyOriginalText?: string }) 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); 1507 1508 /* fuck you */ 1509 const isMainItem = false; ··· 1642 {post.author.displayName || post.author.handle}{" "} 1643 </div> 1644 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1645 + <Mutual targetdidorhandle={post.author.did} />@ 1646 + {post.author.handle}{" "} 1647 </div> 1648 </div> 1649 {uprrrsauthor?.description && ( ··· 1891 </div> 1892 </> 1893 )} 1894 + <div 1895 + style={{ 1896 + paddingTop: post.embed && !concise && depth < 1 ? 4 : 0, 1897 + }} 1898 + > 1899 <> 1900 {expanded && ( 1901 <div ··· 2013 "/post/" + 2014 post.uri.split("/").pop() 2015 ); 2016 + renderSnack({ 2017 + title: "Copied to clipboard!", 2018 + }); 2019 } catch (_e) { 2020 // idk 2021 + renderSnack({ 2022 + title: "Failed to copy link", 2023 + }); 2024 } 2025 }} 2026 style={{ ··· 2029 > 2030 <MdiShareVariant /> 2031 </HitSlopButton> 2032 + <HitSlopButton 2033 + onClick={() => { 2034 + renderSnack({ 2035 + title: "Not implemented yet...", 2036 + }); 2037 + }} 2038 + > 2039 + <span style={btnstyle}> 2040 + <MdiMoreHoriz /> 2041 + </span> 2042 + </HitSlopButton> 2043 </div> 2044 </div> 2045 )} ··· 2278 // <MaybeFeedCard view={embed.record} /> 2279 // </div> 2280 // ) 2281 + } else if ( 2282 + !!reallybaduri && 2283 + !!reallybadaturi && 2284 + reallybadaturi.collection === "app.bsky.feed.generator" 2285 + ) { 2286 + return ( 2287 + <div className="rounded-xl border"> 2288 + <FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder /> 2289 + </div> 2290 + ); 2291 } 2292 2293 // list embed ··· 2299 // <MaybeListCard view={embed.record} /> 2300 // </div> 2301 // ) 2302 + } else if ( 2303 + !!reallybaduri && 2304 + !!reallybadaturi && 2305 + reallybadaturi.collection === "app.bsky.graph.list" 2306 + ) { 2307 + return ( 2308 + <div className="rounded-xl border"> 2309 + <FeedItemRenderAturiLoader 2310 + aturi={reallybaduri} 2311 + disableBottomBorder 2312 + listmode 2313 + disablePropagation 2314 + /> 2315 + </div> 2316 + ); 2317 } 2318 2319 // starter pack embed ··· 2325 // <StarterPackCard starterPack={embed.record} /> 2326 // </div> 2327 // ) 2328 + } else if ( 2329 + !!reallybaduri && 2330 + !!reallybadaturi && 2331 + reallybadaturi.collection === "app.bsky.graph.starterpack" 2332 + ) { 2333 + return ( 2334 + <div className="rounded-xl border"> 2335 + <FeedItemRenderAturiLoader 2336 + aturi={reallybaduri} 2337 + disableBottomBorder 2338 + listmode 2339 + disablePropagation 2340 + /> 2341 + </div> 2342 + ); 2343 } 2344 2345 // quote post ··· 2833 className="link" 2834 style={{ 2835 textDecoration: "none", 2836 + color: "var(--link-text-color)", 2837 wordBreak: "break-all", 2838 }} 2839 target="_blank" ··· 2853 result.push( 2854 <span 2855 key={start} 2856 + style={{ color: "var(--link-text-color)" }} 2857 className=" cursor-pointer" 2858 onClick={(e) => { 2859 e.stopPropagation(); ··· 2871 result.push( 2872 <span 2873 key={start} 2874 + style={{ color: "var(--link-text-color)" }} 2875 onClick={(e) => { 2876 e.stopPropagation(); 2877 }}
+6
src/providers/LikeMutationQueueProvider.tsx
··· 5 import React, { createContext, use, useCallback, useEffect, useRef } from "react"; 6 7 import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms"; 9 import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery"; 10 ··· 125 } 126 } catch (err) { 127 console.error("Like mutation failed, reverting:", err); 128 if (mutation.type === 'like') { 129 setFastState(mutation.target, null); 130 } else if (mutation.type === 'unlike') {
··· 5 import React, { createContext, use, useCallback, useEffect, useRef } from "react"; 6 7 import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 + import { renderSnack } from "~/routes/__root"; 9 import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms"; 10 import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery"; 11 ··· 126 } 127 } catch (err) { 128 console.error("Like mutation failed, reverting:", err); 129 + renderSnack({ 130 + title: 'Like Mutation Failed', 131 + description: 'Please try again.', 132 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 133 + }) 134 if (mutation.type === 'like') { 135 setFastState(mutation.target, null); 136 } else if (mutation.type === 'unlike') {
+21
src/routeTree.gen.ts
··· 12 import { Route as SettingsRouteImport } from './routes/settings' 13 import { Route as SearchRouteImport } from './routes/search' 14 import { Route as NotificationsRouteImport } from './routes/notifications' 15 import { Route as FeedsRouteImport } from './routes/feeds' 16 import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' 17 import { Route as IndexRouteImport } from './routes/index' ··· 42 const NotificationsRoute = NotificationsRouteImport.update({ 43 id: '/notifications', 44 path: '/notifications', 45 getParentRoute: () => rootRouteImport, 46 } as any) 47 const FeedsRoute = FeedsRouteImport.update({ ··· 133 export interface FileRoutesByFullPath { 134 '/': typeof IndexRoute 135 '/feeds': typeof FeedsRoute 136 '/notifications': typeof NotificationsRoute 137 '/search': typeof SearchRoute 138 '/settings': typeof SettingsRoute ··· 152 export interface FileRoutesByTo { 153 '/': typeof IndexRoute 154 '/feeds': typeof FeedsRoute 155 '/notifications': typeof NotificationsRoute 156 '/search': typeof SearchRoute 157 '/settings': typeof SettingsRoute ··· 173 '/': typeof IndexRoute 174 '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren 175 '/feeds': typeof FeedsRoute 176 '/notifications': typeof NotificationsRoute 177 '/search': typeof SearchRoute 178 '/settings': typeof SettingsRoute ··· 195 fullPaths: 196 | '/' 197 | '/feeds' 198 | '/notifications' 199 | '/search' 200 | '/settings' ··· 214 to: 215 | '/' 216 | '/feeds' 217 | '/notifications' 218 | '/search' 219 | '/settings' ··· 234 | '/' 235 | '/_pathlessLayout' 236 | '/feeds' 237 | '/notifications' 238 | '/search' 239 | '/settings' ··· 256 IndexRoute: typeof IndexRoute 257 PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren 258 FeedsRoute: typeof FeedsRoute 259 NotificationsRoute: typeof NotificationsRoute 260 SearchRoute: typeof SearchRoute 261 SettingsRoute: typeof SettingsRoute ··· 288 path: '/notifications' 289 fullPath: '/notifications' 290 preLoaderRoute: typeof NotificationsRouteImport 291 parentRoute: typeof rootRouteImport 292 } 293 '/feeds': { ··· 456 IndexRoute: IndexRoute, 457 PathlessLayoutRoute: PathlessLayoutRouteWithChildren, 458 FeedsRoute: FeedsRoute, 459 NotificationsRoute: NotificationsRoute, 460 SearchRoute: SearchRoute, 461 SettingsRoute: SettingsRoute,
··· 12 import { Route as SettingsRouteImport } from './routes/settings' 13 import { Route as SearchRouteImport } from './routes/search' 14 import { Route as NotificationsRouteImport } from './routes/notifications' 15 + import { Route as ModerationRouteImport } from './routes/moderation' 16 import { Route as FeedsRouteImport } from './routes/feeds' 17 import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' 18 import { Route as IndexRouteImport } from './routes/index' ··· 43 const NotificationsRoute = NotificationsRouteImport.update({ 44 id: '/notifications', 45 path: '/notifications', 46 + getParentRoute: () => rootRouteImport, 47 + } as any) 48 + const ModerationRoute = ModerationRouteImport.update({ 49 + id: '/moderation', 50 + path: '/moderation', 51 getParentRoute: () => rootRouteImport, 52 } as any) 53 const FeedsRoute = FeedsRouteImport.update({ ··· 139 export interface FileRoutesByFullPath { 140 '/': typeof IndexRoute 141 '/feeds': typeof FeedsRoute 142 + '/moderation': typeof ModerationRoute 143 '/notifications': typeof NotificationsRoute 144 '/search': typeof SearchRoute 145 '/settings': typeof SettingsRoute ··· 159 export interface FileRoutesByTo { 160 '/': typeof IndexRoute 161 '/feeds': typeof FeedsRoute 162 + '/moderation': typeof ModerationRoute 163 '/notifications': typeof NotificationsRoute 164 '/search': typeof SearchRoute 165 '/settings': typeof SettingsRoute ··· 181 '/': typeof IndexRoute 182 '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren 183 '/feeds': typeof FeedsRoute 184 + '/moderation': typeof ModerationRoute 185 '/notifications': typeof NotificationsRoute 186 '/search': typeof SearchRoute 187 '/settings': typeof SettingsRoute ··· 204 fullPaths: 205 | '/' 206 | '/feeds' 207 + | '/moderation' 208 | '/notifications' 209 | '/search' 210 | '/settings' ··· 224 to: 225 | '/' 226 | '/feeds' 227 + | '/moderation' 228 | '/notifications' 229 | '/search' 230 | '/settings' ··· 245 | '/' 246 | '/_pathlessLayout' 247 | '/feeds' 248 + | '/moderation' 249 | '/notifications' 250 | '/search' 251 | '/settings' ··· 268 IndexRoute: typeof IndexRoute 269 PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren 270 FeedsRoute: typeof FeedsRoute 271 + ModerationRoute: typeof ModerationRoute 272 NotificationsRoute: typeof NotificationsRoute 273 SearchRoute: typeof SearchRoute 274 SettingsRoute: typeof SettingsRoute ··· 301 path: '/notifications' 302 fullPath: '/notifications' 303 preLoaderRoute: typeof NotificationsRouteImport 304 + parentRoute: typeof rootRouteImport 305 + } 306 + '/moderation': { 307 + id: '/moderation' 308 + path: '/moderation' 309 + fullPath: '/moderation' 310 + preLoaderRoute: typeof ModerationRouteImport 311 parentRoute: typeof rootRouteImport 312 } 313 '/feeds': { ··· 476 IndexRoute: IndexRoute, 477 PathlessLayoutRoute: PathlessLayoutRouteWithChildren, 478 FeedsRoute: FeedsRoute, 479 + ModerationRoute: ModerationRoute, 480 NotificationsRoute: NotificationsRoute, 481 SearchRoute: SearchRoute, 482 SettingsRoute: SettingsRoute,
+170 -11
src/routes/__root.tsx
··· 14 import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 15 import { useAtom } from "jotai"; 16 import * as React from "react"; 17 import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive"; 18 19 import { Composer } from "~/components/Composer"; ··· 83 <LikeMutationQueueProvider> 84 <RootDocument> 85 <KeepAliveProvider> 86 <KeepAliveOutlet /> 87 </KeepAliveProvider> 88 </RootDocument> ··· 91 ); 92 } 93 94 function RootDocument({ children }: { children: React.ReactNode }) { 95 useAtomCssVar(hueAtom, "--tw-gray-hue"); 96 const location = useLocation(); ··· 106 const isSettings = location.pathname.startsWith("/settings"); 107 const isSearch = location.pathname.startsWith("/search"); 108 const isFeeds = location.pathname.startsWith("/feeds"); 109 110 const locationEnum: 111 | "feeds" ··· 113 | "settings" 114 | "notifications" 115 | "profile" 116 | "home" = isFeeds 117 ? "feeds" 118 : isSearch ··· 123 ? "notifications" 124 : isProfile 125 ? "profile" 126 - : "home"; 127 128 const [, setComposerPost] = useAtom(composerAtom); 129 ··· 134 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 135 <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 <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))"}} /> 138 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 139 Red Dwarf{" "} 140 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 198 text="Feeds" 199 /> 200 <MaterialNavItem 201 InactiveIcon={ 202 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 203 } ··· 235 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 236 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 237 //active={true} 238 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 239 text="Post" 240 /> 241 </div> ··· 373 374 <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 <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))"}} /> 377 </div> 378 <MaterialNavItem 379 small ··· 436 /> 437 <MaterialNavItem 438 small 439 InactiveIcon={ 440 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 441 } ··· 475 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 476 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 477 //active={true} 478 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 479 text="Post" 480 /> 481 </div> ··· 485 <button 486 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 style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }} 488 - onClick={() => setComposerPost({ kind: 'root' })} 489 type="button" 490 aria-label="Create Post" 491 > ··· 502 </main> 503 504 <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> 506 <Login /> 507 508 <div className="flex-1"></div> 509 <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) 511 </p> 512 </aside> 513 </div> ··· 654 <IconMaterialSymbolsSettingsOutline className="w-6 h-6" /> 655 } 656 ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />} 657 - active={locationEnum === "settings"} 658 onClickCallbback={() => 659 navigate({ 660 to: "/settings", ··· 683 ) : ( 684 <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 <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))"}} /> 687 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 688 Red Dwarf{" "} 689 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 703 ); 704 } 705 706 - function MaterialNavItem({ 707 InactiveIcon, 708 ActiveIcon, 709 text,
··· 14 import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 15 import { useAtom } from "jotai"; 16 import * as React from "react"; 17 + import { toast as sonnerToast } from "sonner"; 18 + import { Toaster } from "sonner"; 19 import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive"; 20 21 import { Composer } from "~/components/Composer"; ··· 85 <LikeMutationQueueProvider> 86 <RootDocument> 87 <KeepAliveProvider> 88 + <AppToaster /> 89 <KeepAliveOutlet /> 90 </KeepAliveProvider> 91 </RootDocument> ··· 94 ); 95 } 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 + 201 function RootDocument({ children }: { children: React.ReactNode }) { 202 useAtomCssVar(hueAtom, "--tw-gray-hue"); 203 const location = useLocation(); ··· 213 const isSettings = location.pathname.startsWith("/settings"); 214 const isSearch = location.pathname.startsWith("/search"); 215 const isFeeds = location.pathname.startsWith("/feeds"); 216 + const isModeration = location.pathname.startsWith("/moderation"); 217 218 const locationEnum: 219 | "feeds" ··· 221 | "settings" 222 | "notifications" 223 | "profile" 224 + | "moderation" 225 | "home" = isFeeds 226 ? "feeds" 227 : isSearch ··· 232 ? "notifications" 233 : isProfile 234 ? "profile" 235 + : isModeration 236 + ? "moderation" 237 + : "home"; 238 239 const [, setComposerPost] = useAtom(composerAtom); 240 ··· 245 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 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"> 247 <div className="flex items-center gap-3 mb-4"> 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 + /> 255 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 256 Red Dwarf{" "} 257 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 315 text="Feeds" 316 /> 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 330 InactiveIcon={ 331 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 332 } ··· 364 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 365 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 366 //active={true} 367 + onClickCallbback={() => setComposerPost({ kind: "root" })} 368 text="Post" 369 /> 370 </div> ··· 502 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"> 504 <div className="flex items-center gap-3 mb-4"> 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 + /> 512 </div> 513 <MaterialNavItem 514 small ··· 571 /> 572 <MaterialNavItem 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 587 InactiveIcon={ 588 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 589 } ··· 623 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 624 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 625 //active={true} 626 + onClickCallbback={() => setComposerPost({ kind: "root" })} 627 text="Post" 628 /> 629 </div> ··· 633 <button 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" 635 style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }} 636 + onClick={() => setComposerPost({ kind: "root" })} 637 type="button" 638 aria-label="Create Post" 639 > ··· 650 </main> 651 652 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 653 + <div className="px-4 pt-4"> 654 + <Import /> 655 + </div> 656 <Login /> 657 658 <div className="flex-1"></div> 659 <p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4"> 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) 664 </p> 665 </aside> 666 </div> ··· 807 <IconMaterialSymbolsSettingsOutline className="w-6 h-6" /> 808 } 809 ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />} 810 + active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"} 811 onClickCallbback={() => 812 navigate({ 813 to: "/settings", ··· 836 ) : ( 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"> 838 <div className="flex items-center gap-2"> 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 + /> 846 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 847 Red Dwarf{" "} 848 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 862 ); 863 } 864 865 + export function MaterialNavItem({ 866 InactiveIcon, 867 ActiveIcon, 868 text,
+18 -1
src/routes/feeds.tsx
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 3 export const Route = createFileRoute("/feeds")({ 4 component: Feeds, 5 }); 6 7 export function Feeds() { 8 - return <div className="p-6">Feeds page (coming soon)</div>; 9 }
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 3 + import { Header } from "~/components/Header"; 4 + 5 export const Route = createFileRoute("/feeds")({ 6 component: Feeds, 7 }); 8 9 export function Feeds() { 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 + ); 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 ); 573 } 574 575 - export function NotificationItem({ notification }: { notification: string }) { 576 const aturi = new AtUri(notification); 577 const bite = aturi.collection === "net.wafrn.feed.bite"; 578 const navigate = useNavigate(); ··· 618 <img 619 src={avatar || defaultpfp} 620 alt={identity?.handle} 621 - className="w-10 h-10 rounded-full" 622 /> 623 ) : ( 624 <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
··· 572 ); 573 } 574 575 + export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) { 576 const aturi = new AtUri(notification); 577 const bite = aturi.collection === "net.wafrn.feed.bite"; 578 const navigate = useNavigate(); ··· 618 <img 619 src={avatar || defaultpfp} 620 alt={identity?.handle} 621 + className={`w-10 h-10 ${labeler ? "rounded-md" : "rounded-full"}`} 622 /> 623 ) : ( 624 <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
+218 -48
src/routes/profile.$did/index.tsx
··· 32 useQueryIdentity, 33 useQueryProfile, 34 } from "~/utils/useQuery"; 35 36 import { Chip } from "../notifications"; 37 38 export const Route = createFileRoute("/profile/$did/")({ ··· 51 error: identityError, 52 } = useQueryIdentity(did); 53 54 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 55 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; 56 const pdsUrl = identity?.pds; ··· 81 82 const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; 83 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) 90 91 const followercount = resultwhateversure?.data?.total; 92 ··· 136 137 {/* Avatar (PFP) */} 138 <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 - /> 144 </div> 145 146 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> ··· 152 also save it persistently 153 */} 154 <FollowButton targetdidorhandle={did} /> 155 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 156 ... {/* todo: icon */} 157 </button> 158 </div> ··· 165 {handle} 166 </div> 167 <div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2"> 168 - <Link to="/profile/$did/followers" params={{did: did}}>{followercount && (<span className="mr-1 text-gray-900 dark:text-gray-200 font-medium">{followercount}</span>)}Followers</Link> 169 - 170 - <Link to="/profile/$did/follows" params={{did: did}}>Follows</Link> 171 </div> 172 {description && ( 173 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> ··· 183 <ReusableTabRoute 184 route={`Profile` + did} 185 tabs={{ 186 - Posts: <PostsTab did={did} />, 187 - Reposts: <RepostsTab did={did} />, 188 - Feeds: <FeedsTab did={did} />, 189 - Lists: <ListsTab did={did} />, 190 ...(identity?.did === agent?.did 191 ? { Likes: <SelfLikesTab did={did} /> } 192 : {}), ··· 212 } 213 214 export type ProfilePostsFilter = { 215 - posts: boolean, 216 - replies: boolean, 217 - mediaOnly: boolean, 218 - } 219 export const defaultProfilePostsFilter: ProfilePostsFilter = { 220 posts: true, 221 replies: true, 222 mediaOnly: false, 223 - } 224 225 - function ProfilePostsFilterChipBar({filters, toggle}:{filters: ProfilePostsFilter | null, toggle: (key: keyof ProfilePostsFilter) => void}) { 226 - const empty = (!filters?.replies && !filters?.posts); 227 - const almostEmpty = (!filters?.replies && filters?.posts); 228 229 useEffect(() => { 230 if (empty) { 231 - toggle("posts") 232 } 233 }, [empty, toggle]); 234 ··· 237 <Chip 238 state={filters?.posts ?? true} 239 text="Posts" 240 - onClick={() => almostEmpty ? null : toggle("posts")} 241 /> 242 <Chip 243 state={filters?.replies ?? true} ··· 258 const [filterses, setFilterses] = useAtom(profileChipsAtom); 259 const filters = filterses?.[did]; 260 const setFilters = (obj: ProfilePostsFilter) => { 261 - setFilterses((prev)=>{ 262 - return{ 263 ...prev, 264 - [did]: obj 265 - } 266 - }) 267 - } 268 - useEffect(()=>{ 269 if (!filters) { 270 setFilters(defaultProfilePostsFilter); 271 } 272 - }) 273 useReusableTabScrollRestore(`Profile` + did); 274 const queryClient = useQueryClient(); 275 const { ··· 306 ); 307 308 const toggle = (key: keyof ProfilePostsFilter) => { 309 - setFilterses(prev => { 310 - const existing = prev[did] ?? { posts: false, replies: false, mediaOnly: false }; // default 311 312 return { 313 ...prev, ··· 500 ); 501 } 502 503 export function FeedItemRenderAturiLoader({ 504 aturi, 505 listmode, ··· 805 )} 806 </> 807 ) : ( 808 - <button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"> 809 Edit Profile 810 </button> 811 )} ··· 822 const { data: identity } = useQueryIdentity(targetdidorhandle); 823 const [show] = useAtom(enableBitesAtom); 824 825 - if (!show) return 826 827 return ( 828 <> 829 <button 830 - onClick={(e) => { 831 e.stopPropagation(); 832 - sendBite({ 833 agent: agent || undefined, 834 targetDid: identity?.did, 835 }); ··· 842 ); 843 } 844 845 - function sendBite({ 846 agent, 847 targetDid, 848 }: { 849 agent?: Agent; 850 targetDid?: string; 851 }) { 852 - if (!agent?.did || !targetDid) return; 853 const newRecord = { 854 repo: agent.did, 855 collection: "net.wafrn.feed.bite", 856 rkey: TID.next().toString(), 857 record: { 858 $type: "net.wafrn.feed.bite", 859 - subject: "at://"+targetDid, 860 createdAt: new Date().toISOString(), 861 }, 862 }; 863 864 - agent.com.atproto.repo.createRecord(newRecord).catch((err) => { 865 console.error("Bite failed:", err); 866 - }); 867 } 868 - 869 870 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 871 const { agent } = useAuth();
··· 32 useQueryIdentity, 33 useQueryProfile, 34 } from "~/utils/useQuery"; 35 + import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx"; 36 37 + import { renderSnack } from "../__root"; 38 import { Chip } from "../notifications"; 39 40 export const Route = createFileRoute("/profile/$did/")({ ··· 53 error: identityError, 54 } = useQueryIdentity(did); 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 + 70 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 71 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; 72 const pdsUrl = identity?.pds; ··· 97 98 const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; 99 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 + ); 110 111 const followercount = resultwhateversure?.data?.total; 112 ··· 156 157 {/* Avatar (PFP) */} 158 <div className="absolute left-[16px] top-[100px] "> 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 + )} 172 </div> 173 174 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> ··· 180 also save it persistently 181 */} 182 <FollowButton targetdidorhandle={did} /> 183 + <button 184 + className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 185 + onClick={(e) => { 186 + renderSnack({ 187 + title: "Not Implemented Yet", 188 + description: "Sorry...", 189 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 190 + }); 191 + }} 192 + > 193 ... {/* todo: icon */} 194 </button> 195 </div> ··· 202 {handle} 203 </div> 204 <div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2"> 205 + <Link to="/profile/$did/followers" params={{ did: did }}> 206 + {followercount && ( 207 + <span className="mr-1 text-gray-900 dark:text-gray-200 font-medium"> 208 + {followercount} 209 + </span> 210 + )} 211 + Followers 212 + </Link> 213 - 214 + <Link to="/profile/$did/follows" params={{ did: did }}> 215 + Follows 216 + </Link> 217 </div> 218 {description && ( 219 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> ··· 229 <ReusableTabRoute 230 route={`Profile` + did} 231 tabs={{ 232 + ...(isLabeler 233 + ? { 234 + Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />, 235 + } 236 + : {}), 237 + ...{ 238 + Posts: <PostsTab did={did} />, 239 + Reposts: <RepostsTab did={did} />, 240 + Feeds: <FeedsTab did={did} />, 241 + Lists: <ListsTab did={did} />, 242 + }, 243 ...(identity?.did === agent?.did 244 ? { Likes: <SelfLikesTab did={did} /> } 245 : {}), ··· 265 } 266 267 export type ProfilePostsFilter = { 268 + posts: boolean; 269 + replies: boolean; 270 + mediaOnly: boolean; 271 + }; 272 export const defaultProfilePostsFilter: ProfilePostsFilter = { 273 posts: true, 274 replies: true, 275 mediaOnly: false, 276 + }; 277 278 + function ProfilePostsFilterChipBar({ 279 + filters, 280 + toggle, 281 + }: { 282 + filters: ProfilePostsFilter | null; 283 + toggle: (key: keyof ProfilePostsFilter) => void; 284 + }) { 285 + const empty = !filters?.replies && !filters?.posts; 286 + const almostEmpty = !filters?.replies && filters?.posts; 287 288 useEffect(() => { 289 if (empty) { 290 + toggle("posts"); 291 } 292 }, [empty, toggle]); 293 ··· 296 <Chip 297 state={filters?.posts ?? true} 298 text="Posts" 299 + onClick={() => (almostEmpty ? null : toggle("posts"))} 300 /> 301 <Chip 302 state={filters?.replies ?? true} ··· 317 const [filterses, setFilterses] = useAtom(profileChipsAtom); 318 const filters = filterses?.[did]; 319 const setFilters = (obj: ProfilePostsFilter) => { 320 + setFilterses((prev) => { 321 + return { 322 ...prev, 323 + [did]: obj, 324 + }; 325 + }); 326 + }; 327 + useEffect(() => { 328 if (!filters) { 329 setFilters(defaultProfilePostsFilter); 330 } 331 + }); 332 useReusableTabScrollRestore(`Profile` + did); 333 const queryClient = useQueryClient(); 334 const { ··· 365 ); 366 367 const toggle = (key: keyof ProfilePostsFilter) => { 368 + setFilterses((prev) => { 369 + const existing = prev[did] ?? { 370 + posts: false, 371 + replies: false, 372 + mediaOnly: false, 373 + }; // default 374 375 return { 376 ...prev, ··· 563 ); 564 } 565 566 + function LabelsTab({ 567 + did, 568 + labelerRecord, 569 + }: { 570 + did: string; 571 + labelerRecord?: ATPAPI.AppBskyLabelerService.Record; 572 + }) { 573 + useReusableTabScrollRestore(`Profile` + did); 574 + const { agent } = useAuth(); 575 + // const { 576 + // data: identity, 577 + // isLoading: isIdentityLoading, 578 + // error: identityError, 579 + // } = useQueryIdentity(did); 580 + 581 + // const resolvedDid = did.startsWith("did:") ? did : identity?.did; 582 + 583 + const labelMap = new Map( 584 + labelerRecord?.policies?.labelValueDefinitions?.map((def) => { 585 + const locale = def.locales.find((l) => l.lang === "en") ?? def.locales[0]; 586 + return [ 587 + def.identifier, 588 + { 589 + name: locale?.name, 590 + description: locale?.description, 591 + blur: def.blurs, 592 + severity: def.severity, 593 + adultOnly: def.adultOnly, 594 + defaultSetting: def.defaultSetting, 595 + }, 596 + ]; 597 + }) 598 + ); 599 + 600 + return ( 601 + <> 602 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 603 + Labels 604 + </div> 605 + <div> 606 + {[...labelMap.entries()].map(([key, item]) => ( 607 + <div 608 + key={key} 609 + className="border-gray-300 dark:border-gray-700 border-b px-4 py-4" 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 + 646 export function FeedItemRenderAturiLoader({ 647 aturi, 648 listmode, ··· 948 )} 949 </> 950 ) : ( 951 + <button 952 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 953 + onClick={(e) => { 954 + renderSnack({ 955 + title: "Not Implemented Yet", 956 + description: "Sorry...", 957 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 958 + }); 959 + }} 960 + > 961 Edit Profile 962 </button> 963 )} ··· 974 const { data: identity } = useQueryIdentity(targetdidorhandle); 975 const [show] = useAtom(enableBitesAtom); 976 977 + if (!show) return; 978 979 return ( 980 <> 981 <button 982 + onClick={async (e) => { 983 e.stopPropagation(); 984 + await sendBite({ 985 agent: agent || undefined, 986 targetDid: identity?.did, 987 }); ··· 994 ); 995 } 996 997 + async function sendBite({ 998 agent, 999 targetDid, 1000 }: { 1001 agent?: Agent; 1002 targetDid?: string; 1003 }) { 1004 + if (!agent?.did || !targetDid) { 1005 + renderSnack({ 1006 + title: "Bite Failed", 1007 + description: "You must be logged-in to bite someone.", 1008 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 1009 + }); 1010 + return; 1011 + } 1012 const newRecord = { 1013 repo: agent.did, 1014 collection: "net.wafrn.feed.bite", 1015 rkey: TID.next().toString(), 1016 record: { 1017 $type: "net.wafrn.feed.bite", 1018 + subject: "at://" + targetDid, 1019 createdAt: new Date().toISOString(), 1020 }, 1021 }; 1022 1023 + try { 1024 + await agent.com.atproto.repo.createRecord(newRecord); 1025 + renderSnack({ 1026 + title: "Bite Sent", 1027 + description: "Your bite was delivered.", 1028 + //button: { label: 'Undo', onClick: () => console.log('Undo clicked') }, 1029 + }); 1030 + } catch (err) { 1031 console.error("Bite failed:", err); 1032 + renderSnack({ 1033 + title: "Bite Failed", 1034 + description: "Your bite failed to be delivered.", 1035 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 1036 + }); 1037 + } 1038 } 1039 1040 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 1041 const { agent } = useAuth();
+217 -9
src/routes/search.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 2 3 import { Header } from "~/components/Header"; 4 import { Import } from "~/components/Import"; 5 6 export const Route = createFileRoute("/search")({ 7 component: Search, 8 }); 9 10 export function Search() { 11 return ( 12 <> 13 <Header ··· 21 }} 22 /> 23 <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 24 - <Import /> 25 <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> 30 <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 31 <li> 32 - Bluesky URLs from supported clients (like{" "} 33 <code className="text-sm">bsky.app</code> or{" "} 34 <code className="text-sm">deer.social</code>). 35 </li> ··· 39 ). 40 </li> 41 <li> 42 - Plain handles (like{" "} 43 <code className="text-sm">@username.bsky.social</code>). 44 </li> 45 <li> 46 - Direct DIDs (Decentralized Identifiers, starting with{" "} 47 <code className="text-sm">did:</code>). 48 </li> 49 </ul> ··· 51 Simply paste one of these into the import field above and press 52 Enter to load the content. 53 </p> 54 </div> 55 </div> 56 </> 57 ); 58 }
··· 1 + import type { Agent } from "@atproto/api"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { createFileRoute, useSearch } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 5 + import { useEffect,useMemo } from "react"; 6 7 import { Header } from "~/components/Header"; 8 import { Import } from "~/components/Import"; 9 + import { 10 + ReusableTabRoute, 11 + useReusableTabScrollRestore, 12 + } from "~/components/ReusableTabRoute"; 13 + import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 14 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 15 + import { lycanURLAtom } from "~/utils/atoms"; 16 + import { 17 + constructLycanRequestIndexQuery, 18 + useInfiniteQueryLycanSearch, 19 + useQueryIdentity, 20 + useQueryLycanStatus, 21 + } from "~/utils/useQuery"; 22 + 23 + import { renderSnack } from "./__root"; 24 + import { SliderPrimitive } from "./settings"; 25 26 export const Route = createFileRoute("/search")({ 27 component: Search, 28 }); 29 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 + 105 return ( 106 <> 107 <Header ··· 115 }} 116 /> 117 <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 118 + <Import optionaltextstring={q} /> 119 <div className="flex flex-col"> 120 + <p className="text-gray-600 dark:text-gray-400">{maintext}</p> 121 <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 122 <li> 123 + Bluesky URLs (from supported clients) (like{" "} 124 <code className="text-sm">bsky.app</code> or{" "} 125 <code className="text-sm">deer.social</code>). 126 </li> ··· 130 ). 131 </li> 132 <li> 133 + User Handles (like{" "} 134 <code className="text-sm">@username.bsky.social</code>). 135 </li> 136 <li> 137 + DIDs (Decentralized Identifiers, starting with{" "} 138 <code className="text-sm">did:</code>). 139 </li> 140 </ul> ··· 142 Simply paste one of these into the import field above and press 143 Enter to load the content. 144 </p> 145 + 146 + {lycanExists && authed && !lycanReady ? ( 147 + !lycanIndexing ? ( 148 + <div className="mt-4 mx-auto"> 149 + <button 150 + onClick={() => 151 + index({ 152 + agent: agent || undefined, 153 + isAuthed: status === "signedIn", 154 + pdsUrl: identity?.pds, 155 + feedServiceDid: "did:web:" + lycandomain, 156 + }) 157 + } 158 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 159 + text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 160 + > 161 + Index my Account 162 + </button> 163 + </div> 164 + ) : ( 165 + <div className="mt-4 gap-2 flex flex-col"> 166 + <span>indexing...</span> 167 + <SliderPrimitive 168 + value={lycanIndexingProgress || 0} 169 + min={0} 170 + max={1} 171 + /> 172 + </div> 173 + ) 174 + ) : ( 175 + <></> 176 + )} 177 </div> 178 </div> 179 + {q ? <SearchTabs query={q} /> : <></>} 180 </> 181 ); 182 } 183 + 184 + function SearchTabs({ query }: { query: string }) { 185 + return ( 186 + <div> 187 + <ReusableTabRoute 188 + route={`search` + query} 189 + tabs={{ 190 + Likes: <LycanTab query={query} type={"likes"} key={"likes"} />, 191 + Reposts: <LycanTab query={query} type={"reposts"} key={"reposts"} />, 192 + Quotes: <LycanTab query={query} type={"quotes"} key={"quotes"} />, 193 + Pins: <LycanTab query={query} type={"pins"} key={"pins"} />, 194 + }} 195 + /> 196 + </div> 197 + ); 198 + } 199 + 200 + function LycanTab({ 201 + query, 202 + type, 203 + }: { 204 + query: string; 205 + type: "likes" | "pins" | "reposts" | "quotes"; 206 + }) { 207 + useReusableTabScrollRestore("search" + query); 208 + 209 + const { 210 + data: postsData, 211 + fetchNextPage, 212 + hasNextPage, 213 + isFetchingNextPage, 214 + isLoading: arePostsLoading, 215 + } = useInfiniteQueryLycanSearch({ query: query, type: type }); 216 + 217 + const posts = useMemo( 218 + () => 219 + postsData?.pages.flatMap((page) => { 220 + if (page) { 221 + return page.posts; 222 + } else { 223 + return []; 224 + } 225 + }) ?? [], 226 + [postsData] 227 + ); 228 + 229 + return ( 230 + <> 231 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 232 + Posts 233 + </div> */} 234 + <div> 235 + {posts.map((post) => ( 236 + <UniversalPostRendererATURILoader 237 + key={post} 238 + atUri={post} 239 + feedviewpost={true} 240 + /> 241 + ))} 242 + </div> 243 + 244 + {/* Loading and "Load More" states */} 245 + {arePostsLoading && posts.length === 0 && ( 246 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 247 + )} 248 + {isFetchingNextPage && ( 249 + <div className="p-4 text-center text-gray-500">Loading more...</div> 250 + )} 251 + {hasNextPage && !isFetchingNextPage && ( 252 + <button 253 + onClick={() => fetchNextPage()} 254 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 255 + > 256 + Load More Posts 257 + </button> 258 + )} 259 + {posts.length === 0 && !arePostsLoading && ( 260 + <div className="p-4 text-center text-gray-500">No posts found.</div> 261 + )} 262 + </> 263 + ); 264 + 265 + return <></>; 266 + }
+148 -30
src/routes/settings.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 2 import { useAtom, useAtomValue, useSetAtom } from "jotai"; 3 import { Slider, Switch } from "radix-ui"; 4 - import { useEffect,useState } from "react"; 5 6 import { Header } from "~/components/Header"; 7 import Login from "~/components/Login"; ··· 10 defaultconstellationURL, 11 defaulthue, 12 defaultImgCDN, 13 defaultslingshotURL, 14 defaultVideoCDN, 15 enableBitesAtom, 16 hueAtom, 17 imgCDNAtom, 18 slingshotURLAtom, 19 videoCDNAtom, 20 } from "~/utils/atoms"; 21 22 export const Route = createFileRoute("/settings")({ 23 component: Settings, 24 }); 25 26 export function Settings() { 27 return ( 28 <> 29 <Header ··· 39 <div className="lg:hidden"> 40 <Login /> 41 </div> 42 <div className="h-4" /> 43 <TextInputSetting 44 atom={constellationURLAtom} 45 title={"Constellation"} ··· 68 description={"Customize the Slingshot instance to be used by Red Dwarf"} 69 init={defaultVideoCDN} 70 /> 71 72 - <Hue /> 73 <SwitchSetting 74 atom={enableBitesAtom} 75 title={"Bites"} 76 - description={"Enable Wafrn Bites"} 77 //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 81 </p> 82 </> 83 ); 84 } 85 86 export function SwitchSetting({ 87 atom, 88 title, ··· 105 } 106 107 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> 115 116 <Switch.Root 117 - id="switch-demo" 118 checked={value} 119 onCheckedChange={(v) => setValue(v)} 120 - className="w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-blue-500 transition-colors" 121 > 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 - /> 125 </Switch.Root> 126 </div> 127 ); ··· 130 function Hue() { 131 const [hue, setHue] = useAtom(hueAtom); 132 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 - /> 140 <button 141 onClick={() => setHue(defaulthue ?? 28)} 142 className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 ··· 207 ); 208 } 209 210 - 211 interface SliderProps { 212 atom: typeof hueAtom; 213 min?: number; ··· 221 max = 100, 222 step = 1, 223 }) => { 224 - 225 - const [value, setValue] = useAtom(atom) 226 227 return ( 228 <Slider.Root ··· 239 <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 </Slider.Root> 241 ); 242 - };
··· 1 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 import { useAtom, useAtomValue, useSetAtom } from "jotai"; 3 import { Slider, Switch } from "radix-ui"; 4 + import { useEffect, useState } from "react"; 5 6 import { Header } from "~/components/Header"; 7 import Login from "~/components/Login"; ··· 10 defaultconstellationURL, 11 defaulthue, 12 defaultImgCDN, 13 + defaultLycanURL, 14 defaultslingshotURL, 15 defaultVideoCDN, 16 enableBitesAtom, 17 + enableBridgyTextAtom, 18 + enableWafrnTextAtom, 19 hueAtom, 20 imgCDNAtom, 21 + lycanURLAtom, 22 slingshotURLAtom, 23 videoCDNAtom, 24 } from "~/utils/atoms"; 25 26 + import { MaterialNavItem } from "./__root"; 27 + 28 export const Route = createFileRoute("/settings")({ 29 component: Settings, 30 }); 31 32 export function Settings() { 33 + const navigate = useNavigate(); 34 return ( 35 <> 36 <Header ··· 46 <div className="lg:hidden"> 47 <Login /> 48 </div> 49 + <div className="sm:hidden flex flex-col justify-around mt-4"> 50 + <SettingHeading title="Other Pages" top /> 51 + <MaterialNavItem 52 + InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />} 53 + ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />} 54 + active={false} 55 + onClickCallbback={() => 56 + navigate({ 57 + to: "/feeds", 58 + //params: { did: agent.assertDid }, 59 + }) 60 + } 61 + text="Feeds" 62 + /> 63 + <MaterialNavItem 64 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 65 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 66 + active={false} 67 + onClickCallbback={() => 68 + navigate({ 69 + to: "/moderation", 70 + //params: { did: agent.assertDid }, 71 + }) 72 + } 73 + text="Moderation" 74 + /> 75 + </div> 76 <div className="h-4" /> 77 + 78 + <SettingHeading title="Personalization" top /> 79 + <Hue /> 80 + 81 + <SettingHeading title="Network Configuration" /> 82 + <div className="flex flex-col px-4 pb-2"> 83 + <span className="text-md">Service Endpoints</span> 84 + <span className="text-sm text-gray-500 dark:text-gray-400"> 85 + Customize the servers to be used by the app 86 + </span> 87 + </div> 88 <TextInputSetting 89 atom={constellationURLAtom} 90 title={"Constellation"} ··· 113 description={"Customize the Slingshot instance to be used by Red Dwarf"} 114 init={defaultVideoCDN} 115 /> 116 + <TextInputSetting 117 + atom={lycanURLAtom} 118 + title={"Lycan Search"} 119 + description={"Enable text search across posts you've interacted with"} 120 + init={defaultLycanURL} 121 + /> 122 123 + <SettingHeading title="Experimental" /> 124 <SwitchSetting 125 atom={enableBitesAtom} 126 title={"Bites"} 127 + description={"Enable Wafrn Bites to bite and be bitten by other people"} 128 //init={false} 129 + /> 130 + <div className="h-4" /> 131 + <SwitchSetting 132 + atom={enableBridgyTextAtom} 133 + title={"Bridgy Text"} 134 + description={ 135 + "Show the original text of posts bridged from the Fediverse" 136 + } 137 + //init={false} 138 + /> 139 + <div className="h-4" /> 140 + <SwitchSetting 141 + atom={enableWafrnTextAtom} 142 + title={"Wafrn Text"} 143 + description={"Show the original text of posts from Wafrn instances"} 144 + //init={false} 145 + /> 146 + <p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4"> 147 + Notice: Please restart/refresh the app if changes arent applying 148 + correctly 149 </p> 150 </> 151 ); 152 } 153 154 + export function SettingHeading({ 155 + title, 156 + top, 157 + }: { 158 + title: string; 159 + top?: boolean; 160 + }) { 161 + return ( 162 + <div 163 + className="px-4" 164 + style={{ marginTop: top ? 0 : 18, paddingBottom: 12 }} 165 + > 166 + <span className=" text-sm font-medium text-gray-500 dark:text-gray-400"> 167 + {title} 168 + </span> 169 + </div> 170 + ); 171 + } 172 + 173 export function SwitchSetting({ 174 atom, 175 title, ··· 192 } 193 194 return ( 195 + <div className="flex items-center gap-4 px-4 "> 196 + <label htmlFor={`switch-${title}`} className="flex flex-row flex-1"> 197 + <div className="flex flex-col"> 198 + <span className="text-md">{title}</span> 199 + <span className="text-sm text-gray-500 dark:text-gray-400"> 200 + {description} 201 + </span> 202 + </div> 203 + </label> 204 205 <Switch.Root 206 + id={`switch-${title}`} 207 checked={value} 208 onCheckedChange={(v) => setValue(v)} 209 + className="m3switch root" 210 > 211 + <Switch.Thumb className="m3switch thumb " /> 212 </Switch.Root> 213 </div> 214 ); ··· 217 function Hue() { 218 const [hue, setHue] = useAtom(hueAtom); 219 return ( 220 + <div className="flex flex-col px-4"> 221 + <span className="z-[2] text-md">Hue</span> 222 + <span className="z-[2] text-sm text-gray-500 dark:text-gray-400"> 223 + Change the colors of the app 224 + </span> 225 + <div className="z-[1] flex flex-row items-center gap-4"> 226 + <SliderComponent atom={hueAtom} max={360} /> 227 <button 228 onClick={() => setHue(defaulthue ?? 28)} 229 className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 ··· 294 ); 295 } 296 297 interface SliderProps { 298 atom: typeof hueAtom; 299 min?: number; ··· 307 max = 100, 308 step = 1, 309 }) => { 310 + const [value, setValue] = useAtom(atom); 311 312 return ( 313 <Slider.Root ··· 324 <Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" /> 325 </Slider.Root> 326 ); 327 + }; 328 + 329 + 330 + interface SliderPProps { 331 + value: number; 332 + min?: number; 333 + max?: number; 334 + step?: number; 335 + } 336 + 337 + 338 + export const SliderPrimitive: React.FC<SliderPProps> = ({ 339 + value, 340 + min = 0, 341 + max = 100, 342 + step = 1, 343 + }) => { 344 + 345 + return ( 346 + <Slider.Root 347 + className="relative flex items-center w-full h-4" 348 + value={[value]} 349 + min={min} 350 + max={max} 351 + step={step} 352 + onValueChange={(v: number[]) => {}} 353 + > 354 + <Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full"> 355 + <Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" /> 356 + </Slider.Track> 357 + <Slider.Thumb className=" hidden shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" /> 358 + </Slider.Root> 359 + ); 360 + };
+98 -1
src/styles/app.css
··· 33 --color-gray-950: oklch(0.129 0.055 var(--safe-hue)); 34 } 35 36 @layer base { 37 html { 38 color-scheme: light dark; ··· 84 .dangerousFediContent { 85 & a[href]{ 86 text-decoration: none; 87 - color: rgb(29, 122, 242); 88 word-break: break-all; 89 } 90 } ··· 275 &::before{ 276 background-color: var(--color-gray-500); 277 } 278 } 279 } 280 }
··· 33 --color-gray-950: oklch(0.129 0.055 var(--safe-hue)); 34 } 35 36 + :root { 37 + --link-text-color: oklch(0.5962 0.1987 var(--safe-hue)); 38 + /* max chroma!!! use fallback*/ 39 + /*--link-text-color: oklch(0.6 0.37 var(--safe-hue));*/ 40 + } 41 + 42 @layer base { 43 html { 44 color-scheme: light dark; ··· 90 .dangerousFediContent { 91 & a[href]{ 92 text-decoration: none; 93 + color: var(--link-text-color); 94 word-break: break-all; 95 } 96 } ··· 281 &::before{ 282 background-color: var(--color-gray-500); 283 } 284 + } 285 + } 286 + } 287 + 288 + :root{ 289 + --thumb-size: 2rem; 290 + --root-size: 3.25rem; 291 + 292 + --switch-off-border: var(--color-gray-400); 293 + --switch-off-bg: var(--color-gray-200); 294 + --switch-off-thumb: var(--color-gray-400); 295 + 296 + 297 + --switch-on-bg: var(--color-gray-500); 298 + --switch-on-thumb: var(--color-gray-50); 299 + 300 + } 301 + @media (prefers-color-scheme: dark) { 302 + :root { 303 + --switch-off-border: var(--color-gray-500); 304 + --switch-off-bg: var(--color-gray-800); 305 + --switch-off-thumb: var(--color-gray-500); 306 + 307 + 308 + --switch-on-bg: var(--color-gray-400); 309 + --switch-on-thumb: var(--color-gray-700); 310 + } 311 + } 312 + 313 + .m3switch.root{ 314 + /*w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-gray-500 transition-colors*/ 315 + /*width: 40px; 316 + height: 24px;*/ 317 + 318 + inline-size: var(--root-size); 319 + block-size: 2rem; 320 + border-radius: 99999px; 321 + 322 + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; 323 + transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */ 324 + transition-duration: var(--default-transition-duration); /* 150ms */ 325 + 326 + .m3switch.thumb{ 327 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 328 + 329 + height: var(--thumb-size); 330 + width: var(--thumb-size); 331 + display: inline-block; 332 + border-radius: 9999px; 333 + 334 + transform-origin: center; 335 + 336 + transition-property: transform, translate, scale, rotate; 337 + transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */ 338 + transition-duration: var(--default-transition-duration); /* 150ms */ 339 + 340 + } 341 + 342 + &[aria-checked="true"] { 343 + box-shadow: inset 0px 0px 0px 1.8px transparent; 344 + background-color: var(--switch-on-bg); 345 + 346 + .m3switch.thumb{ 347 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 348 + 349 + background-color: var(--switch-on-thumb); 350 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.72); 351 + &:active { 352 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88); 353 + } 354 + 355 + } 356 + &:active .m3switch.thumb{ 357 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88); 358 + } 359 + } 360 + 361 + &[aria-checked="false"] { 362 + box-shadow: inset 0px 0px 0px 1.8px var(--switch-off-border); 363 + background-color: var(--switch-off-bg); 364 + .m3switch.thumb{ 365 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 366 + 367 + background-color: var(--switch-off-thumb); 368 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.5); 369 + &:active { 370 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88); 371 + } 372 + } 373 + &:active .m3switch.thumb{ 374 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88); 375 } 376 } 377 }
+16
src/utils/atoms.ts
··· 92 defaultVideoCDN 93 ); 94 95 export const defaulthue = 28; 96 export const hueAtom = atomWithStorage<number>("hue", defaulthue); 97 ··· 137 "enableBitesAtom", 138 false 139 );
··· 92 defaultVideoCDN 93 ); 94 95 + export const defaultLycanURL = ""; 96 + export const lycanURLAtom = atomWithStorage<string>( 97 + "lycanURL", 98 + defaultLycanURL 99 + ); 100 + 101 export const defaulthue = 28; 102 export const hueAtom = atomWithStorage<number>("hue", defaulthue); 103 ··· 143 "enableBitesAtom", 144 false 145 ); 146 + 147 + export const enableBridgyTextAtom = atomWithStorage<boolean>( 148 + "enableBridgyTextAtom", 149 + false 150 + ); 151 + 152 + export const enableWafrnTextAtom = atomWithStorage<boolean>( 153 + "enableWafrnTextAtom", 154 + false 155 + );
+2 -2
src/utils/oauthClient.ts
··· 1 import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser'; 2 3 - // i tried making this https://pds-nd.whey.party but cors is annoying as fuck 4 - const handleResolverPDS = 'https://bsky.social'; 5 6 // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 // @ts-ignore this should be fine ? the vite plugin should generate this before errors
··· 1 import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser'; 2 3 + import resolvers from '../../public/resolvers.json' with { type: 'json' }; 4 + const handleResolverPDS = resolvers.resolver || 'https://bsky.social'; 5 6 // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 // @ts-ignore this should be fine ? the vite plugin should generate this before errors
+384 -157
src/utils/useQuery.ts
··· 5 queryOptions, 6 useInfiniteQuery, 7 useQuery, 8 - type UseQueryResult} from "@tanstack/react-query"; 9 import { useAtom } from "jotai"; 10 11 - import { constellationURLAtom, slingshotURLAtom } from "./atoms"; 12 13 - export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) { 14 return queryOptions({ 15 queryKey: ["identity", didorhandle], 16 queryFn: async () => { 17 - if (!didorhandle) return undefined as undefined 18 const res = await fetch( 19 `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 20 ); ··· 31 } 32 }, 33 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 34 - gcTime: /*0//*/5 * 60 * 1000, 35 }); 36 } 37 export function useQueryIdentity(didorhandle: string): UseQueryResult< ··· 43 }, 44 Error 45 >; 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 - > 60 export function useQueryIdentity(didorhandle?: string) { 61 - const [slingshoturl] = useAtom(slingshotURLAtom) 62 return useQuery(constructIdentityQuery(didorhandle, slingshoturl)); 63 } 64 ··· 66 return queryOptions({ 67 queryKey: ["post", uri], 68 queryFn: async () => { 69 - if (!uri) return undefined as undefined 70 const res = await fetch( 71 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 72 ); ··· 77 return undefined; 78 } 79 if (res.status === 400) return undefined; 80 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 81 return undefined; // cache โ€œnot foundโ€ 82 } 83 try { 84 if (!res.ok) throw new Error("Failed to fetch post"); 85 - return (data) as { 86 uri: string; 87 cid: string; 88 value: any; ··· 97 return failureCount < 2; 98 }, 99 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 100 - gcTime: /*0//*/5 * 60 * 1000, 101 }); 102 } 103 export function useQueryPost(uri: string): UseQueryResult< ··· 108 }, 109 Error 110 >; 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 - > 124 export function useQueryPost(uri?: string) { 125 - const [slingshoturl] = useAtom(slingshotURLAtom) 126 return useQuery(constructPostQuery(uri, slingshoturl)); 127 } 128 ··· 130 return queryOptions({ 131 queryKey: ["profile", uri], 132 queryFn: async () => { 133 - if (!uri) return undefined as undefined 134 const res = await fetch( 135 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 136 ); ··· 141 return undefined; 142 } 143 if (res.status === 400) return undefined; 144 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 145 return undefined; // cache โ€œnot foundโ€ 146 } 147 try { 148 if (!res.ok) throw new Error("Failed to fetch post"); 149 - return (data) as { 150 uri: string; 151 cid: string; 152 value: any; ··· 161 return failureCount < 2; 162 }, 163 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 164 - gcTime: /*0//*/5 * 60 * 1000, 165 }); 166 } 167 export function useQueryProfile(uri: string): UseQueryResult< ··· 172 }, 173 Error 174 >; 175 - export function useQueryProfile(): UseQueryResult< 176 - undefined, 177 - Error 178 - >; 179 - export function useQueryProfile(uri?: string): 180 - UseQueryResult< 181 - { 182 uri: string; 183 cid: string; 184 value: ATPAPI.AppBskyActorProfile.Record; 185 - } | undefined, 186 - Error 187 - > 188 export function useQueryProfile(uri?: string) { 189 - const [slingshoturl] = useAtom(slingshotURLAtom) 190 return useQuery(constructProfileQuery(uri, slingshoturl)); 191 } 192 ··· 222 // method: "/links/all", 223 // target: string 224 // ): QueryOptions<linksAllResponse, Error>; 225 - export function constructConstellationQuery(query?:{ 226 - constellation: string, 227 method: 228 | "/links" 229 | "/links/distinct-dids" 230 | "/links/count" 231 | "/links/count/distinct-dids" 232 | "/links/all" 233 - | "undefined", 234 - target: string, 235 - collection?: string, 236 - path?: string, 237 - cursor?: string, 238 - dids?: string[] 239 - } 240 - ) { 241 // : QueryOptions< 242 // | linksRecordsResponse 243 // | linksDidsResponse ··· 247 // Error 248 // > 249 return queryOptions({ 250 - queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const, 251 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 259 const res = await fetch( 260 `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 ); ··· 281 }, 282 // enforce short lifespan 283 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 284 - gcTime: /*0//*/5 * 60 * 1000, 285 }); 286 } 287 // todo do more of these instead of overloads since overloads sucks so much apparently ··· 293 cursor?: string; 294 }): UseQueryResult<linksCountResponse, Error> | undefined { 295 //if (!query) return; 296 - const [constellationurl] = useAtom(constellationURLAtom) 297 const queryres = useQuery( 298 - constructConstellationQuery(query && {constellation: constellationurl, ...query}) 299 ) as unknown as UseQueryResult<linksCountResponse, Error>; 300 if (!query) { 301 - return undefined as undefined; 302 } 303 return queryres as UseQueryResult<linksCountResponse, Error>; 304 } ··· 365 > 366 | undefined { 367 //if (!query) return; 368 - const [constellationurl] = useAtom(constellationURLAtom) 369 return useQuery( 370 - constructConstellationQuery(query && {constellation: constellationurl, ...query}) 371 ); 372 } 373 ··· 411 }) { 412 return queryOptions({ 413 // 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 }], 415 queryFn: async () => { 416 - if (!options) return undefined as undefined 417 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 418 if (isAuthed) { 419 // Authenticated flow 420 if (!agent || !pdsUrl || !feedServiceDid) { 421 - throw new Error("Missing required info for authenticated feed fetch."); 422 } 423 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 424 const res = await agent.fetchHandler(url, { ··· 428 "Content-Type": "application/json", 429 }, 430 }); 431 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 432 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 433 } else { 434 // Unauthenticated flow (using a public PDS/AppView) 435 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 436 const res = await fetch(url); 437 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 438 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 439 } 440 }, ··· 452 return useQuery(constructFeedSkeletonQuery(options)); 453 } 454 455 - export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { 456 return queryOptions({ 457 - queryKey: ['preferences', agent?.did], 458 queryFn: async () => { 459 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 460 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; ··· 465 }); 466 } 467 export function useQueryPreferences(options: { 468 - agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined 469 }) { 470 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 471 } 472 473 - 474 - 475 export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { 476 return queryOptions({ 477 queryKey: ["arbitrary", uri], 478 queryFn: async () => { 479 - if (!uri) return undefined as undefined 480 const res = await fetch( 481 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 482 ); ··· 487 return undefined; 488 } 489 if (res.status === 400) return undefined; 490 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 491 return undefined; // cache โ€œnot foundโ€ 492 } 493 try { 494 if (!res.ok) throw new Error("Failed to fetch post"); 495 - return (data) as { 496 uri: string; 497 cid: string; 498 value: any; ··· 507 return failureCount < 2; 508 }, 509 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 510 - gcTime: /*0//*/5 * 60 * 1000, 511 }); 512 } 513 export function useQueryArbitrary(uri: string): UseQueryResult< ··· 518 }, 519 Error 520 >; 521 - export function useQueryArbitrary(): UseQueryResult< 522 - undefined, 523 - Error 524 - >; 525 export function useQueryArbitrary(uri?: string): UseQueryResult< 526 - { 527 - uri: string; 528 - cid: string; 529 - value: any; 530 - } | undefined, 531 Error 532 >; 533 export function useQueryArbitrary(uri?: string) { 534 - const [slingshoturl] = useAtom(slingshotURLAtom) 535 return useQuery(constructArbitraryQuery(uri, slingshoturl)); 536 } 537 538 - export function constructFallbackNothingQuery(){ 539 return queryOptions({ 540 queryKey: ["nothing"], 541 queryFn: async () => { 542 - return undefined 543 }, 544 }); 545 } ··· 553 }[]; 554 }; 555 556 - export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") { 557 return queryOptions({ 558 - queryKey: ['authorFeed', did, collection], 559 queryFn: async ({ pageParam }: QueryFunctionContext) => { 560 const limit = 25; 561 - 562 const cursor = pageParam as string | undefined; 563 - const cursorParam = cursor ? `&cursor=${cursor}` : ''; 564 - 565 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`; 566 - 567 const res = await fetch(url); 568 if (!res.ok) throw new Error("Failed to fetch author's posts"); 569 - 570 return res.json() as Promise<ListRecordsResponse>; 571 }, 572 }); 573 } 574 575 - export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) { 576 - const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection); 577 - 578 return useInfiniteQuery({ 579 queryKey, 580 queryFn, ··· 595 // todo the hell is a unauthedfeedurl 596 unauthedfeedurl?: string; 597 }) { 598 - const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = options; 599 - 600 return queryOptions({ 601 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 602 - 603 - queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 604 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 605 - 606 if (isAuthed && !unauthedfeedurl) { 607 if (!agent || !pdsUrl || !feedServiceDid) { 608 - throw new Error("Missing required info for authenticated feed fetch."); 609 } 610 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 611 const res = await agent.fetchHandler(url, { ··· 615 "Content-Type": "application/json", 616 }, 617 }); 618 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 619 return (await res.json()) as FeedSkeletonPage; 620 } else { 621 const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 622 const res = await fetch(url); 623 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 624 return (await res.json()) as FeedSkeletonPage; 625 } 626 }, ··· 636 unauthedfeedurl?: string; 637 }) { 638 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 651 652 export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 653 - constellation: string, 654 - method: '/links' 655 - target?: string 656 - collection: string 657 - path: string, 658 - staleMult?: number 659 }) { 660 const safemult = query?.staleMult ?? 1; 661 // console.log( ··· 666 return infiniteQueryOptions({ 667 enabled: !!query?.target, 668 queryKey: [ 669 - 'reddwarf_constellation', 670 query?.method, 671 query?.target, 672 query?.collection, 673 query?.path, 674 ] as const, 675 676 - queryFn: async ({pageParam}: {pageParam?: string}) => { 677 - if (!query || !query?.target) return undefined 678 679 - const method = query.method 680 - const target = query.target 681 - const collection = query.collection 682 - const path = query.path 683 - const cursor = pageParam 684 685 const res = await fetch( 686 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${ 687 - collection ? `&collection=${encodeURIComponent(collection)}` : '' 688 - }${path ? `&path=${encodeURIComponent(path)}` : ''}${ 689 - cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' 690 - }`, 691 - ) 692 693 - if (!res.ok) throw new Error('Failed to fetch') 694 695 - return (await res.json()) as linksRecordsResponse 696 }, 697 698 - getNextPageParam: lastPage => { 699 - return (lastPage as any)?.cursor ?? undefined 700 }, 701 initialPageParam: undefined, 702 staleTime: 5 * 60 * 1000 * safemult, 703 gcTime: 5 * 60 * 1000 * safemult, 704 - }) 705 - }
··· 5 queryOptions, 6 useInfiniteQuery, 7 useQuery, 8 + type UseQueryResult, 9 + } from "@tanstack/react-query"; 10 import { useAtom } from "jotai"; 11 12 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 13 + 14 + import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms"; 15 16 + export function constructIdentityQuery( 17 + didorhandle?: string, 18 + slingshoturl?: string 19 + ) { 20 return queryOptions({ 21 queryKey: ["identity", didorhandle], 22 queryFn: async () => { 23 + if (!didorhandle) return undefined as undefined; 24 const res = await fetch( 25 `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 26 ); ··· 37 } 38 }, 39 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 40 + gcTime: /*0//*/ 5 * 60 * 1000, 41 }); 42 } 43 export function useQueryIdentity(didorhandle: string): UseQueryResult< ··· 49 }, 50 Error 51 >; 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 + >; 63 export function useQueryIdentity(didorhandle?: string) { 64 + const [slingshoturl] = useAtom(slingshotURLAtom); 65 return useQuery(constructIdentityQuery(didorhandle, slingshoturl)); 66 } 67 ··· 69 return queryOptions({ 70 queryKey: ["post", uri], 71 queryFn: async () => { 72 + if (!uri) return undefined as undefined; 73 const res = await fetch( 74 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 75 ); ··· 80 return undefined; 81 } 82 if (res.status === 400) return undefined; 83 + if ( 84 + data?.error === "InvalidRequest" && 85 + data.message?.includes("Could not find repo") 86 + ) { 87 return undefined; // cache โ€œnot foundโ€ 88 } 89 try { 90 if (!res.ok) throw new Error("Failed to fetch post"); 91 + return data as { 92 uri: string; 93 cid: string; 94 value: any; ··· 103 return failureCount < 2; 104 }, 105 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 106 + gcTime: /*0//*/ 5 * 60 * 1000, 107 }); 108 } 109 export function useQueryPost(uri: string): UseQueryResult< ··· 114 }, 115 Error 116 >; 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 + >; 127 export function useQueryPost(uri?: string) { 128 + const [slingshoturl] = useAtom(slingshotURLAtom); 129 return useQuery(constructPostQuery(uri, slingshoturl)); 130 } 131 ··· 133 return queryOptions({ 134 queryKey: ["profile", uri], 135 queryFn: async () => { 136 + if (!uri) return undefined as undefined; 137 const res = await fetch( 138 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 139 ); ··· 144 return undefined; 145 } 146 if (res.status === 400) return undefined; 147 + if ( 148 + data?.error === "InvalidRequest" && 149 + data.message?.includes("Could not find repo") 150 + ) { 151 return undefined; // cache โ€œnot foundโ€ 152 } 153 try { 154 if (!res.ok) throw new Error("Failed to fetch post"); 155 + return data as { 156 uri: string; 157 cid: string; 158 value: any; ··· 167 return failureCount < 2; 168 }, 169 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 170 + gcTime: /*0//*/ 5 * 60 * 1000, 171 }); 172 } 173 export function useQueryProfile(uri: string): UseQueryResult< ··· 178 }, 179 Error 180 >; 181 + export function useQueryProfile(): UseQueryResult<undefined, Error>; 182 + export function useQueryProfile(uri?: string): UseQueryResult< 183 + | { 184 uri: string; 185 cid: string; 186 value: ATPAPI.AppBskyActorProfile.Record; 187 + } 188 + | undefined, 189 + Error 190 + >; 191 export function useQueryProfile(uri?: string) { 192 + const [slingshoturl] = useAtom(slingshotURLAtom); 193 return useQuery(constructProfileQuery(uri, slingshoturl)); 194 } 195 ··· 225 // method: "/links/all", 226 // target: string 227 // ): QueryOptions<linksAllResponse, Error>; 228 + export function constructConstellationQuery(query?: { 229 + constellation: string; 230 method: 231 | "/links" 232 | "/links/distinct-dids" 233 | "/links/count" 234 | "/links/count/distinct-dids" 235 | "/links/all" 236 + | "undefined"; 237 + target: string; 238 + collection?: string; 239 + path?: string; 240 + cursor?: string; 241 + dids?: string[]; 242 + }) { 243 // : QueryOptions< 244 // | linksRecordsResponse 245 // | linksDidsResponse ··· 249 // Error 250 // > 251 return queryOptions({ 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, 261 queryFn: async () => { 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; 269 const res = await fetch( 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("") : ""}` 271 ); ··· 291 }, 292 // enforce short lifespan 293 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 294 + gcTime: /*0//*/ 5 * 60 * 1000, 295 }); 296 } 297 // todo do more of these instead of overloads since overloads sucks so much apparently ··· 303 cursor?: string; 304 }): UseQueryResult<linksCountResponse, Error> | undefined { 305 //if (!query) return; 306 + const [constellationurl] = useAtom(constellationURLAtom); 307 const queryres = useQuery( 308 + constructConstellationQuery( 309 + query && { constellation: constellationurl, ...query } 310 + ) 311 ) as unknown as UseQueryResult<linksCountResponse, Error>; 312 if (!query) { 313 + return undefined as undefined; 314 } 315 return queryres as UseQueryResult<linksCountResponse, Error>; 316 } ··· 377 > 378 | undefined { 379 //if (!query) return; 380 + const [constellationurl] = useAtom(constellationURLAtom); 381 return useQuery( 382 + constructConstellationQuery( 383 + query && { constellation: constellationurl, ...query } 384 + ) 385 ); 386 } 387 ··· 425 }) { 426 return queryOptions({ 427 // The query key includes all dependencies to ensure it refetches when they change 428 + queryKey: [ 429 + "feedSkeleton", 430 + options?.feedUri, 431 + { isAuthed: options?.isAuthed, did: options?.agent?.did }, 432 + ], 433 queryFn: async () => { 434 + if (!options) return undefined as undefined; 435 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 436 if (isAuthed) { 437 // Authenticated flow 438 if (!agent || !pdsUrl || !feedServiceDid) { 439 + throw new Error( 440 + "Missing required info for authenticated feed fetch." 441 + ); 442 } 443 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 444 const res = await agent.fetchHandler(url, { ··· 448 "Content-Type": "application/json", 449 }, 450 }); 451 + if (!res.ok) 452 + throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 453 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 454 } else { 455 // Unauthenticated flow (using a public PDS/AppView) 456 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 457 const res = await fetch(url); 458 + if (!res.ok) 459 + throw new Error(`Public feed fetch failed: ${res.statusText}`); 460 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 461 } 462 }, ··· 474 return useQuery(constructFeedSkeletonQuery(options)); 475 } 476 477 + export function constructPreferencesQuery( 478 + agent?: ATPAPI.Agent | undefined, 479 + pdsUrl?: string | undefined 480 + ) { 481 return queryOptions({ 482 + queryKey: ["preferences", agent?.did], 483 queryFn: async () => { 484 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 485 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; ··· 490 }); 491 } 492 export function useQueryPreferences(options: { 493 + agent?: ATPAPI.Agent | undefined; 494 + pdsUrl?: string | undefined; 495 }) { 496 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 497 } 498 499 export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { 500 return queryOptions({ 501 queryKey: ["arbitrary", uri], 502 queryFn: async () => { 503 + if (!uri) return undefined as undefined; 504 const res = await fetch( 505 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 506 ); ··· 511 return undefined; 512 } 513 if (res.status === 400) return undefined; 514 + if ( 515 + data?.error === "InvalidRequest" && 516 + data.message?.includes("Could not find repo") 517 + ) { 518 return undefined; // cache โ€œnot foundโ€ 519 } 520 try { 521 if (!res.ok) throw new Error("Failed to fetch post"); 522 + return data as { 523 uri: string; 524 cid: string; 525 value: any; ··· 534 return failureCount < 2; 535 }, 536 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 537 + gcTime: /*0//*/ 5 * 60 * 1000, 538 }); 539 } 540 export function useQueryArbitrary(uri: string): UseQueryResult< ··· 545 }, 546 Error 547 >; 548 + export function useQueryArbitrary(): UseQueryResult<undefined, Error>; 549 export function useQueryArbitrary(uri?: string): UseQueryResult< 550 + | { 551 + uri: string; 552 + cid: string; 553 + value: any; 554 + } 555 + | undefined, 556 Error 557 >; 558 export function useQueryArbitrary(uri?: string) { 559 + const [slingshoturl] = useAtom(slingshotURLAtom); 560 return useQuery(constructArbitraryQuery(uri, slingshoturl)); 561 } 562 563 + export function constructFallbackNothingQuery() { 564 return queryOptions({ 565 queryKey: ["nothing"], 566 queryFn: async () => { 567 + return undefined; 568 }, 569 }); 570 } ··· 578 }[]; 579 }; 580 581 + export function constructAuthorFeedQuery( 582 + did: string, 583 + pdsUrl: string, 584 + collection: string = "app.bsky.feed.post" 585 + ) { 586 return queryOptions({ 587 + queryKey: ["authorFeed", did, collection], 588 queryFn: async ({ pageParam }: QueryFunctionContext) => { 589 const limit = 25; 590 + 591 const cursor = pageParam as string | undefined; 592 + const cursorParam = cursor ? `&cursor=${cursor}` : ""; 593 + 594 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`; 595 + 596 const res = await fetch(url); 597 if (!res.ok) throw new Error("Failed to fetch author's posts"); 598 + 599 return res.json() as Promise<ListRecordsResponse>; 600 }, 601 }); 602 } 603 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 + 615 return useInfiniteQuery({ 616 queryKey, 617 queryFn, ··· 632 // todo the hell is a unauthedfeedurl 633 unauthedfeedurl?: string; 634 }) { 635 + const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = 636 + options; 637 + 638 return queryOptions({ 639 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 640 + 641 + queryFn: async ({ 642 + pageParam, 643 + }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 644 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 645 + 646 if (isAuthed && !unauthedfeedurl) { 647 if (!agent || !pdsUrl || !feedServiceDid) { 648 + throw new Error( 649 + "Missing required info for authenticated feed fetch." 650 + ); 651 } 652 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 653 const res = await agent.fetchHandler(url, { ··· 657 "Content-Type": "application/json", 658 }, 659 }); 660 + if (!res.ok) 661 + throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 662 return (await res.json()) as FeedSkeletonPage; 663 } else { 664 const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 665 const res = await fetch(url); 666 + if (!res.ok) 667 + throw new Error(`Public feed fetch failed: ${res.statusText}`); 668 return (await res.json()) as FeedSkeletonPage; 669 } 670 }, ··· 680 unauthedfeedurl?: string; 681 }) { 682 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 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 + } 703 704 export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 705 + constellation: string; 706 + method: "/links"; 707 + target?: string; 708 + collection: string; 709 + path: string; 710 + staleMult?: number; 711 }) { 712 const safemult = query?.staleMult ?? 1; 713 // console.log( ··· 718 return infiniteQueryOptions({ 719 enabled: !!query?.target, 720 queryKey: [ 721 + "reddwarf_constellation", 722 query?.method, 723 query?.target, 724 query?.collection, 725 query?.path, 726 ] as const, 727 728 + queryFn: async ({ pageParam }: { pageParam?: string }) => { 729 + if (!query || !query?.target) return undefined; 730 731 + const method = query.method; 732 + const target = query.target; 733 + const collection = query.collection; 734 + const path = query.path; 735 + const cursor = pageParam; 736 737 const res = await fetch( 738 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${ 739 + collection ? `&collection=${encodeURIComponent(collection)}` : "" 740 + }${path ? `&path=${encodeURIComponent(path)}` : ""}${ 741 + cursor ? `&cursor=${encodeURIComponent(cursor)}` : "" 742 + }` 743 + ); 744 745 + if (!res.ok) throw new Error("Failed to fetch"); 746 747 + return (await res.json()) as linksRecordsResponse; 748 }, 749 750 + getNextPageParam: (lastPage) => { 751 + return (lastPage as any)?.cursor ?? undefined; 752 }, 753 initialPageParam: undefined, 754 staleTime: 5 * 60 * 1000 * safemult, 755 gcTime: 5 * 60 * 1000 * safemult, 756 + }); 757 + } 758 + 759 + export function useQueryLycanStatus() { 760 + const [lycanurl] = useAtom(lycanURLAtom); 761 + const { agent, status } = useAuth(); 762 + const { data: identity } = useQueryIdentity(agent?.did); 763 + return useQuery( 764 + constructLycanStatusCheckQuery({ 765 + agent: agent || undefined, 766 + isAuthed: status === "signedIn", 767 + pdsUrl: identity?.pds, 768 + feedServiceDid: "did:web:"+lycanurl, 769 + }) 770 + ); 771 + } 772 + 773 + export function constructLycanStatusCheckQuery(options: { 774 + agent?: ATPAPI.Agent; 775 + isAuthed: boolean; 776 + pdsUrl?: string; 777 + feedServiceDid?: string; 778 + }) { 779 + const { agent, isAuthed, pdsUrl, feedServiceDid } = options; 780 + 781 + return queryOptions({ 782 + queryKey: ["lycanStatus", { isAuthed, did: agent?.did }], 783 + 784 + queryFn: async () => { 785 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 786 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.getImportStatus`; 787 + const res = await agent.fetchHandler(url, { 788 + method: "GET", 789 + headers: { 790 + "atproto-proxy": `${feedServiceDid}#lycan`, 791 + "Content-Type": "application/json", 792 + }, 793 + }); 794 + if (!res.ok) 795 + throw new Error( 796 + `Authenticated lycan status fetch failed: ${res.statusText}` 797 + ); 798 + return (await res.json()) as statuschek; 799 + } 800 + return undefined; 801 + }, 802 + }); 803 + } 804 + 805 + type statuschek = { 806 + [key: string]: unknown; 807 + error?: "MethodNotImplemented"; 808 + message?: "Method Not Implemented"; 809 + status?: "finished" | "in_progress"; 810 + position?: string, 811 + progress?: number, 812 + 813 + }; 814 + 815 + //{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268} 816 + type importtype = { 817 + message?: "Import has already started" | "Import has been scheduled" 818 + } 819 + 820 + export function constructLycanRequestIndexQuery(options: { 821 + agent?: ATPAPI.Agent; 822 + isAuthed: boolean; 823 + pdsUrl?: string; 824 + feedServiceDid?: string; 825 + }) { 826 + const { agent, isAuthed, pdsUrl, feedServiceDid } = options; 827 + 828 + return queryOptions({ 829 + queryKey: ["lycanIndex", { isAuthed, did: agent?.did }], 830 + 831 + queryFn: async () => { 832 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 833 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.startImport`; 834 + const res = await agent.fetchHandler(url, { 835 + method: "POST", 836 + headers: { 837 + "atproto-proxy": `${feedServiceDid}#lycan`, 838 + "Content-Type": "application/json", 839 + }, 840 + }); 841 + if (!res.ok) 842 + throw new Error( 843 + `Authenticated lycan status fetch failed: ${res.statusText}` 844 + ); 845 + return await res.json() as importtype; 846 + } 847 + return undefined; 848 + }, 849 + }); 850 + } 851 + 852 + type LycanSearchPage = { 853 + terms: string[]; 854 + posts: string[]; 855 + cursor?: string; 856 + }; 857 + 858 + 859 + export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) { 860 + 861 + 862 + const [lycanurl] = useAtom(lycanURLAtom); 863 + const { agent, status } = useAuth(); 864 + const { data: identity } = useQueryIdentity(agent?.did); 865 + 866 + const { queryKey, queryFn } = constructLycanSearchQuery({ 867 + agent: agent || undefined, 868 + isAuthed: status === "signedIn", 869 + pdsUrl: identity?.pds, 870 + feedServiceDid: "did:web:"+lycanurl, 871 + query: options.query, 872 + type: options.type, 873 + }) 874 + 875 + return { 876 + ...useInfiniteQuery({ 877 + queryKey, 878 + queryFn, 879 + initialPageParam: undefined as never, 880 + getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 881 + //staleTime: Infinity, 882 + refetchOnWindowFocus: false, 883 + // enabled: 884 + // !!options.feedUri && 885 + // (options.isAuthed 886 + // ? ((!!options.agent && !!options.pdsUrl) || 887 + // !!options.unauthedfeedurl) && 888 + // !!options.feedServiceDid 889 + // : true), 890 + }), 891 + queryKey: queryKey, 892 + }; 893 + } 894 + 895 + 896 + export function constructLycanSearchQuery(options: { 897 + agent?: ATPAPI.Agent; 898 + isAuthed: boolean; 899 + pdsUrl?: string; 900 + feedServiceDid?: string; 901 + type: "likes" | "pins" | "reposts" | "quotes"; 902 + query: string; 903 + }) { 904 + const { agent, isAuthed, pdsUrl, feedServiceDid, type, query } = options; 905 + 906 + return infiniteQueryOptions({ 907 + queryKey: ["lycanSearch", query, type, { isAuthed, did: agent?.did }], 908 + 909 + queryFn: async ({ 910 + pageParam, 911 + }: QueryFunctionContext): Promise<LycanSearchPage | undefined> => { 912 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 913 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.searchPosts?query=${query}&collection=${type}${pageParam ? `&cursor=${pageParam}` : ""}`; 914 + const res = await agent.fetchHandler(url, { 915 + method: "GET", 916 + headers: { 917 + "atproto-proxy": `${feedServiceDid}#lycan`, 918 + "Content-Type": "application/json", 919 + }, 920 + }); 921 + if (!res.ok) 922 + throw new Error( 923 + `Authenticated lycan status fetch failed: ${res.statusText}` 924 + ); 925 + return (await res.json()) as LycanSearchPage; 926 + } 927 + return undefined; 928 + }, 929 + initialPageParam: undefined as never, 930 + getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 931 + }); 932 + }
+5
vite.config.ts
··· 13 const PROD_URL = "https://reddwarf.app" 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 16 function shp(url: string): string { 17 return url.replace(/^https?:\/\//, ''); 18 } ··· 23 generateMetadataPlugin({ 24 prod: PROD_URL, 25 dev: DEV_URL, 26 }), 27 TanStackRouterVite({ autoCodeSplitting: true }), 28 viteReact({
··· 13 const PROD_URL = "https://reddwarf.app" 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 16 + const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party" 17 + const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social" 18 + 19 function shp(url: string): string { 20 return url.replace(/^https?:\/\//, ''); 21 } ··· 26 generateMetadataPlugin({ 27 prod: PROD_URL, 28 dev: DEV_URL, 29 + prodResolver: PROD_HANDLE_RESOLVER_PDS, 30 + devResolver: DEV_HANDLE_RESOLVER_PDS, 31 }), 32 TanStackRouterVite({ autoCodeSplitting: true }), 33 viteReact({