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 },
+4
src/auto-imports.d.ts
··· 19 const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 23 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 24 }
··· 19 const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default 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 }
+6 -1
src/components/InfiniteCustomFeed.tsx
··· 14 feedUri: string; 15 pdsUrl?: string; 16 feedServiceDid?: string; 17 } 18 19 export function InfiniteCustomFeed({ 20 feedUri, 21 pdsUrl, 22 feedServiceDid, 23 }: InfiniteCustomFeedProps) { 24 const { agent } = useAuth(); 25 - const authed = !!agent?.did; 26 27 // const identityresultmaybe = useQueryIdentity(agent?.did); 28 // const identity = identityresultmaybe?.data; ··· 45 isAuthed: authed ?? false, 46 pdsUrl: pdsUrl, 47 feedServiceDid: feedServiceDid, 48 }); 49 const queryClient = useQueryClient(); 50
··· 14 feedUri: string; 15 pdsUrl?: string; 16 feedServiceDid?: string; 17 + authedOverride?: boolean; 18 + unauthedfeedurl?: string; 19 } 20 21 export function InfiniteCustomFeed({ 22 feedUri, 23 pdsUrl, 24 feedServiceDid, 25 + authedOverride, 26 + unauthedfeedurl, 27 }: InfiniteCustomFeedProps) { 28 const { agent } = useAuth(); 29 + const authed = authedOverride || !!agent?.did; 30 31 // const identityresultmaybe = useQueryIdentity(agent?.did); 32 // const identity = identityresultmaybe?.data; ··· 49 isAuthed: authed ?? false, 50 pdsUrl: pdsUrl, 51 feedServiceDid: feedServiceDid, 52 + unauthedfeedurl: unauthedfeedurl, 53 }); 54 const queryClient = useQueryClient(); 55
+124
src/components/ReusableTabRoute.tsx
···
··· 1 + import * as TabsPrimitive from "@radix-ui/react-tabs"; 2 + import { useAtom } from "jotai"; 3 + import { useEffect, useLayoutEffect } from "react"; 4 + 5 + import { isAtTopAtom, reusableTabRouteScrollAtom } from "~/utils/atoms"; 6 + 7 + /** 8 + * Please wrap your Route in a div, do not return a top-level fragment, 9 + * it will break navigation scroll restoration 10 + */ 11 + export function ReusableTabRoute({ 12 + route, 13 + tabs, 14 + }: { 15 + route: string; 16 + tabs: Record<string, React.ReactNode>; 17 + }) { 18 + const [reusableTabState, setReusableTabState] = useAtom( 19 + reusableTabRouteScrollAtom 20 + ); 21 + const [isAtTop] = useAtom(isAtTopAtom); 22 + 23 + const routeState = reusableTabState?.[route] ?? { 24 + activeTab: Object.keys(tabs)[0], 25 + scrollPositions: {}, 26 + }; 27 + const activeTab = routeState.activeTab; 28 + 29 + const handleValueChange = (newTab: string) => { 30 + setReusableTabState((prev) => { 31 + const current = prev?.[route] ?? routeState; 32 + return { 33 + ...prev, 34 + [route]: { 35 + ...current, 36 + scrollPositions: { 37 + ...current.scrollPositions, 38 + [current.activeTab]: window.scrollY, 39 + }, 40 + activeTab: newTab, 41 + }, 42 + }; 43 + }); 44 + }; 45 + 46 + // // todo, warning experimental, usually this doesnt work, 47 + // // like at all, and i usually do this for each tab 48 + // useLayoutEffect(() => { 49 + // const savedScroll = routeState.scrollPositions[activeTab] ?? 0; 50 + // window.scrollTo({ top: savedScroll }); 51 + // // eslint-disable-next-line react-hooks/exhaustive-deps 52 + // }, [activeTab, route]); 53 + 54 + useLayoutEffect(() => { 55 + return () => { 56 + setReusableTabState((prev) => { 57 + const current = prev?.[route] ?? routeState; 58 + return { 59 + ...prev, 60 + [route]: { 61 + ...current, 62 + scrollPositions: { 63 + ...current.scrollPositions, 64 + [current.activeTab]: window.scrollY, 65 + }, 66 + }, 67 + }; 68 + }); 69 + }; 70 + // eslint-disable-next-line react-hooks/exhaustive-deps 71 + }, []); 72 + 73 + return ( 74 + <TabsPrimitive.Root 75 + value={activeTab} 76 + onValueChange={handleValueChange} 77 + className={`w-full`} 78 + > 79 + <TabsPrimitive.List 80 + className={`flex sticky top-[52px] bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-[9] border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`} 81 + > 82 + {Object.entries(tabs).map(([key]) => ( 83 + <TabsPrimitive.Trigger key={key} value={key} className="m3tab"> 84 + {key} 85 + </TabsPrimitive.Trigger> 86 + ))} 87 + </TabsPrimitive.List> 88 + 89 + {Object.entries(tabs).map(([key, node]) => ( 90 + <TabsPrimitive.Content key={key} value={key} className="flex-1 min-h-[80dvh]"> 91 + {activeTab === key && node} 92 + </TabsPrimitive.Content> 93 + ))} 94 + </TabsPrimitive.Root> 95 + ); 96 + } 97 + 98 + export function useReusableTabScrollRestore(route: string) { 99 + const [reusableTabState] = useAtom( 100 + reusableTabRouteScrollAtom 101 + ); 102 + 103 + const routeState = reusableTabState?.[route]; 104 + const activeTab = routeState?.activeTab; 105 + 106 + useEffect(() => { 107 + const savedScroll = activeTab ? routeState?.scrollPositions[activeTab] ?? 0 : 0; 108 + //window.scrollTo(0, savedScroll); 109 + window.scrollTo({ top: savedScroll }); 110 + // eslint-disable-next-line react-hooks/exhaustive-deps 111 + }, []); 112 + } 113 + 114 + 115 + /* 116 + 117 + const [notifState] = useAtom(notificationsScrollAtom); 118 + const activeTab = notifState.activeTab; 119 + useEffect(() => { 120 + const savedY = notifState.scrollPositions[activeTab] ?? 0; 121 + window.scrollTo(0, savedY); 122 + }, [activeTab, notifState.scrollPositions]); 123 + 124 + */
+186 -55
src/components/UniversalPostRenderer.tsx
··· 1 import { useNavigate } from "@tanstack/react-router"; 2 import DOMPurify from "dompurify"; 3 import { useAtom } from "jotai"; ··· 9 import { 10 composerAtom, 11 constellationURLAtom, 12 imgCDNAtom, 13 - likedPostsAtom, 14 } from "~/utils/atoms"; 15 import { useHydratedEmbed } from "~/utils/useHydrated"; 16 import { ··· 38 feedviewpost?: boolean; 39 repostedby?: string; 40 style?: React.CSSProperties; 41 - ref?: React.Ref<HTMLDivElement>; 42 dataIndexPropPass?: number; 43 nopics?: boolean; 44 concise?: boolean; 45 lightboxCallback?: (d: LightboxProps) => void; 46 maxReplies?: number; 47 isQuote?: boolean; 48 } 49 50 // export async function cachedGetRecord({ ··· 157 lightboxCallback, 158 maxReplies, 159 isQuote, 160 }: UniversalPostRendererATURILoaderProps) { 161 // todo remove this once tree rendering is implemented, use a prop like isTree 162 const TEMPLINEAR = true; ··· 520 ? true 521 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 522 ? false 523 - : (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine 524 } 525 topReplyLine={topReplyLine} 526 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 542 lightboxCallback={lightboxCallback} 543 maxReplies={maxReplies} 544 isQuote={isQuote} 545 /> 546 <> 547 - {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( 548 <> 549 - {/* <div>hello</div> */} 550 - <MoreReplies atUri={atUri} /> 551 </> 552 - ) : (<></>)} 553 </> 554 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 555 <> ··· 644 lightboxCallback, 645 maxReplies, 646 isQuote, 647 }: { 648 postRecord: any; 649 profileRecord: any; ··· 659 feedviewpost?: boolean; 660 repostedby?: string; 661 style?: React.CSSProperties; 662 - ref?: React.Ref<HTMLDivElement>; 663 dataIndexPropPass?: number; 664 nopics?: boolean; 665 concise?: boolean; 666 lightboxCallback?: (d: LightboxProps) => void; 667 maxReplies?: number; 668 isQuote?: boolean; 669 }) { 670 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 671 const navigate = useNavigate(); ··· 736 // run(); 737 // }, [postRecord, resolved?.did]); 738 739 const { 740 data: hydratedEmbed, 741 isLoading: isEmbedLoading, ··· 830 // }, [fakepost, get, set]); 831 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 832 ?.uri; 833 - const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; 834 const replyhookvalue = useQueryIdentity( 835 feedviewpost ? feedviewpostreplydid : undefined 836 ); ··· 841 repostedby ? aturirepostbydid : undefined 842 ); 843 const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle; 844 return ( 845 <> 846 {/* <p> 847 {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} 848 </p> */} 849 <UniversalPostRenderer 850 expanded={detailed} 851 onPostClick={() => ··· 1204 1205 import defaultpfp from "~/../public/favicon.png"; 1206 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1207 - import { FollowButton, Mutual } from "~/routes/profile.$did"; 1208 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1209 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 1210 // import type { 1211 // ViewRecord, ··· 1358 depth?: number; 1359 repostedby?: string; 1360 style?: React.CSSProperties; 1361 - ref?: React.Ref<HTMLDivElement>; 1362 dataIndexPropPass?: number; 1363 nopics?: boolean; 1364 concise?: boolean; ··· 1367 }) { 1368 const parsed = new AtUri(post.uri); 1369 const navigate = useNavigate(); 1370 - const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom); 1371 const [hasRetweeted, setHasRetweeted] = useState<boolean>( 1372 post.viewer?.repost ? true : false 1373 - ); 1374 - const [hasLiked, setHasLiked] = useState<boolean>( 1375 - post.uri in likedPosts || post.viewer?.like ? true : false 1376 ); 1377 const [, setComposerPost] = useAtom(composerAtom); 1378 const { agent } = useAuth(); 1379 - const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like); 1380 const [retweetUri, setRetweetUri] = useState<string | undefined>( 1381 post.viewer?.repost 1382 ); 1383 - 1384 - const likeOrUnlikePost = async () => { 1385 - const newLikedPosts = { ...likedPosts }; 1386 - if (!agent) { 1387 - console.error("Agent is null or undefined"); 1388 - return; 1389 - } 1390 - if (hasLiked) { 1391 - if (post.uri in likedPosts) { 1392 - const likeUri = likedPosts[post.uri]; 1393 - setLikeUri(likeUri); 1394 - } 1395 - if (likeUri) { 1396 - await agent.deleteLike(likeUri); 1397 - setHasLiked(false); 1398 - delete newLikedPosts[post.uri]; 1399 - } 1400 - } else { 1401 - const { uri } = await agent.like(post.uri, post.cid); 1402 - setLikeUri(uri); 1403 - setHasLiked(true); 1404 - newLikedPosts[post.uri] = uri; 1405 - } 1406 - setLikedPosts(newLikedPosts); 1407 - }; 1408 1409 const repostOrUnrepostPost = async () => { 1410 if (!agent) { ··· 1435 : undefined; 1436 1437 const emergencySalt = randomString(); 1438 - const fedi = (post.record as { bridgyOriginalText?: string }) 1439 .bridgyOriginalText; 1440 1441 /* fuck you */ 1442 const isMainItem = false; 1443 const setMainItem = (any: any) => {}; 1444 // eslint-disable-next-line react-hooks/refs 1445 - console.log("Received ref in UniversalPostRenderer:", ref); 1446 return ( 1447 <div ref={ref} style={style} data-index={dataIndexPropPass}> 1448 <div ··· 1575 {post.author.displayName || post.author.handle}{" "} 1576 </div> 1577 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1578 - <Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "} 1579 </div> 1580 </div> 1581 {uprrrsauthor?.description && ( ··· 1823 </div> 1824 </> 1825 )} 1826 - <div style={{ paddingTop: post.embed && !concise && depth < 1 ? 4 : 0 }}> 1827 <> 1828 {expanded && ( 1829 <div ··· 1919 </DropdownMenu.Root> 1920 <HitSlopButton 1921 onClick={() => { 1922 - likeOrUnlikePost(); 1923 }} 1924 style={{ 1925 ...btnstyle, 1926 - ...(hasLiked ? { color: "#EC4899" } : {}), 1927 }} 1928 > 1929 - {hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 1930 - {(post.likeCount || 0) + (hasLiked ? 1 : 0)} 1931 </HitSlopButton> 1932 <div style={{ display: "flex", gap: 8 }}> 1933 <HitSlopButton ··· 1941 "/post/" + 1942 post.uri.split("/").pop() 1943 ); 1944 } catch (_e) { 1945 // idk 1946 } 1947 }} 1948 style={{ ··· 1951 > 1952 <MdiShareVariant /> 1953 </HitSlopButton> 1954 - <span style={btnstyle}> 1955 - <MdiMoreHoriz /> 1956 - </span> 1957 </div> 1958 </div> 1959 )} ··· 2179 } 2180 2181 if (AppBskyEmbedRecord.isView(embed)) { 2182 // custom feed embed (i.e. generator view) 2183 if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 2184 // stopgap sorry ··· 2188 // <MaybeFeedCard view={embed.record} /> 2189 // </div> 2190 // ) 2191 } 2192 2193 // list embed ··· 2199 // <MaybeListCard view={embed.record} /> 2200 // </div> 2201 // ) 2202 } 2203 2204 // starter pack embed ··· 2210 // <StarterPackCard starterPack={embed.record} /> 2211 // </div> 2212 // ) 2213 } 2214 2215 // quote post ··· 2269 </div> 2270 ); 2271 } else { 2272 return <>sorry</>; 2273 } 2274 //return <QuotePostRenderer record={embed.record} moderation={moderation} />; ··· 2702 className="link" 2703 style={{ 2704 textDecoration: "none", 2705 - color: "rgb(29, 122, 242)", 2706 wordBreak: "break-all", 2707 }} 2708 target="_blank" ··· 2722 result.push( 2723 <span 2724 key={start} 2725 - style={{ color: "rgb(29, 122, 242)" }} 2726 className=" cursor-pointer" 2727 onClick={(e) => { 2728 e.stopPropagation(); ··· 2740 result.push( 2741 <span 2742 key={start} 2743 - style={{ color: "rgb(29, 122, 242)" }} 2744 onClick={(e) => { 2745 e.stopPropagation(); 2746 }}
··· 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"; 18 import { ··· 40 feedviewpost?: boolean; 41 repostedby?: string; 42 style?: React.CSSProperties; 43 + ref?: React.RefObject<HTMLDivElement>; 44 dataIndexPropPass?: number; 45 nopics?: boolean; 46 concise?: boolean; 47 lightboxCallback?: (d: LightboxProps) => void; 48 maxReplies?: number; 49 isQuote?: boolean; 50 + filterNoReplies?: boolean; 51 + filterMustHaveMedia?: boolean; 52 + filterMustBeReply?: boolean; 53 } 54 55 // export async function cachedGetRecord({ ··· 162 lightboxCallback, 163 maxReplies, 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} ··· 552 lightboxCallback={lightboxCallback} 553 maxReplies={maxReplies} 554 isQuote={isQuote} 555 + filterNoReplies={filterNoReplies} 556 + filterMustHaveMedia={filterMustHaveMedia} 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 <> ··· 659 lightboxCallback, 660 maxReplies, 661 isQuote, 662 + filterNoReplies, 663 + filterMustHaveMedia, 664 + filterMustBeReply, 665 }: { 666 postRecord: any; 667 profileRecord: any; ··· 677 feedviewpost?: boolean; 678 repostedby?: string; 679 style?: React.CSSProperties; 680 + ref?: React.RefObject<HTMLDivElement>; 681 dataIndexPropPass?: number; 682 nopics?: boolean; 683 concise?: boolean; 684 lightboxCallback?: (d: LightboxProps) => void; 685 maxReplies?: number; 686 isQuote?: boolean; 687 + filterNoReplies?: boolean; 688 + filterMustHaveMedia?: boolean; 689 + filterMustBeReply?: boolean; 690 }) { 691 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 692 const navigate = useNavigate(); ··· 757 // run(); 758 // }, [postRecord, resolved?.did]); 759 760 + const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed; 761 + const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; 762 + const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; 763 + const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia"; 764 + const isQuotewithImages = 765 + isquotewithmedia && 766 + (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 767 + "app.bsky.embed.images"; 768 + const isQuotewithVideo = 769 + isquotewithmedia && 770 + (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 771 + "app.bsky.embed.video"; 772 + 773 + const hasMedia = 774 + hasEmbed && 775 + (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo); 776 + 777 const { 778 data: hydratedEmbed, 779 isLoading: isEmbedLoading, ··· 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 ); ··· 880 repostedby ? aturirepostbydid : undefined 881 ); 882 const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle; 883 + 884 + if (filterNoReplies && thereply) return null; 885 + 886 + if (filterMustHaveMedia && !hasMedia) return null; 887 + 888 + if (filterMustBeReply && !thereply) return null; 889 + 890 return ( 891 <> 892 {/* <p> 893 {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} 894 </p> */} 895 + {/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span> 896 + <span>thereply is {thereply ? "true" : "false"}</span> */} 897 <UniversalPostRenderer 898 expanded={detailed} 899 onPostClick={() => ··· 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"; 1264 // import type { 1265 // ViewRecord, ··· 1412 depth?: number; 1413 repostedby?: string; 1414 style?: React.CSSProperties; 1415 + ref?: React.RefObject<HTMLDivElement>; 1416 dataIndexPropPass?: number; 1417 nopics?: boolean; 1418 concise?: boolean; ··· 1421 }) { 1422 const parsed = new AtUri(post.uri); 1423 const navigate = useNavigate(); 1424 const [hasRetweeted, setHasRetweeted] = useState<boolean>( 1425 post.viewer?.repost ? true : false 1426 ); 1427 const [, setComposerPost] = useAtom(composerAtom); 1428 const { agent } = useAuth(); 1429 const [retweetUri, setRetweetUri] = useState<string | undefined>( 1430 post.viewer?.repost 1431 ); 1432 + const { liked, toggle, backfill } = useFastLike(post.uri, post.cid); 1433 + // const bovref = useBackfillOnView(post.uri, post.cid); 1434 + // React.useLayoutEffect(()=>{ 1435 + // if (expanded && !isQuote) { 1436 + // backfill(); 1437 + // } 1438 + // },[backfill, expanded, isQuote]) 1439 1440 const repostOrUnrepostPost = async () => { 1441 if (!agent) { ··· 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; 1510 const setMainItem = (any: any) => {}; 1511 // eslint-disable-next-line react-hooks/refs 1512 + //console.log("Received ref in UniversalPostRenderer:", usedref); 1513 return ( 1514 <div ref={ref} style={style} data-index={dataIndexPropPass}> 1515 <div ··· 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 ··· 1991 </DropdownMenu.Root> 1992 <HitSlopButton 1993 onClick={() => { 1994 + toggle(); 1995 }} 1996 style={{ 1997 ...btnstyle, 1998 + ...(liked ? { color: "#EC4899" } : {}), 1999 }} 2000 > 2001 + {liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 2002 + {(post.likeCount || 0) + (liked ? 1 : 0)} 2003 </HitSlopButton> 2004 <div style={{ display: "flex", gap: 8 }}> 2005 <HitSlopButton ··· 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 )} ··· 2265 } 2266 2267 if (AppBskyEmbedRecord.isView(embed)) { 2268 + // hey im really lazy and im gonna do it the bad way 2269 + const reallybaduri = (embed?.record as any)?.uri as string | undefined; 2270 + const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined; 2271 + 2272 // custom feed embed (i.e. generator view) 2273 if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 2274 // stopgap sorry ··· 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 ··· 2399 </div> 2400 ); 2401 } else { 2402 + console.log("what the hell is a ", embed); 2403 return <>sorry</>; 2404 } 2405 //return <QuotePostRenderer record={embed.record} moderation={moderation} />; ··· 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 }}
+163
src/providers/LikeMutationQueueProvider.tsx
···
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { TID } from "@atproto/common-web"; 3 + import { useQueryClient } from "@tanstack/react-query"; 4 + import { useAtom } from "jotai"; 5 + import React, { createContext, use, useCallback, useEffect, useRef } from "react"; 6 + 7 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 + import { renderSnack } from "~/routes/__root"; 9 + import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms"; 10 + import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery"; 11 + 12 + export type LikeRecord = { uri: string; target: string; cid: string }; 13 + export type LikeMutation = { type: 'like'; target: string; cid: string }; 14 + export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord }; 15 + export type Mutation = LikeMutation | UnlikeMutation; 16 + 17 + interface LikeMutationQueueContextType { 18 + fastState: (target: string) => LikeRecord | null | undefined; 19 + fastToggle: (target:string, cid:string) => void; 20 + backfillState: (target: string, user: string) => Promise<void>; 21 + } 22 + 23 + const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined); 24 + 25 + export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) { 26 + const { agent } = useAuth(); 27 + const queryClient = useQueryClient(); 28 + const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom); 29 + const [constellationurl] = useAtom(constellationURLAtom); 30 + 31 + const likedPostsRef = useRef(likedPosts); 32 + useEffect(() => { 33 + likedPostsRef.current = likedPosts; 34 + }, [likedPosts]); 35 + 36 + const queueRef = useRef<Mutation[]>([]); 37 + const runningRef = useRef(false); 38 + 39 + const fastState = (target: string) => likedPosts[target]; 40 + 41 + const setFastState = useCallback( 42 + (target: string, record: LikeRecord | null) => 43 + setLikedPosts((prev) => ({ ...prev, [target]: record })), 44 + [setLikedPosts] 45 + ); 46 + 47 + const enqueue = (mutation: Mutation) => queueRef.current.push(mutation); 48 + 49 + const fastToggle = useCallback((target: string, cid: string) => { 50 + const likedRecord = likedPostsRef.current[target]; 51 + 52 + if (likedRecord) { 53 + setFastState(target, null); 54 + if (likedRecord.uri !== 'pending') { 55 + enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord }); 56 + } 57 + } else { 58 + setFastState(target, { uri: "pending", target, cid }); 59 + enqueue({ type: "like", target, cid }); 60 + } 61 + }, [setFastState]); 62 + 63 + /** 64 + * 65 + * @deprecated dont use it yet, will cause infinite rerenders 66 + */ 67 + const backfillState = async (target: string, user: string) => { 68 + const query = constructConstellationQuery({ 69 + constellation: constellationurl, 70 + method: "/links", 71 + target, 72 + collection: "app.bsky.feed.like", 73 + path: ".subject.uri", 74 + dids: [user], 75 + }); 76 + const data = await queryClient.fetchQuery(query); 77 + const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? []; 78 + const found = likes.find((r) => r.did === user); 79 + if (found) { 80 + const uri = `at://${found.did}/${found.collection}/${found.rkey}`; 81 + const ciddata = await queryClient.fetchQuery( 82 + constructArbitraryQuery(uri) 83 + ); 84 + if (ciddata?.cid) 85 + setFastState(target, { uri, target, cid: ciddata?.cid }); 86 + } else { 87 + setFastState(target, null); 88 + } 89 + }; 90 + 91 + 92 + useEffect(() => { 93 + if (!agent?.did) return; 94 + 95 + const processQueue = async () => { 96 + if (runningRef.current || queueRef.current.length === 0) return; 97 + runningRef.current = true; 98 + 99 + while (queueRef.current.length > 0) { 100 + const mutation = queueRef.current.shift()!; 101 + try { 102 + if (mutation.type === "like") { 103 + const newRecord = { 104 + repo: agent.did!, 105 + collection: "app.bsky.feed.like", 106 + rkey: TID.next().toString(), 107 + record: { 108 + $type: "app.bsky.feed.like", 109 + subject: { uri: mutation.target, cid: mutation.cid }, 110 + createdAt: new Date().toISOString(), 111 + }, 112 + }; 113 + const response = await agent.com.atproto.repo.createRecord(newRecord); 114 + if (!response.success) throw new Error("createRecord failed"); 115 + 116 + const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`; 117 + setFastState(mutation.target, { 118 + uri, 119 + target: mutation.target, 120 + cid: mutation.cid, 121 + }); 122 + } else if (mutation.type === "unlike") { 123 + const aturi = new AtUri(mutation.likeRecordUri); 124 + await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey }); 125 + setFastState(mutation.target, null); 126 + } 127 + } catch (err) { 128 + console.error("Like mutation failed, reverting:", err); 129 + renderSnack({ 130 + title: 'Like Mutation Failed', 131 + description: 'Please try again.', 132 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 133 + }) 134 + if (mutation.type === 'like') { 135 + setFastState(mutation.target, null); 136 + } else if (mutation.type === 'unlike') { 137 + setFastState(mutation.target, mutation.originalRecord); 138 + } 139 + } 140 + } 141 + runningRef.current = false; 142 + }; 143 + 144 + const interval = setInterval(processQueue, 1000); 145 + return () => clearInterval(interval); 146 + }, [agent, setFastState]); 147 + 148 + const value = { fastState, fastToggle, backfillState }; 149 + 150 + return ( 151 + <LikeMutationQueueContext value={value}> 152 + {children} 153 + </LikeMutationQueueContext> 154 + ); 155 + } 156 + 157 + export function useLikeMutationQueue() { 158 + const context = use(LikeMutationQueueContext); 159 + if (context === undefined) { 160 + throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider'); 161 + } 162 + return context; 163 + }
+84
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' 18 import { Route as CallbackIndexRouteImport } from './routes/callback/index' 19 import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout' 20 import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index' 21 import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' 22 import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a' 23 import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey' 24 import { Route as ProfileDidPostRkeyRepostedByRouteImport } from './routes/profile.$did/post.$rkey.reposted-by' 25 import { Route as ProfileDidPostRkeyQuotesRouteImport } from './routes/profile.$did/post.$rkey.quotes' 26 import { Route as ProfileDidPostRkeyLikedByRouteImport } from './routes/profile.$did/post.$rkey.liked-by' ··· 41 path: '/notifications', 42 getParentRoute: () => rootRouteImport, 43 } as any) 44 const FeedsRoute = FeedsRouteImport.update({ 45 id: '/feeds', 46 path: '/feeds', ··· 70 path: '/profile/$did/', 71 getParentRoute: () => rootRouteImport, 72 } as any) 73 const PathlessLayoutNestedLayoutRouteBRoute = 74 PathlessLayoutNestedLayoutRouteBRouteImport.update({ 75 id: '/route-b', ··· 87 path: '/profile/$did/post/$rkey', 88 getParentRoute: () => rootRouteImport, 89 } as any) 90 const ProfileDidPostRkeyRepostedByRoute = 91 ProfileDidPostRkeyRepostedByRouteImport.update({ 92 id: '/reposted-by', ··· 115 export interface FileRoutesByFullPath { 116 '/': typeof IndexRoute 117 '/feeds': typeof FeedsRoute 118 '/notifications': typeof NotificationsRoute 119 '/search': typeof SearchRoute 120 '/settings': typeof SettingsRoute 121 '/callback': typeof CallbackIndexRoute 122 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 123 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 124 '/profile/$did': typeof ProfileDidIndexRoute 125 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 126 '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 127 '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute ··· 131 export interface FileRoutesByTo { 132 '/': typeof IndexRoute 133 '/feeds': typeof FeedsRoute 134 '/notifications': typeof NotificationsRoute 135 '/search': typeof SearchRoute 136 '/settings': typeof SettingsRoute 137 '/callback': typeof CallbackIndexRoute 138 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 139 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 140 '/profile/$did': typeof ProfileDidIndexRoute 141 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 142 '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 143 '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute ··· 149 '/': typeof IndexRoute 150 '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren 151 '/feeds': typeof FeedsRoute 152 '/notifications': typeof NotificationsRoute 153 '/search': typeof SearchRoute 154 '/settings': typeof SettingsRoute ··· 156 '/callback/': typeof CallbackIndexRoute 157 '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 158 '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 159 '/profile/$did/': typeof ProfileDidIndexRoute 160 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 161 '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 162 '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute ··· 168 fullPaths: 169 | '/' 170 | '/feeds' 171 | '/notifications' 172 | '/search' 173 | '/settings' 174 | '/callback' 175 | '/route-a' 176 | '/route-b' 177 | '/profile/$did' 178 | '/profile/$did/post/$rkey' 179 | '/profile/$did/post/$rkey/liked-by' 180 | '/profile/$did/post/$rkey/quotes' ··· 184 to: 185 | '/' 186 | '/feeds' 187 | '/notifications' 188 | '/search' 189 | '/settings' 190 | '/callback' 191 | '/route-a' 192 | '/route-b' 193 | '/profile/$did' 194 | '/profile/$did/post/$rkey' 195 | '/profile/$did/post/$rkey/liked-by' 196 | '/profile/$did/post/$rkey/quotes' ··· 201 | '/' 202 | '/_pathlessLayout' 203 | '/feeds' 204 | '/notifications' 205 | '/search' 206 | '/settings' ··· 208 | '/callback/' 209 | '/_pathlessLayout/_nested-layout/route-a' 210 | '/_pathlessLayout/_nested-layout/route-b' 211 | '/profile/$did/' 212 | '/profile/$did/post/$rkey' 213 | '/profile/$did/post/$rkey/liked-by' 214 | '/profile/$did/post/$rkey/quotes' ··· 220 IndexRoute: typeof IndexRoute 221 PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren 222 FeedsRoute: typeof FeedsRoute 223 NotificationsRoute: typeof NotificationsRoute 224 SearchRoute: typeof SearchRoute 225 SettingsRoute: typeof SettingsRoute 226 CallbackIndexRoute: typeof CallbackIndexRoute 227 ProfileDidIndexRoute: typeof ProfileDidIndexRoute 228 ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren 229 } 230 ··· 251 preLoaderRoute: typeof NotificationsRouteImport 252 parentRoute: typeof rootRouteImport 253 } 254 '/feeds': { 255 id: '/feeds' 256 path: '/feeds' ··· 293 preLoaderRoute: typeof ProfileDidIndexRouteImport 294 parentRoute: typeof rootRouteImport 295 } 296 '/_pathlessLayout/_nested-layout/route-b': { 297 id: '/_pathlessLayout/_nested-layout/route-b' 298 path: '/route-b' ··· 312 path: '/profile/$did/post/$rkey' 313 fullPath: '/profile/$did/post/$rkey' 314 preLoaderRoute: typeof ProfileDidPostRkeyRouteImport 315 parentRoute: typeof rootRouteImport 316 } 317 '/profile/$did/post/$rkey/reposted-by': { ··· 396 IndexRoute: IndexRoute, 397 PathlessLayoutRoute: PathlessLayoutRouteWithChildren, 398 FeedsRoute: FeedsRoute, 399 NotificationsRoute: NotificationsRoute, 400 SearchRoute: SearchRoute, 401 SettingsRoute: SettingsRoute, 402 CallbackIndexRoute: CallbackIndexRoute, 403 ProfileDidIndexRoute: ProfileDidIndexRoute, 404 ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren, 405 } 406 export const routeTree = rootRouteImport
··· 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' 19 import { Route as CallbackIndexRouteImport } from './routes/callback/index' 20 import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout' 21 import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index' 22 + import { Route as ProfileDidFollowsRouteImport } from './routes/profile.$did/follows' 23 + import { Route as ProfileDidFollowersRouteImport } from './routes/profile.$did/followers' 24 import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' 25 import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a' 26 import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey' 27 + import { Route as ProfileDidFeedRkeyRouteImport } from './routes/profile.$did/feed.$rkey' 28 import { Route as ProfileDidPostRkeyRepostedByRouteImport } from './routes/profile.$did/post.$rkey.reposted-by' 29 import { Route as ProfileDidPostRkeyQuotesRouteImport } from './routes/profile.$did/post.$rkey.quotes' 30 import { Route as ProfileDidPostRkeyLikedByRouteImport } from './routes/profile.$did/post.$rkey.liked-by' ··· 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({ 54 id: '/feeds', 55 path: '/feeds', ··· 79 path: '/profile/$did/', 80 getParentRoute: () => rootRouteImport, 81 } as any) 82 + const ProfileDidFollowsRoute = ProfileDidFollowsRouteImport.update({ 83 + id: '/profile/$did/follows', 84 + path: '/profile/$did/follows', 85 + getParentRoute: () => rootRouteImport, 86 + } as any) 87 + const ProfileDidFollowersRoute = ProfileDidFollowersRouteImport.update({ 88 + id: '/profile/$did/followers', 89 + path: '/profile/$did/followers', 90 + getParentRoute: () => rootRouteImport, 91 + } as any) 92 const PathlessLayoutNestedLayoutRouteBRoute = 93 PathlessLayoutNestedLayoutRouteBRouteImport.update({ 94 id: '/route-b', ··· 106 path: '/profile/$did/post/$rkey', 107 getParentRoute: () => rootRouteImport, 108 } as any) 109 + const ProfileDidFeedRkeyRoute = ProfileDidFeedRkeyRouteImport.update({ 110 + id: '/profile/$did/feed/$rkey', 111 + path: '/profile/$did/feed/$rkey', 112 + getParentRoute: () => rootRouteImport, 113 + } as any) 114 const ProfileDidPostRkeyRepostedByRoute = 115 ProfileDidPostRkeyRepostedByRouteImport.update({ 116 id: '/reposted-by', ··· 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 146 '/callback': typeof CallbackIndexRoute 147 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 148 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 149 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 150 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 151 '/profile/$did': typeof ProfileDidIndexRoute 152 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 153 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 154 '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 155 '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute ··· 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 166 '/callback': typeof CallbackIndexRoute 167 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 168 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 169 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 170 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 171 '/profile/$did': typeof ProfileDidIndexRoute 172 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 173 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 174 '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 175 '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute ··· 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 ··· 189 '/callback/': typeof CallbackIndexRoute 190 '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 191 '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 192 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 193 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 194 '/profile/$did/': typeof ProfileDidIndexRoute 195 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 196 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 197 '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 198 '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute ··· 204 fullPaths: 205 | '/' 206 | '/feeds' 207 + | '/moderation' 208 | '/notifications' 209 | '/search' 210 | '/settings' 211 | '/callback' 212 | '/route-a' 213 | '/route-b' 214 + | '/profile/$did/followers' 215 + | '/profile/$did/follows' 216 | '/profile/$did' 217 + | '/profile/$did/feed/$rkey' 218 | '/profile/$did/post/$rkey' 219 | '/profile/$did/post/$rkey/liked-by' 220 | '/profile/$did/post/$rkey/quotes' ··· 224 to: 225 | '/' 226 | '/feeds' 227 + | '/moderation' 228 | '/notifications' 229 | '/search' 230 | '/settings' 231 | '/callback' 232 | '/route-a' 233 | '/route-b' 234 + | '/profile/$did/followers' 235 + | '/profile/$did/follows' 236 | '/profile/$did' 237 + | '/profile/$did/feed/$rkey' 238 | '/profile/$did/post/$rkey' 239 | '/profile/$did/post/$rkey/liked-by' 240 | '/profile/$did/post/$rkey/quotes' ··· 245 | '/' 246 | '/_pathlessLayout' 247 | '/feeds' 248 + | '/moderation' 249 | '/notifications' 250 | '/search' 251 | '/settings' ··· 253 | '/callback/' 254 | '/_pathlessLayout/_nested-layout/route-a' 255 | '/_pathlessLayout/_nested-layout/route-b' 256 + | '/profile/$did/followers' 257 + | '/profile/$did/follows' 258 | '/profile/$did/' 259 + | '/profile/$did/feed/$rkey' 260 | '/profile/$did/post/$rkey' 261 | '/profile/$did/post/$rkey/liked-by' 262 | '/profile/$did/post/$rkey/quotes' ··· 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 275 CallbackIndexRoute: typeof CallbackIndexRoute 276 + ProfileDidFollowersRoute: typeof ProfileDidFollowersRoute 277 + ProfileDidFollowsRoute: typeof ProfileDidFollowsRoute 278 ProfileDidIndexRoute: typeof ProfileDidIndexRoute 279 + ProfileDidFeedRkeyRoute: typeof ProfileDidFeedRkeyRoute 280 ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren 281 } 282 ··· 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': { 314 id: '/feeds' 315 path: '/feeds' ··· 352 preLoaderRoute: typeof ProfileDidIndexRouteImport 353 parentRoute: typeof rootRouteImport 354 } 355 + '/profile/$did/follows': { 356 + id: '/profile/$did/follows' 357 + path: '/profile/$did/follows' 358 + fullPath: '/profile/$did/follows' 359 + preLoaderRoute: typeof ProfileDidFollowsRouteImport 360 + parentRoute: typeof rootRouteImport 361 + } 362 + '/profile/$did/followers': { 363 + id: '/profile/$did/followers' 364 + path: '/profile/$did/followers' 365 + fullPath: '/profile/$did/followers' 366 + preLoaderRoute: typeof ProfileDidFollowersRouteImport 367 + parentRoute: typeof rootRouteImport 368 + } 369 '/_pathlessLayout/_nested-layout/route-b': { 370 id: '/_pathlessLayout/_nested-layout/route-b' 371 path: '/route-b' ··· 385 path: '/profile/$did/post/$rkey' 386 fullPath: '/profile/$did/post/$rkey' 387 preLoaderRoute: typeof ProfileDidPostRkeyRouteImport 388 + parentRoute: typeof rootRouteImport 389 + } 390 + '/profile/$did/feed/$rkey': { 391 + id: '/profile/$did/feed/$rkey' 392 + path: '/profile/$did/feed/$rkey' 393 + fullPath: '/profile/$did/feed/$rkey' 394 + preLoaderRoute: typeof ProfileDidFeedRkeyRouteImport 395 parentRoute: typeof rootRouteImport 396 } 397 '/profile/$did/post/$rkey/reposted-by': { ··· 476 IndexRoute: IndexRoute, 477 PathlessLayoutRoute: PathlessLayoutRouteWithChildren, 478 FeedsRoute: FeedsRoute, 479 + ModerationRoute: ModerationRoute, 480 NotificationsRoute: NotificationsRoute, 481 SearchRoute: SearchRoute, 482 SettingsRoute: SettingsRoute, 483 CallbackIndexRoute: CallbackIndexRoute, 484 + ProfileDidFollowersRoute: ProfileDidFollowersRoute, 485 + ProfileDidFollowsRoute: ProfileDidFollowsRoute, 486 ProfileDidIndexRoute: ProfileDidIndexRoute, 487 + ProfileDidFeedRkeyRoute: ProfileDidFeedRkeyRoute, 488 ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren, 489 } 490 export const routeTree = rootRouteImport
+178 -16
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"; ··· 22 import Login from "~/components/Login"; 23 import { NotFound } from "~/components/NotFound"; 24 import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; 25 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 26 import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms"; 27 import { seo } from "~/utils/seo"; ··· 79 function RootComponent() { 80 return ( 81 <UnifiedAuthProvider> 82 - <RootDocument> 83 - <KeepAliveProvider> 84 - <KeepAliveOutlet /> 85 - </KeepAliveProvider> 86 - </RootDocument> 87 </UnifiedAuthProvider> 88 ); 89 } 90 91 function RootDocument({ children }: { children: React.ReactNode }) { 92 useAtomCssVar(hueAtom, "--tw-gray-hue"); 93 const location = useLocation(); ··· 103 const isSettings = location.pathname.startsWith("/settings"); 104 const isSearch = location.pathname.startsWith("/search"); 105 const isFeeds = location.pathname.startsWith("/feeds"); 106 107 const locationEnum: 108 | "feeds" ··· 110 | "settings" 111 | "notifications" 112 | "profile" 113 | "home" = isFeeds 114 ? "feeds" 115 : isSearch ··· 120 ? "notifications" 121 : isProfile 122 ? "profile" 123 - : "home"; 124 125 const [, setComposerPost] = useAtom(composerAtom); 126 ··· 131 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 132 <nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start"> 133 <div className="flex items-center gap-3 mb-4"> 134 - <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 135 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 136 Red Dwarf{" "} 137 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 193 }) 194 } 195 text="Feeds" 196 /> 197 <MaterialNavItem 198 InactiveIcon={ ··· 232 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 233 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 234 //active={true} 235 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 236 text="Post" 237 /> 238 </div> ··· 370 371 <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"> 372 <div className="flex items-center gap-3 mb-4"> 373 - <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 374 </div> 375 <MaterialNavItem 376 small ··· 433 /> 434 <MaterialNavItem 435 small 436 InactiveIcon={ 437 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 438 } ··· 472 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 473 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 474 //active={true} 475 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 476 text="Post" 477 /> 478 </div> ··· 482 <button 483 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" 484 style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }} 485 - onClick={() => setComposerPost({ kind: 'root' })} 486 type="button" 487 aria-label="Create Post" 488 > ··· 499 </main> 500 501 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 502 - <div className="px-4 pt-4"><Import /></div> 503 <Login /> 504 505 <div className="flex-1"></div> 506 <p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4"> 507 - 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) 508 </p> 509 </aside> 510 </div> ··· 651 <IconMaterialSymbolsSettingsOutline className="w-6 h-6" /> 652 } 653 ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />} 654 - active={locationEnum === "settings"} 655 onClickCallbback={() => 656 navigate({ 657 to: "/settings", ··· 680 ) : ( 681 <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"> 682 <div className="flex items-center gap-2"> 683 - <FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 684 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 685 Red Dwarf{" "} 686 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 700 ); 701 } 702 703 - function MaterialNavItem({ 704 InactiveIcon, 705 ActiveIcon, 706 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"; ··· 24 import Login from "~/components/Login"; 25 import { NotFound } from "~/components/NotFound"; 26 import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; 27 + import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; 28 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 29 import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms"; 30 import { seo } from "~/utils/seo"; ··· 82 function RootComponent() { 83 return ( 84 <UnifiedAuthProvider> 85 + <LikeMutationQueueProvider> 86 + <RootDocument> 87 + <KeepAliveProvider> 88 + <AppToaster /> 89 + <KeepAliveOutlet /> 90 + </KeepAliveProvider> 91 + </RootDocument> 92 + </LikeMutationQueueProvider> 93 </UnifiedAuthProvider> 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"> ··· 313 }) 314 } 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={ ··· 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 }
+44 -33
src/routes/index.tsx
··· 359 > 360 {!isAuthRestoring && savedFeeds.length > 0 ? ( 361 <div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}> 362 - {savedFeeds.map((item: any, idx: number) => { 363 - const label = item.value.split("/").pop() || item.value; 364 - const isActive = selectedFeed === item.value; 365 - return ( 366 - <button 367 - key={item.value || idx} 368 - className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${ 369 - isActive 370 - ? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600" 371 - : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800" 372 - // ? "bg-gray-500 text-white" 373 - // : item.pinned 374 - // ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200" 375 - // : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200" 376 - }`} 377 - onClick={() => setSelectedFeed(item.value)} 378 - title={item.value} 379 - > 380 - {label} 381 - {item.pinned && ( 382 - <span 383 - className={`ml-1 text-xs ${ 384 - isActive 385 - ? "text-gray-900 dark:text-gray-100" 386 - : "text-gray-600 dark:text-gray-400" 387 - }`} 388 - > 389 - โ˜… 390 - </span> 391 - )} 392 - </button> 393 - ); 394 - })} 395 </div> 396 ) : ( 397 // <span className="text-xl font-bold ml-2">Home</span> ··· 435 </div> 436 ); 437 } 438 // not even used lmaooo 439 440 // export async function cachedResolveDIDWEBDOC({
··· 359 > 360 {!isAuthRestoring && savedFeeds.length > 0 ? ( 361 <div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}> 362 + {savedFeeds.map((item: any, idx: number) => {return <FeedTabOnTop key={item} item={item} idx={idx} />})} 363 </div> 364 ) : ( 365 // <span className="text-xl font-bold ml-2">Home</span> ··· 403 </div> 404 ); 405 } 406 + 407 + 408 + // todo please use types this is dangerous very dangerous. 409 + // todo fix this whenever proper preferences is handled 410 + function FeedTabOnTop({item, idx}:{item: any, idx: number}) { 411 + const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom); 412 + const selectedFeed = persistentSelectedFeed 413 + const setSelectedFeed = setPersistentSelectedFeed 414 + const rkey = item.value.split("/").pop() || item.value; 415 + const isActive = selectedFeed === item.value; 416 + const { data: feedrecord } = useQueryArbitrary(item.value) 417 + const label = feedrecord?.value?.displayName || rkey 418 + return ( 419 + <button 420 + key={item.value || idx} 421 + className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${ 422 + isActive 423 + ? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600" 424 + : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800" 425 + // ? "bg-gray-500 text-white" 426 + // : item.pinned 427 + // ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200" 428 + // : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200" 429 + }`} 430 + onClick={() => setSelectedFeed(item.value)} 431 + title={item.value} 432 + > 433 + {label} 434 + {item.pinned && ( 435 + <span 436 + className={`ml-1 text-xs ${ 437 + isActive 438 + ? "text-gray-900 dark:text-gray-100" 439 + : "text-gray-600 dark:text-gray-400" 440 + }`} 441 + > 442 + โ˜… 443 + </span> 444 + )} 445 + </button> 446 + ); 447 + } 448 + 449 // not even used lmaooo 450 451 // export async function cachedResolveDIDWEBDOC({
+269
src/routes/moderation.tsx
···
··· 1 + import * as ATPAPI from "@atproto/api"; 2 + import { 3 + isAdultContentPref, 4 + isBskyAppStatePref, 5 + isContentLabelPref, 6 + isFeedViewPref, 7 + isLabelersPref, 8 + isMutedWordsPref, 9 + isSavedFeedsPref, 10 + } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 11 + import { createFileRoute } from "@tanstack/react-router"; 12 + import { useAtom } from "jotai"; 13 + import { Switch } from "radix-ui"; 14 + 15 + import { Header } from "~/components/Header"; 16 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 17 + import { quickAuthAtom } from "~/utils/atoms"; 18 + import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery"; 19 + 20 + import { renderSnack } from "./__root"; 21 + import { NotificationItem } from "./notifications"; 22 + import { SettingHeading } from "./settings"; 23 + 24 + export const Route = createFileRoute("/moderation")({ 25 + component: RouteComponent, 26 + }); 27 + 28 + function RouteComponent() { 29 + const { agent } = useAuth(); 30 + 31 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 32 + const isAuthRestoring = quickAuth ? status === "loading" : false; 33 + 34 + const identityresultmaybe = useQueryIdentity( 35 + !isAuthRestoring ? agent?.did : undefined 36 + ); 37 + const identity = identityresultmaybe?.data; 38 + 39 + const prefsresultmaybe = useQueryPreferences({ 40 + agent: !isAuthRestoring ? (agent ?? undefined) : undefined, 41 + pdsUrl: !isAuthRestoring ? identity?.pds : undefined, 42 + }); 43 + const rawprefs = prefsresultmaybe?.data?.preferences as 44 + | ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"] 45 + | undefined; 46 + 47 + //console.log(JSON.stringify(prefs, null, 2)) 48 + 49 + const parsedPref = parsePreferences(rawprefs); 50 + 51 + return ( 52 + <div> 53 + <Header 54 + title={`Moderation`} 55 + backButtonCallback={() => { 56 + if (window.history.length > 1) { 57 + window.history.back(); 58 + } else { 59 + window.location.assign("/"); 60 + } 61 + }} 62 + bottomBorderDisabled={true} 63 + /> 64 + {/* <SettingHeading title="Moderation Tools" /> 65 + <p> 66 + todo: add all these: 67 + <br /> 68 + - Interaction settings 69 + <br /> 70 + - Muted words & tags 71 + <br /> 72 + - Moderation lists 73 + <br /> 74 + - Muted accounts 75 + <br /> 76 + - Blocked accounts 77 + <br /> 78 + - Verification settings 79 + <br /> 80 + </p> */} 81 + <SettingHeading title="Content Filters" /> 82 + <div> 83 + <div className="flex items-center gap-4 px-4 py-2 border-b"> 84 + <label 85 + htmlFor={`switch-${"hardcoded"}`} 86 + className="flex flex-row flex-1" 87 + > 88 + <div className="flex flex-col"> 89 + <span className="text-md">{"Adult Content"}</span> 90 + <span className="text-sm text-gray-500 dark:text-gray-400"> 91 + {"Enable adult content"} 92 + </span> 93 + </div> 94 + </label> 95 + 96 + <Switch.Root 97 + id={`switch-${"hardcoded"}`} 98 + checked={parsedPref?.adultContentEnabled} 99 + onCheckedChange={(v) => { 100 + renderSnack({ 101 + title: "Sorry... Modifying preferences is not implemented yet", 102 + description: "You can use another app to change preferences", 103 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 104 + }); 105 + }} 106 + className="m3switch root" 107 + > 108 + <Switch.Thumb className="m3switch thumb " /> 109 + </Switch.Root> 110 + </div> 111 + <div className=""> 112 + {Object.entries(parsedPref?.contentLabelPrefs ?? {}).map( 113 + ([label, visibility]) => ( 114 + <div 115 + key={label} 116 + className="flex justify-between border-b py-2 px-4" 117 + > 118 + <label 119 + htmlFor={`switch-${"hardcoded"}`} 120 + className="flex flex-row flex-1" 121 + > 122 + <div className="flex flex-col"> 123 + <span className="text-md">{label}</span> 124 + <span className="text-sm text-gray-500 dark:text-gray-400"> 125 + {"uknown labeler"} 126 + </span> 127 + </div> 128 + </label> 129 + {/* <span className="text-md text-gray-500 dark:text-gray-400"> 130 + {visibility} 131 + </span> */} 132 + <TripleToggle 133 + value={visibility as "ignore" | "warn" | "hide"} 134 + /> 135 + </div> 136 + ) 137 + )} 138 + </div> 139 + </div> 140 + <SettingHeading title="Advanced" /> 141 + {parsedPref?.labelers.map((labeler) => { 142 + return ( 143 + <NotificationItem 144 + key={labeler} 145 + notification={labeler} 146 + labeler={true} 147 + /> 148 + ); 149 + })} 150 + </div> 151 + ); 152 + } 153 + 154 + export function TripleToggle({ 155 + value, 156 + onChange, 157 + }: { 158 + value: "ignore" | "warn" | "hide"; 159 + onChange?: (newValue: "ignore" | "warn" | "hide") => void; 160 + }) { 161 + const options: Array<"ignore" | "warn" | "hide"> = ["ignore", "warn", "hide"]; 162 + return ( 163 + <div className="flex rounded-full bg-gray-200 dark:bg-gray-800 p-1 text-sm"> 164 + {options.map((opt) => { 165 + const isActive = opt === value; 166 + return ( 167 + <button 168 + key={opt} 169 + onClick={() => { 170 + renderSnack({ 171 + title: "Sorry... Modifying preferences is not implemented yet", 172 + description: "You can use another app to change preferences", 173 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 174 + }); 175 + onChange?.(opt); 176 + }} 177 + className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${ 178 + isActive 179 + ? "bg-gray-400 dark:bg-gray-600 text-white" 180 + : "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700" 181 + }`} 182 + > 183 + {" "} 184 + {opt.charAt(0).toUpperCase() + opt.slice(1)} 185 + </button> 186 + ); 187 + })} 188 + </div> 189 + ); 190 + } 191 + 192 + type PrefItem = 193 + ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"][number]; 194 + 195 + export interface NormalizedPreferences { 196 + contentLabelPrefs: Record<string, string>; 197 + mutedWords: string[]; 198 + feedViewPrefs: Record<string, any>; 199 + labelers: string[]; 200 + adultContentEnabled: boolean; 201 + savedFeeds: { 202 + pinned: string[]; 203 + saved: string[]; 204 + }; 205 + nuxs: string[]; 206 + } 207 + 208 + export function parsePreferences( 209 + prefs?: PrefItem[] 210 + ): NormalizedPreferences | undefined { 211 + if (!prefs) return undefined; 212 + const normalized: NormalizedPreferences = { 213 + contentLabelPrefs: {}, 214 + mutedWords: [], 215 + feedViewPrefs: {}, 216 + labelers: [], 217 + adultContentEnabled: false, 218 + savedFeeds: { pinned: [], saved: [] }, 219 + nuxs: [], 220 + }; 221 + 222 + for (const pref of prefs) { 223 + switch (pref.$type) { 224 + case "app.bsky.actor.defs#contentLabelPref": 225 + if (!isContentLabelPref(pref)) break; 226 + normalized.contentLabelPrefs[pref.label] = pref.visibility; 227 + break; 228 + 229 + case "app.bsky.actor.defs#mutedWordsPref": 230 + if (!isMutedWordsPref(pref)) break; 231 + for (const item of pref.items ?? []) { 232 + normalized.mutedWords.push(item.value); 233 + } 234 + break; 235 + 236 + case "app.bsky.actor.defs#feedViewPref": 237 + if (!isFeedViewPref(pref)) break; 238 + normalized.feedViewPrefs[pref.feed] = pref; 239 + break; 240 + 241 + case "app.bsky.actor.defs#labelersPref": 242 + if (!isLabelersPref(pref)) break; 243 + normalized.labelers.push(...(pref.labelers?.map((l) => l.did) ?? [])); 244 + break; 245 + 246 + case "app.bsky.actor.defs#adultContentPref": 247 + if (!isAdultContentPref(pref)) break; 248 + normalized.adultContentEnabled = !!pref.enabled; 249 + break; 250 + 251 + case "app.bsky.actor.defs#savedFeedsPref": 252 + if (!isSavedFeedsPref(pref)) break; 253 + normalized.savedFeeds.pinned.push(...(pref.pinned ?? [])); 254 + normalized.savedFeeds.saved.push(...(pref.saved ?? [])); 255 + break; 256 + 257 + case "app.bsky.actor.defs#bskyAppStatePref": 258 + if (!isBskyAppStatePref(pref)) break; 259 + normalized.nuxs.push(...(pref.nuxs?.map((n) => n.id) ?? [])); 260 + break; 261 + 262 + default: 263 + // unknown pref type โ€” just ignore for now 264 + break; 265 + } 266 + } 267 + 268 + return normalized; 269 + }
+247 -123
src/routes/notifications.tsx
··· 1 import { AtUri } from "@atproto/api"; 2 - import * as TabsPrimitive from "@radix-ui/react-tabs"; 3 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 4 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 5 import { useAtom } from "jotai"; 6 import * as React from "react"; 7 - import { useEffect, useLayoutEffect } from "react"; 8 9 import defaultpfp from "~/../public/favicon.png"; 10 import { Header } from "~/components/Header"; 11 import { 12 MdiCardsHeartOutline, 13 MdiCommentOutline, 14 MdiRepeat, ··· 17 import { useAuth } from "~/providers/UnifiedAuthProvider"; 18 import { 19 constellationURLAtom, 20 imgCDNAtom, 21 - isAtTopAtom, 22 - notificationsScrollAtom, 23 } from "~/utils/atoms"; 24 import { 25 useInfiniteQueryAuthorFeed, ··· 55 }); 56 57 export default function NotificationsTabs() { 58 - const [notifState, setNotifState] = useAtom(notificationsScrollAtom); 59 - const activeTab = notifState.activeTab; 60 - const [isAtTop] = useAtom(isAtTopAtom); 61 - 62 - const handleValueChange = (newTab: string) => { 63 - console.log(newTab); 64 - setNotifState((prev) => { 65 - const wow = { 66 - ...prev, 67 - scrollPositions: { 68 - ...prev.scrollPositions, 69 - [prev.activeTab]: window.scrollY, 70 - }, 71 - activeTab: newTab, 72 - }; 73 - //console.log(wow); 74 - return wow; 75 - }); 76 - }; 77 - 78 - useLayoutEffect(() => { 79 - return () => { 80 - setNotifState((prev) => { 81 - const wow = { 82 - ...prev, 83 - scrollPositions: { 84 - ...prev.scrollPositions, 85 - [activeTab]: window.scrollY, 86 - }, 87 - }; 88 - //console.log(wow); 89 - return wow; 90 - }); 91 - }; 92 - // eslint-disable-next-line react-hooks/exhaustive-deps 93 - }, []); 94 - 95 return ( 96 - <TabsPrimitive.Root 97 - value={activeTab} 98 - onValueChange={handleValueChange} 99 - className={`w-full`} 100 - > 101 - <TabsPrimitive.List 102 - className={`flex sticky top-[52px] bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-[9] border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`} 103 - > 104 - <TabsPrimitive.Trigger 105 - value="mentions" 106 - className="m3tab" 107 - // styling is in app.css 108 - > 109 - Mentions 110 - </TabsPrimitive.Trigger> 111 - <TabsPrimitive.Trigger value="follows" className="m3tab"> 112 - Follows 113 - </TabsPrimitive.Trigger> 114 - <TabsPrimitive.Trigger value="postInteractions" className="m3tab"> 115 - Post Interactions 116 - </TabsPrimitive.Trigger> 117 - </TabsPrimitive.List> 118 - 119 - <TabsPrimitive.Content value="mentions" className="flex-1"> 120 - {activeTab === "mentions" && <MentionsTab />} 121 - </TabsPrimitive.Content> 122 - 123 - <TabsPrimitive.Content value="follows" className="flex-1"> 124 - {activeTab === "follows" && <FollowsTab />} 125 - </TabsPrimitive.Content> 126 - 127 - <TabsPrimitive.Content value="postInteractions" className="flex-1"> 128 - {activeTab === "postInteractions" && <PostInteractionsTab />} 129 - </TabsPrimitive.Content> 130 - </TabsPrimitive.Root> 131 ); 132 } 133 ··· 169 ); 170 }, [infiniteMentionsData]); 171 172 - const [notifState] = useAtom(notificationsScrollAtom); 173 - const activeTab = notifState.activeTab; 174 - useEffect(() => { 175 - const savedY = notifState.scrollPositions[activeTab] ?? 0; 176 - window.scrollTo(0, savedY); 177 - }, [activeTab, notifState.scrollPositions]); 178 179 if (isLoading) return <LoadingState text="Loading mentions..." />; 180 if (isError) return <ErrorState error={error} />; ··· 200 ); 201 } 202 203 - function FollowsTab() { 204 const { agent } = useAuth(); 205 const [constellationurl] = useAtom(constellationURLAtom); 206 const infinitequeryresults = useInfiniteQuery({ 207 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 208 { 209 constellation: constellationurl, 210 method: "/links", 211 - target: agent?.did, 212 collection: "app.bsky.graph.follow", 213 path: ".subject", 214 } 215 ), 216 - enabled: !!agent?.did, 217 }); 218 219 const { ··· 238 ); 239 }, [infiniteFollowsData]); 240 241 - const [notifState] = useAtom(notificationsScrollAtom); 242 - const activeTab = notifState.activeTab; 243 - useEffect(() => { 244 - const savedY = notifState.scrollPositions[activeTab] ?? 0; 245 - window.scrollTo(0, savedY); 246 - }, [activeTab, notifState.scrollPositions]); 247 248 - if (isLoading) return <LoadingState text="Loading mentions..." />; 249 if (isError) return <ErrorState error={error} />; 250 251 - if (!followsAturis?.length) return <EmptyState text="No mentions yet." />; 252 253 return ( 254 <> ··· 298 [postsData] 299 ); 300 301 - const [notifState] = useAtom(notificationsScrollAtom); 302 - const activeTab = notifState.activeTab; 303 - useEffect(() => { 304 - const savedY = notifState.scrollPositions[activeTab] ?? 0; 305 - window.scrollTo(0, savedY); 306 - }, [activeTab, notifState.scrollPositions]); 307 308 return ( 309 <> 310 - {posts.map((m) => ( 311 <PostInteractionsItem key={m.uri} uri={m.uri} /> 312 ))} 313 ··· 324 ); 325 } 326 327 - const ORDER: ("like" | "repost" | "reply" | "quote")[] = [ 328 - "like", 329 - "repost", 330 - "reply", 331 - "quote", 332 - ]; 333 334 function PostInteractionsItem({ uri }: { uri: string }) { 335 const { data: links } = useQueryConstellation({ 336 method: "/links/all", 337 target: uri, ··· 352 353 const all = likes + replies + reposts + quotes; 354 355 return ( 356 <div className="flex flex-col"> 357 <div className="border rounded-xl mx-4 mt-4 overflow-hidden"> 358 <UniversalPostRendererATURILoader 359 isQuote ··· 363 concise={true} 364 /> 365 <div className="flex flex-col divide-x"> 366 - <InteractionsButton 367 - key={likes} 368 type={"like"} 369 uri={uri} 370 count={likes} 371 - /> 372 - <InteractionsButton 373 - key={reposts} 374 type={"repost"} 375 uri={uri} 376 count={reposts} 377 - /> 378 - <InteractionsButton 379 - key={replies} 380 type={"reply"} 381 uri={uri} 382 count={replies} 383 - /> 384 - <InteractionsButton 385 - key={quotes} 386 type={"quote"} 387 uri={uri} 388 count={quotes} 389 - /> 390 {!all && ( 391 <div className="text-center text-gray-500 dark:text-gray-400 pb-3 pt-2 border-t"> 392 No interactions yet. ··· 442 ) : ( 443 <></> 444 )} 445 - {type} 446 - {/* bad grammar replys */} 447 - {count > 1 ? "s" : ""} <div className="flex-1" /> {count} 448 </Link> 449 ); 450 } 451 452 - export function NotificationItem({ notification }: { notification: string }) { 453 const aturi = new AtUri(notification); 454 const navigate = useNavigate(); 455 const { data: identity } = useQueryIdentity(aturi.host); 456 const resolvedDid = identity?.did; ··· 494 <img 495 src={avatar || defaultpfp} 496 alt={identity?.handle} 497 - className="w-10 h-10 rounded-full" 498 /> 499 ) : ( 500 <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
··· 1 import { AtUri } from "@atproto/api"; 2 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 4 import { useAtom } from "jotai"; 5 import * as React from "react"; 6 7 import defaultpfp from "~/../public/favicon.png"; 8 import { Header } from "~/components/Header"; 9 import { 10 + ReusableTabRoute, 11 + useReusableTabScrollRestore, 12 + } from "~/components/ReusableTabRoute"; 13 + import { 14 MdiCardsHeartOutline, 15 MdiCommentOutline, 16 MdiRepeat, ··· 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 import { 21 constellationURLAtom, 22 + enableBitesAtom, 23 imgCDNAtom, 24 + postInteractionsFiltersAtom, 25 } from "~/utils/atoms"; 26 import { 27 useInfiniteQueryAuthorFeed, ··· 57 }); 58 59 export default function NotificationsTabs() { 60 + const [bitesEnabled] = useAtom(enableBitesAtom); 61 return ( 62 + <ReusableTabRoute 63 + route={`Notifications`} 64 + tabs={{ 65 + Mentions: <MentionsTab />, 66 + Follows: <FollowsTab />, 67 + "Post Interactions": <PostInteractionsTab />, 68 + ...bitesEnabled ? { 69 + Bites: <BitesTab />, 70 + } : {} 71 + }} 72 + /> 73 ); 74 } 75 ··· 111 ); 112 }, [infiniteMentionsData]); 113 114 + useReusableTabScrollRestore("Notifications"); 115 116 if (isLoading) return <LoadingState text="Loading mentions..." />; 117 if (isError) return <ErrorState error={error} />; ··· 137 ); 138 } 139 140 + export function FollowsTab({did}:{did?:string}) { 141 const { agent } = useAuth(); 142 + const userdidunsafe = did ?? agent?.did; 143 + const { data: identity} = useQueryIdentity(userdidunsafe); 144 + const userdid = identity?.did; 145 + 146 const [constellationurl] = useAtom(constellationURLAtom); 147 const infinitequeryresults = useInfiniteQuery({ 148 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 149 { 150 constellation: constellationurl, 151 method: "/links", 152 + target: userdid, 153 collection: "app.bsky.graph.follow", 154 path: ".subject", 155 } 156 ), 157 + enabled: !!userdid, 158 }); 159 160 const { ··· 179 ); 180 }, [infiniteFollowsData]); 181 182 + useReusableTabScrollRestore("Notifications"); 183 184 + if (isLoading) return <LoadingState text="Loading follows..." />; 185 if (isError) return <ErrorState error={error} />; 186 187 + if (!followsAturis?.length) return <EmptyState text="No follows yet." />; 188 + 189 + return ( 190 + <> 191 + {followsAturis.map((m) => ( 192 + <NotificationItem key={m} notification={m} /> 193 + ))} 194 + 195 + {hasNextPage && ( 196 + <button 197 + onClick={() => fetchNextPage()} 198 + disabled={isFetchingNextPage} 199 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 200 + > 201 + {isFetchingNextPage ? "Loading..." : "Load More"} 202 + </button> 203 + )} 204 + </> 205 + ); 206 + } 207 + 208 + 209 + export function BitesTab({did}:{did?:string}) { 210 + const { agent } = useAuth(); 211 + const userdidunsafe = did ?? agent?.did; 212 + const { data: identity} = useQueryIdentity(userdidunsafe); 213 + const userdid = identity?.did; 214 + 215 + const [constellationurl] = useAtom(constellationURLAtom); 216 + const infinitequeryresults = useInfiniteQuery({ 217 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 218 + { 219 + constellation: constellationurl, 220 + method: "/links", 221 + target: "at://"+userdid, 222 + collection: "net.wafrn.feed.bite", 223 + path: ".subject", 224 + staleMult: 0 // safe fun 225 + } 226 + ), 227 + enabled: !!userdid, 228 + }); 229 + 230 + const { 231 + data: infiniteFollowsData, 232 + fetchNextPage, 233 + hasNextPage, 234 + isFetchingNextPage, 235 + isLoading, 236 + isError, 237 + error, 238 + } = infinitequeryresults; 239 + 240 + const followsAturis = React.useMemo(() => { 241 + // Get all replies from the standard infinite query 242 + return ( 243 + infiniteFollowsData?.pages.flatMap( 244 + (page) => 245 + page?.linking_records.map( 246 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 247 + ) ?? [] 248 + ) ?? [] 249 + ); 250 + }, [infiniteFollowsData]); 251 + 252 + useReusableTabScrollRestore("Notifications"); 253 + 254 + if (isLoading) return <LoadingState text="Loading bites..." />; 255 + if (isError) return <ErrorState error={error} />; 256 + 257 + if (!followsAturis?.length) return <EmptyState text="No bites yet." />; 258 259 return ( 260 <> ··· 304 [postsData] 305 ); 306 307 + useReusableTabScrollRestore("Notifications"); 308 + 309 + const [filters] = useAtom(postInteractionsFiltersAtom); 310 + const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts); 311 312 return ( 313 <> 314 + <PostInteractionsFilterChipBar /> 315 + {!empty && posts.map((m) => ( 316 <PostInteractionsItem key={m.uri} uri={m.uri} /> 317 ))} 318 ··· 329 ); 330 } 331 332 + function PostInteractionsFilterChipBar() { 333 + const [filters, setFilters] = useAtom(postInteractionsFiltersAtom); 334 + // const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts); 335 + 336 + // useEffect(() => { 337 + // if (empty) { 338 + // setFilters((prev) => ({ 339 + // ...prev, 340 + // likes: true, 341 + // })); 342 + // } 343 + // }, [ 344 + // empty, 345 + // setFilters, 346 + // ]); 347 + 348 + const toggle = (key: keyof typeof filters) => { 349 + setFilters((prev) => ({ 350 + ...prev, 351 + [key]: !prev[key], 352 + })); 353 + }; 354 + 355 + return ( 356 + <div className="flex flex-row flex-wrap gap-2 px-4 pt-4"> 357 + <Chip 358 + state={filters.likes} 359 + text="Likes" 360 + onClick={() => toggle("likes")} 361 + /> 362 + <Chip 363 + state={filters.reposts} 364 + text="Reposts" 365 + onClick={() => toggle("reposts")} 366 + /> 367 + <Chip 368 + state={filters.replies} 369 + text="Replies" 370 + onClick={() => toggle("replies")} 371 + /> 372 + <Chip 373 + state={filters.quotes} 374 + text="Quotes" 375 + onClick={() => toggle("quotes")} 376 + /> 377 + <Chip 378 + state={filters.showAll} 379 + text="Show All Metrics" 380 + onClick={() => toggle("showAll")} 381 + /> 382 + </div> 383 + ); 384 + } 385 + 386 + export function Chip({ 387 + state, 388 + text, 389 + onClick, 390 + }: { 391 + state: boolean; 392 + text: string; 393 + onClick: React.MouseEventHandler<HTMLButtonElement>; 394 + }) { 395 + return ( 396 + <button 397 + onClick={onClick} 398 + className={`relative inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-all 399 + ${ 400 + state 401 + ? "bg-primary/20 text-primary bg-gray-200 dark:bg-gray-800 border border-transparent" 402 + : "bg-surface-container-low text-on-surface-variant border border-outline" 403 + } 404 + hover:bg-primary/30 active:scale-[0.97] 405 + dark:border-outline-variant 406 + `} 407 + > 408 + {state && ( 409 + <IconMdiCheck 410 + className="mr-1.5 inline-block w-4 h-4 rounded-full bg-primary" 411 + aria-hidden 412 + /> 413 + )} 414 + {text} 415 + </button> 416 + ); 417 + } 418 419 function PostInteractionsItem({ uri }: { uri: string }) { 420 + const [filters] = useAtom(postInteractionsFiltersAtom); 421 const { data: links } = useQueryConstellation({ 422 method: "/links/all", 423 target: uri, ··· 438 439 const all = likes + replies + reposts + quotes; 440 441 + //const failLikes = filters.likes && likes < 1; 442 + //const failReposts = filters.reposts && reposts < 1; 443 + //const failReplies = filters.replies && replies < 1; 444 + //const failQuotes = filters.quotes && quotes < 1; 445 + 446 + const showLikes = filters.showAll || filters.likes 447 + const showReposts = filters.showAll || filters.reposts 448 + const showReplies = filters.showAll || filters.replies 449 + const showQuotes = filters.showAll || filters.quotes 450 + 451 + //const showNone = !showLikes && !showReposts && !showReplies && !showQuotes; 452 + 453 + //const fail = failLikes || failReposts || failReplies || failQuotes || showNone; 454 + 455 + const matchesLikes = filters.likes && likes > 0; 456 + const matchesReposts = filters.reposts && reposts > 0; 457 + const matchesReplies = filters.replies && replies > 0; 458 + const matchesQuotes = filters.quotes && quotes > 0; 459 + 460 + const matchesAnything = 461 + // filters.showAll || 462 + matchesLikes || 463 + matchesReposts || 464 + matchesReplies || 465 + matchesQuotes; 466 + 467 + if (!matchesAnything) return null; 468 + 469 + //if (fail) return; 470 + 471 return ( 472 <div className="flex flex-col"> 473 + {/* <span>fail likes {failLikes ? "true" : "false"}</span> 474 + <span>fail repost {failReposts ? "true" : "false"}</span> 475 + <span>fail reply {failReplies ? "true" : "false"}</span> 476 + <span>fail qupte {failQuotes ? "true" : "false"}</span> */} 477 <div className="border rounded-xl mx-4 mt-4 overflow-hidden"> 478 <UniversalPostRendererATURILoader 479 isQuote ··· 483 concise={true} 484 /> 485 <div className="flex flex-col divide-x"> 486 + {showLikes &&(<InteractionsButton 487 type={"like"} 488 uri={uri} 489 count={likes} 490 + />)} 491 + {showReposts && (<InteractionsButton 492 type={"repost"} 493 uri={uri} 494 count={reposts} 495 + />)} 496 + {showReplies && (<InteractionsButton 497 type={"reply"} 498 uri={uri} 499 count={replies} 500 + />)} 501 + {showQuotes && (<InteractionsButton 502 type={"quote"} 503 uri={uri} 504 count={quotes} 505 + />)} 506 {!all && ( 507 <div className="text-center text-gray-500 dark:text-gray-400 pb-3 pt-2 border-t"> 508 No interactions yet. ··· 558 ) : ( 559 <></> 560 )} 561 + {type === "like" 562 + ? "likes" 563 + : type === "reply" 564 + ? "replies" 565 + : type === "quote" 566 + ? "quotes" 567 + : type === "repost" 568 + ? "reposts" 569 + : ""} 570 + <div className="flex-1" /> {count} 571 </Link> 572 ); 573 } 574 575 + export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) { 576 const aturi = new AtUri(notification); 577 + const bite = aturi.collection === "net.wafrn.feed.bite"; 578 const navigate = useNavigate(); 579 const { data: identity } = useQueryIdentity(aturi.host); 580 const resolvedDid = identity?.did; ··· 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" />
+91
src/routes/profile.$did/feed.$rkey.tsx
···
··· 1 + import * as ATPAPI from "@atproto/api"; 2 + import { AtUri } from "@atproto/api"; 3 + import { createFileRoute } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 5 + 6 + import { Header } from "~/components/Header"; 7 + import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed"; 8 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 + import { quickAuthAtom } from "~/utils/atoms"; 10 + import { useQueryArbitrary, useQueryIdentity } from "~/utils/useQuery"; 11 + 12 + export const Route = createFileRoute("/profile/$did/feed/$rkey")({ 13 + component: FeedRoute, 14 + }); 15 + 16 + // todo: scroll restoration 17 + function FeedRoute() { 18 + const { did, rkey } = Route.useParams(); 19 + const { agent, status } = useAuth(); 20 + const { data: identitydata } = useQueryIdentity(did); 21 + const { data: identity } = useQueryIdentity(agent?.did); 22 + const uri = `at://${identitydata?.did || did}/app.bsky.feed.generator/${rkey}`; 23 + const aturi = new AtUri(uri); 24 + const { data: feeddata } = useQueryArbitrary(uri); 25 + 26 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 27 + const isAuthRestoring = quickAuth ? status === "loading" : false; 28 + 29 + const authed = status === "signedIn"; 30 + 31 + const feedServiceDid = !isAuthRestoring 32 + ? ((feeddata?.value as any)?.did as string | undefined) 33 + : undefined; 34 + 35 + // const { 36 + // data: feedData, 37 + // isLoading: isFeedLoading, 38 + // error: feedError, 39 + // } = useQueryFeedSkeleton({ 40 + // feedUri: selectedFeed!, 41 + // agent: agent ?? undefined, 42 + // isAuthed: authed ?? false, 43 + // pdsUrl: identity?.pds, 44 + // feedServiceDid: feedServiceDid, 45 + // }); 46 + 47 + // const feed = feedData?.feed || []; 48 + 49 + const isReadyForAuthedFeed = 50 + !isAuthRestoring && authed && agent && identity?.pds && feedServiceDid; 51 + const isReadyForUnauthedFeed = !isAuthRestoring && !authed; 52 + 53 + const feed: ATPAPI.AppBskyFeedGenerator.Record | undefined = feeddata?.value; 54 + 55 + const web = feedServiceDid?.replace(/^did:web:/, "") || ""; 56 + 57 + return ( 58 + <> 59 + <Header 60 + title={feed?.displayName || aturi.rkey} 61 + backButtonCallback={() => { 62 + if (window.history.length > 1) { 63 + window.history.back(); 64 + } else { 65 + window.location.assign("/"); 66 + } 67 + }} 68 + /> 69 + 70 + {isAuthRestoring || 71 + (authed && (!identity?.pds || !feedServiceDid) && ( 72 + <div className="p-4 text-center text-gray-500"> 73 + Preparing your feed... 74 + </div> 75 + ))} 76 + 77 + {!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? ( 78 + <InfiniteCustomFeed 79 + key={uri} 80 + feedUri={uri} 81 + pdsUrl={identity?.pds} 82 + feedServiceDid={feedServiceDid} 83 + authedOverride={!authed && true || undefined} 84 + unauthedfeedurl={!authed && web || undefined} 85 + /> 86 + ) : ( 87 + <div className="p-4 text-center text-gray-500">Loading.......</div> 88 + )} 89 + </> 90 + ); 91 + }
+30
src/routes/profile.$did/followers.tsx
···
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + 3 + import { Header } from "~/components/Header"; 4 + 5 + import { FollowsTab } from "../notifications"; 6 + 7 + export const Route = createFileRoute("/profile/$did/followers")({ 8 + component: RouteComponent, 9 + }); 10 + 11 + // todo: scroll restoration 12 + function RouteComponent() { 13 + const params = Route.useParams(); 14 + 15 + return ( 16 + <div> 17 + <Header 18 + title={"Followers"} 19 + backButtonCallback={() => { 20 + if (window.history.length > 1) { 21 + window.history.back(); 22 + } else { 23 + window.location.assign("/"); 24 + } 25 + }} 26 + /> 27 + <FollowsTab did={params.did} /> 28 + </div> 29 + ); 30 + }
+79
src/routes/profile.$did/follows.tsx
···
··· 1 + import * as ATPAPI from "@atproto/api" 2 + import { createFileRoute } from '@tanstack/react-router' 3 + import React from 'react'; 4 + 5 + import { Header } from '~/components/Header'; 6 + import { useReusableTabScrollRestore } from '~/components/ReusableTabRoute'; 7 + import { useInfiniteQueryAuthorFeed, useQueryIdentity } from '~/utils/useQuery'; 8 + 9 + import { EmptyState, ErrorState, LoadingState, NotificationItem } from '../notifications'; 10 + 11 + export const Route = createFileRoute('/profile/$did/follows')({ 12 + component: RouteComponent, 13 + }) 14 + 15 + // todo: scroll restoration 16 + function RouteComponent() { 17 + const params = Route.useParams(); 18 + return ( 19 + <div> 20 + <Header 21 + title={"Follows"} 22 + backButtonCallback={() => { 23 + if (window.history.length > 1) { 24 + window.history.back(); 25 + } else { 26 + window.location.assign("/"); 27 + } 28 + }} 29 + /> 30 + <Follows did={params.did}/> 31 + </div> 32 + ); 33 + } 34 + 35 + function Follows({did}:{did:string}) { 36 + const {data: identity} = useQueryIdentity(did); 37 + const infinitequeryresults = useInfiniteQueryAuthorFeed(identity?.did, identity?.pds, "app.bsky.graph.follow"); 38 + 39 + const { 40 + data: infiniteFollowsData, 41 + fetchNextPage, 42 + hasNextPage, 43 + isFetchingNextPage, 44 + isLoading, 45 + isError, 46 + error, 47 + } = infinitequeryresults; 48 + 49 + const followsAturis = React.useMemo( 50 + () => infiniteFollowsData?.pages.flatMap((page) => page.records) ?? [], 51 + [infiniteFollowsData] 52 + ); 53 + 54 + useReusableTabScrollRestore("Notifications"); 55 + 56 + if (isLoading) return <LoadingState text="Loading follows..." />; 57 + if (isError) return <ErrorState error={error} />; 58 + 59 + if (!followsAturis?.length) return <EmptyState text="No follows yet." />; 60 + 61 + return ( 62 + <> 63 + {followsAturis.map((m) => { 64 + const record = m.value as unknown as ATPAPI.AppBskyGraphFollow.Record; 65 + return <NotificationItem key={record.subject} notification={record.subject} /> 66 + })} 67 + 68 + {hasNextPage && ( 69 + <button 70 + onClick={() => fetchNextPage()} 71 + disabled={isFetchingNextPage} 72 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 73 + > 74 + {isFetchingNextPage ? "Loading..." : "Load More"} 75 + </button> 76 + )} 77 + </> 78 + ); 79 + }
+829 -80
src/routes/profile.$did/index.tsx
··· 1 - import { RichText } from "@atproto/api"; 2 import { useQueryClient } from "@tanstack/react-query"; 3 - import { createFileRoute, useNavigate } from "@tanstack/react-router"; 4 import { useAtom } from "jotai"; 5 import React, { type ReactNode, useEffect, useState } from "react"; 6 7 import { Header } from "~/components/Header"; 8 import { 9 renderTextWithFacets, 10 UniversalPostRendererATURILoader, 11 } from "~/components/UniversalPostRenderer"; 12 import { useAuth } from "~/providers/UnifiedAuthProvider"; 13 - import { imgCDNAtom } from "~/utils/atoms"; 14 import { 15 toggleFollow, 16 useGetFollowState, 17 useGetOneToOneState, 18 } from "~/utils/followState"; 19 import { 20 useInfiniteQueryAuthorFeed, 21 useQueryIdentity, 22 useQueryProfile, 23 } from "~/utils/useQuery"; 24 25 export const Route = createFileRoute("/profile/$did/")({ 26 component: ProfileComponent, ··· 29 function ProfileComponent() { 30 // booo bad this is not always the did it might be a handle, use identity.did instead 31 const { did } = Route.useParams(); 32 const navigate = useNavigate(); 33 const queryClient = useQueryClient(); 34 const { ··· 37 error: identityError, 38 } = useQueryIdentity(did); 39 40 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 41 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; 42 const pdsUrl = identity?.pds; ··· 47 const { data: profileRecord } = useQueryProfile(profileUri); 48 const profile = profileRecord?.value; 49 50 - const { 51 - data: postsData, 52 - fetchNextPage, 53 - hasNextPage, 54 - isFetchingNextPage, 55 - isLoading: arePostsLoading, 56 - } = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl); 57 - 58 - React.useEffect(() => { 59 - if (postsData) { 60 - postsData.pages.forEach((page) => { 61 - page.records.forEach((record) => { 62 - if (!queryClient.getQueryData(["post", record.uri])) { 63 - queryClient.setQueryData(["post", record.uri], record); 64 - } 65 - }); 66 - }); 67 - } 68 - }, [postsData, queryClient]); 69 - 70 - const posts = React.useMemo( 71 - () => postsData?.pages.flatMap((page) => page.records) ?? [], 72 - [postsData] 73 - ); 74 - 75 const [imgcdn] = useAtom(imgCDNAtom); 76 77 function getAvatarUrl(p: typeof profile) { ··· 90 const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did; 91 const description = profile?.description || ""; 92 93 - if (isIdentityLoading) { 94 - return ( 95 - <div className="p-4 text-center text-gray-500">Resolving profile...</div> 96 - ); 97 - } 98 99 - if (identityError) { 100 - return ( 101 - <div className="p-4 text-center text-red-500"> 102 - Error: {identityError.message} 103 - </div> 104 - ); 105 - } 106 107 - if (!resolvedDid) { 108 - return ( 109 - <div className="p-4 text-center text-gray-500">Profile not found.</div> 110 - ); 111 - } 112 113 return ( 114 - <> 115 <Header 116 title={`Profile`} 117 backButtonCallback={() => { ··· 121 window.location.assign("/"); 122 } 123 }} 124 /> 125 {/* <div className="flex gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700"> 126 <Link ··· 155 156 {/* Avatar (PFP) */} 157 <div className="absolute left-[16px] top-[100px] "> 158 - <img 159 - src={getAvatarUrl(profile) || "/favicon.png"} 160 - alt="avatar" 161 - className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700" 162 - /> 163 </div> 164 165 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> 166 {/* 167 todo: full follow and unfollow backfill (along with partial likes backfill, 168 just enough for it to be useful) ··· 170 also save it persistently 171 */} 172 <FollowButton targetdidorhandle={did} /> 173 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 174 ... {/* todo: icon */} 175 </button> 176 </div> ··· 182 <Mutual targetdidorhandle={did} /> 183 {handle} 184 </div> 185 {description && ( 186 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 187 {/* {description} */} ··· 191 </div> 192 </div> 193 194 - {/* Posts Section */} 195 - <div className="max-w-2xl mx-auto"> 196 - <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 197 - Posts 198 </div> 199 - <div> 200 - {posts.map((post) => ( 201 <UniversalPostRendererATURILoader 202 - key={post.uri} 203 - atUri={post.uri} 204 feedviewpost={true} 205 /> 206 - ))} 207 - </div> 208 209 - {/* Loading and "Load More" states */} 210 - {arePostsLoading && posts.length === 0 && ( 211 - <div className="p-4 text-center text-gray-500">Loading posts...</div> 212 - )} 213 - {isFetchingNextPage && ( 214 - <div className="p-4 text-center text-gray-500">Loading more...</div> 215 - )} 216 - {hasNextPage && !isFetchingNextPage && ( 217 - <button 218 - onClick={() => fetchNextPage()} 219 - 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" 220 > 221 - Load More Posts 222 - </button> 223 - )} 224 - {posts.length === 0 && !arePostsLoading && ( 225 - <div className="p-4 text-center text-gray-500">No posts found.</div> 226 - )} 227 </div> 228 </> 229 ); 230 } ··· 280 )} 281 </> 282 ) : ( 283 - <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]"> 284 Edit Profile 285 </button> 286 )} 287 </> 288 ); 289 } 290 291 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
··· 1 + import { Agent, RichText } from "@atproto/api"; 2 + import * as ATPAPI from "@atproto/api"; 3 + import { TID } from "@atproto/common-web"; 4 import { useQueryClient } from "@tanstack/react-query"; 5 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 6 import { useAtom } from "jotai"; 7 import React, { type ReactNode, useEffect, useState } from "react"; 8 9 + import defaultpfp from "~/../public/favicon.png"; 10 import { Header } from "~/components/Header"; 11 import { 12 + ReusableTabRoute, 13 + useReusableTabScrollRestore, 14 + } from "~/components/ReusableTabRoute"; 15 + import { 16 renderTextWithFacets, 17 UniversalPostRendererATURILoader, 18 } from "~/components/UniversalPostRenderer"; 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 + import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 21 import { 22 toggleFollow, 23 useGetFollowState, 24 useGetOneToOneState, 25 } from "~/utils/followState"; 26 + import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue"; 27 import { 28 useInfiniteQueryAuthorFeed, 29 + useQueryArbitrary, 30 + useQueryConstellation, 31 + useQueryConstellationLinksCountDistinctDids, 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/")({ 41 component: ProfileComponent, ··· 44 function ProfileComponent() { 45 // booo bad this is not always the did it might be a handle, use identity.did instead 46 const { did } = Route.useParams(); 47 + const { agent } = useAuth(); 48 const navigate = useNavigate(); 49 const queryClient = useQueryClient(); 50 const { ··· 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; ··· 77 const { data: profileRecord } = useQueryProfile(profileUri); 78 const profile = profileRecord?.value; 79 80 const [imgcdn] = useAtom(imgCDNAtom); 81 82 function getAvatarUrl(p: typeof profile) { ··· 95 const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did; 96 const description = profile?.description || ""; 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 113 return ( 114 + <div className=""> 115 <Header 116 title={`Profile`} 117 backButtonCallback={() => { ··· 121 window.location.assign("/"); 122 } 123 }} 124 + bottomBorderDisabled={true} 125 /> 126 {/* <div className="flex gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700"> 127 <Link ··· 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"> 175 + <BiteButton targetdidorhandle={did} /> 176 {/* 177 todo: full follow and unfollow backfill (along with partial likes backfill, 178 just enough for it to be useful) ··· 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> ··· 201 <Mutual targetdidorhandle={did} /> 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]"> 220 {/* {description} */} ··· 224 </div> 225 </div> 226 227 + {/* this should not be rendered until its ready (the top profile layout is stable) */} 228 + {isReady ? ( 229 + <ReusableTabRoute 230 + route={`Profile` + did} 231 + tabs={{ 232 + ...(isLabeler 233 + ? { 234 + Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />, 235 + } 236 + : {}), 237 + ...{ 238 + Posts: <PostsTab did={did} />, 239 + Reposts: <RepostsTab did={did} />, 240 + Feeds: <FeedsTab did={did} />, 241 + Lists: <ListsTab did={did} />, 242 + }, 243 + ...(identity?.did === agent?.did 244 + ? { Likes: <SelfLikesTab did={did} /> } 245 + : {}), 246 + }} 247 + /> 248 + ) : isIdentityLoading ? ( 249 + <div className="p-4 text-center text-gray-500"> 250 + Resolving profile... 251 </div> 252 + ) : identityError ? ( 253 + <div className="p-4 text-center text-red-500"> 254 + Error: {identityError.message} 255 + </div> 256 + ) : !resolvedDid ? ( 257 + <div className="p-4 text-center text-gray-500">Profile not found.</div> 258 + ) : ( 259 + <div className="p-4 text-center text-gray-500"> 260 + Loading profile content... 261 + </div> 262 + )} 263 + </div> 264 + ); 265 + } 266 + 267 + export type ProfilePostsFilter = { 268 + posts: boolean; 269 + replies: boolean; 270 + mediaOnly: boolean; 271 + }; 272 + export const defaultProfilePostsFilter: ProfilePostsFilter = { 273 + posts: true, 274 + replies: true, 275 + mediaOnly: false, 276 + }; 277 + 278 + function ProfilePostsFilterChipBar({ 279 + filters, 280 + toggle, 281 + }: { 282 + filters: ProfilePostsFilter | null; 283 + toggle: (key: keyof ProfilePostsFilter) => void; 284 + }) { 285 + const empty = !filters?.replies && !filters?.posts; 286 + const almostEmpty = !filters?.replies && filters?.posts; 287 + 288 + useEffect(() => { 289 + if (empty) { 290 + toggle("posts"); 291 + } 292 + }, [empty, toggle]); 293 + 294 + return ( 295 + <div className="flex flex-row flex-wrap gap-2 px-4 pt-4"> 296 + <Chip 297 + state={filters?.posts ?? true} 298 + text="Posts" 299 + onClick={() => (almostEmpty ? null : toggle("posts"))} 300 + /> 301 + <Chip 302 + state={filters?.replies ?? true} 303 + text="Replies" 304 + onClick={() => toggle("replies")} 305 + /> 306 + <Chip 307 + state={filters?.mediaOnly ?? false} 308 + text="Media Only" 309 + onClick={() => toggle("mediaOnly")} 310 + /> 311 + </div> 312 + ); 313 + } 314 + 315 + function PostsTab({ did }: { did: string }) { 316 + // todo: this needs to be a (non-persisted is fine) atom to survive navigation 317 + const [filterses, setFilterses] = useAtom(profileChipsAtom); 318 + const filters = filterses?.[did]; 319 + const setFilters = (obj: ProfilePostsFilter) => { 320 + setFilterses((prev) => { 321 + return { 322 + ...prev, 323 + [did]: obj, 324 + }; 325 + }); 326 + }; 327 + useEffect(() => { 328 + if (!filters) { 329 + setFilters(defaultProfilePostsFilter); 330 + } 331 + }); 332 + useReusableTabScrollRestore(`Profile` + did); 333 + const queryClient = useQueryClient(); 334 + const { 335 + data: identity, 336 + isLoading: isIdentityLoading, 337 + error: identityError, 338 + } = useQueryIdentity(did); 339 + 340 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 341 + 342 + const { 343 + data: postsData, 344 + fetchNextPage, 345 + hasNextPage, 346 + isFetchingNextPage, 347 + isLoading: arePostsLoading, 348 + } = useInfiniteQueryAuthorFeed(resolvedDid, identity?.pds); 349 + 350 + React.useEffect(() => { 351 + if (postsData) { 352 + postsData.pages.forEach((page) => { 353 + page.records.forEach((record) => { 354 + if (!queryClient.getQueryData(["post", record.uri])) { 355 + queryClient.setQueryData(["post", record.uri], record); 356 + } 357 + }); 358 + }); 359 + } 360 + }, [postsData, queryClient]); 361 + 362 + const posts = React.useMemo( 363 + () => postsData?.pages.flatMap((page) => page.records) ?? [], 364 + [postsData] 365 + ); 366 + 367 + const toggle = (key: keyof ProfilePostsFilter) => { 368 + setFilterses((prev) => { 369 + const existing = prev[did] ?? { 370 + posts: false, 371 + replies: false, 372 + mediaOnly: false, 373 + }; // default 374 + 375 + return { 376 + ...prev, 377 + [did]: { 378 + ...existing, 379 + [key]: !existing[key], // safely negate 380 + }, 381 + }; 382 + }); 383 + }; 384 + 385 + return ( 386 + <> 387 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 388 + Posts 389 + </div> */} 390 + <ProfilePostsFilterChipBar filters={filters} toggle={toggle} /> 391 + <div> 392 + {posts.map((post) => ( 393 + <UniversalPostRendererATURILoader 394 + key={post.uri} 395 + atUri={post.uri} 396 + feedviewpost={true} 397 + filterNoReplies={!filters?.replies} 398 + filterMustHaveMedia={filters?.mediaOnly} 399 + filterMustBeReply={!filters?.posts} 400 + /> 401 + ))} 402 + </div> 403 + 404 + {/* Loading and "Load More" states */} 405 + {arePostsLoading && posts.length === 0 && ( 406 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 407 + )} 408 + {isFetchingNextPage && ( 409 + <div className="p-4 text-center text-gray-500">Loading more...</div> 410 + )} 411 + {hasNextPage && !isFetchingNextPage && ( 412 + <button 413 + onClick={() => fetchNextPage()} 414 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 415 + > 416 + Load More Posts 417 + </button> 418 + )} 419 + {posts.length === 0 && !arePostsLoading && ( 420 + <div className="p-4 text-center text-gray-500">No posts found.</div> 421 + )} 422 + </> 423 + ); 424 + } 425 + 426 + function RepostsTab({ did }: { did: string }) { 427 + useReusableTabScrollRestore(`Profile` + did); 428 + const { 429 + data: identity, 430 + isLoading: isIdentityLoading, 431 + error: identityError, 432 + } = useQueryIdentity(did); 433 + 434 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 435 + 436 + const { 437 + data: repostsData, 438 + fetchNextPage, 439 + hasNextPage, 440 + isFetchingNextPage, 441 + isLoading: arePostsLoading, 442 + } = useInfiniteQueryAuthorFeed( 443 + resolvedDid, 444 + identity?.pds, 445 + "app.bsky.feed.repost" 446 + ); 447 + 448 + const reposts = React.useMemo( 449 + () => repostsData?.pages.flatMap((page) => page.records) ?? [], 450 + [repostsData] 451 + ); 452 + 453 + return ( 454 + <> 455 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 456 + Reposts 457 + </div> 458 + <div> 459 + {reposts.map((repost) => { 460 + if ( 461 + !repost || 462 + !repost?.value || 463 + !repost?.value?.subject || 464 + // @ts-expect-error blehhhhh 465 + !repost?.value?.subject?.uri 466 + ) 467 + return; 468 + const repostRecord = 469 + repost.value as unknown as ATPAPI.AppBskyFeedRepost.Record; 470 + return ( 471 <UniversalPostRendererATURILoader 472 + key={repostRecord.subject.uri} 473 + atUri={repostRecord.subject.uri} 474 feedviewpost={true} 475 + repostedby={repost.uri} 476 /> 477 + ); 478 + })} 479 + </div> 480 + 481 + {/* Loading and "Load More" states */} 482 + {arePostsLoading && reposts.length === 0 && ( 483 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 484 + )} 485 + {isFetchingNextPage && ( 486 + <div className="p-4 text-center text-gray-500">Loading more...</div> 487 + )} 488 + {hasNextPage && !isFetchingNextPage && ( 489 + <button 490 + onClick={() => fetchNextPage()} 491 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 492 + > 493 + Load More Posts 494 + </button> 495 + )} 496 + {reposts.length === 0 && !arePostsLoading && ( 497 + <div className="p-4 text-center text-gray-500">No posts found.</div> 498 + )} 499 + </> 500 + ); 501 + } 502 + 503 + function FeedsTab({ did }: { did: string }) { 504 + useReusableTabScrollRestore(`Profile` + did); 505 + const { 506 + data: identity, 507 + isLoading: isIdentityLoading, 508 + error: identityError, 509 + } = useQueryIdentity(did); 510 + 511 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 512 + 513 + const { 514 + data: feedsData, 515 + fetchNextPage, 516 + hasNextPage, 517 + isFetchingNextPage, 518 + isLoading: arePostsLoading, 519 + } = useInfiniteQueryAuthorFeed( 520 + resolvedDid, 521 + identity?.pds, 522 + "app.bsky.feed.generator" 523 + ); 524 + 525 + const feeds = React.useMemo( 526 + () => feedsData?.pages.flatMap((page) => page.records) ?? [], 527 + [feedsData] 528 + ); 529 + 530 + return ( 531 + <> 532 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 533 + Feeds 534 + </div> 535 + <div> 536 + {feeds.map((feed) => { 537 + if (!feed || !feed?.value) return; 538 + const feedGenRecord = 539 + feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record; 540 + return <FeedItemRender feed={feed as any} key={feed.uri} />; 541 + })} 542 + </div> 543 + 544 + {/* Loading and "Load More" states */} 545 + {arePostsLoading && feeds.length === 0 && ( 546 + <div className="p-4 text-center text-gray-500">Loading feeds...</div> 547 + )} 548 + {isFetchingNextPage && ( 549 + <div className="p-4 text-center text-gray-500">Loading more...</div> 550 + )} 551 + {hasNextPage && !isFetchingNextPage && ( 552 + <button 553 + onClick={() => fetchNextPage()} 554 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 555 + > 556 + Load More Feeds 557 + </button> 558 + )} 559 + {feeds.length === 0 && !arePostsLoading && ( 560 + <div className="p-4 text-center text-gray-500">No feeds found.</div> 561 + )} 562 + </> 563 + ); 564 + } 565 + 566 + function LabelsTab({ 567 + did, 568 + labelerRecord, 569 + }: { 570 + did: string; 571 + labelerRecord?: ATPAPI.AppBskyLabelerService.Record; 572 + }) { 573 + useReusableTabScrollRestore(`Profile` + did); 574 + const { agent } = useAuth(); 575 + // const { 576 + // data: identity, 577 + // isLoading: isIdentityLoading, 578 + // error: identityError, 579 + // } = useQueryIdentity(did); 580 581 + // const resolvedDid = did.startsWith("did:") ? did : identity?.did; 582 + 583 + const labelMap = new Map( 584 + labelerRecord?.policies?.labelValueDefinitions?.map((def) => { 585 + const locale = def.locales.find((l) => l.lang === "en") ?? def.locales[0]; 586 + return [ 587 + def.identifier, 588 + { 589 + name: locale?.name, 590 + description: locale?.description, 591 + blur: def.blurs, 592 + severity: def.severity, 593 + adultOnly: def.adultOnly, 594 + defaultSetting: def.defaultSetting, 595 + }, 596 + ]; 597 + }) 598 + ); 599 + 600 + return ( 601 + <> 602 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 603 + Labels 604 + </div> 605 + <div> 606 + {[...labelMap.entries()].map(([key, item]) => ( 607 + <div 608 + key={key} 609 + className="border-gray-300 dark:border-gray-700 border-b px-4 py-4" 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, 649 + disableBottomBorder, 650 + disablePropagation, 651 + }: { 652 + aturi: string; 653 + listmode?: boolean; 654 + disableBottomBorder?: boolean; 655 + disablePropagation?: boolean; 656 + }) { 657 + const { data: record } = useQueryArbitrary(aturi); 658 + 659 + if (!record) return; 660 + return ( 661 + <FeedItemRender 662 + listmode={listmode} 663 + feed={record} 664 + disableBottomBorder={disableBottomBorder} 665 + disablePropagation={disablePropagation} 666 + /> 667 + ); 668 + } 669 + 670 + export function FeedItemRender({ 671 + feed, 672 + listmode, 673 + disableBottomBorder, 674 + disablePropagation, 675 + }: { 676 + feed: { uri: string; cid: string; value: any }; 677 + listmode?: boolean; 678 + disableBottomBorder?: boolean; 679 + disablePropagation?: boolean; 680 + }) { 681 + const name = listmode 682 + ? (feed.value?.name as string) 683 + : (feed.value?.displayName as string); 684 + const aturi = new ATPAPI.AtUri(feed.uri); 685 + const { data: identity } = useQueryIdentity(aturi.host); 686 + const resolvedDid = identity?.did; 687 + const [imgcdn] = useAtom(imgCDNAtom); 688 + 689 + function getAvatarThumbnailUrl(f: typeof feed) { 690 + const link = f?.value.avatar?.ref?.["$link"]; 691 + if (!link || !resolvedDid) return null; 692 + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 693 + } 694 + 695 + const { data: likes } = useQueryConstellation( 696 + // @ts-expect-error overloads sucks 697 + !listmode 698 + ? { 699 + target: feed.uri, 700 + method: "/links/count", 701 + collection: "app.bsky.feed.like", 702 + path: ".subject.uri", 703 + } 704 + : undefined 705 + ); 706 + 707 + return ( 708 + <Link 709 + className={`px-4 py-4 ${!disableBottomBorder && "border-b"} flex flex-col gap-1`} 710 + to="/profile/$did/feed/$rkey" 711 + params={{ did: aturi.host, rkey: aturi.rkey }} 712 + onClick={(e) => { 713 + e.stopPropagation(); 714 + }} 715 + > 716 + <div className="flex flex-row gap-3"> 717 + <div className="min-w-10 min-h-10"> 718 + <img 719 + src={getAvatarThumbnailUrl(feed) || defaultpfp} 720 + className="h-10 w-10 rounded border" 721 + /> 722 + </div> 723 + <div className="flex flex-col"> 724 + <span className="">{name}</span> 725 + <span className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center"> 726 + {feed.value.did || aturi.rkey} 727 + </span> 728 + </div> 729 + <div className="flex-1" /> 730 + {/* <div className="button bg-red-500 rounded-full min-w-[60px]" /> */} 731 + </div> 732 + <span className=" text-sm">{feed.value?.description}</span> 733 + {!listmode && ( 734 + <span className=" text-sm dark:text-gray-400 text-gray-500"> 735 + Liked by {((likes as unknown as any)?.total as number) || 0} users 736 + </span> 737 + )} 738 + </Link> 739 + ); 740 + } 741 + 742 + function ListsTab({ did }: { did: string }) { 743 + useReusableTabScrollRestore(`Profile` + did); 744 + const { 745 + data: identity, 746 + isLoading: isIdentityLoading, 747 + error: identityError, 748 + } = useQueryIdentity(did); 749 + 750 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 751 + 752 + const { 753 + data: feedsData, 754 + fetchNextPage, 755 + hasNextPage, 756 + isFetchingNextPage, 757 + isLoading: arePostsLoading, 758 + } = useInfiniteQueryAuthorFeed( 759 + resolvedDid, 760 + identity?.pds, 761 + "app.bsky.graph.list" 762 + ); 763 + 764 + const feeds = React.useMemo( 765 + () => feedsData?.pages.flatMap((page) => page.records) ?? [], 766 + [feedsData] 767 + ); 768 + 769 + return ( 770 + <> 771 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 772 + Feeds 773 + </div> 774 + <div> 775 + {feeds.map((feed) => { 776 + if (!feed || !feed?.value) return; 777 + const feedGenRecord = 778 + feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record; 779 + return ( 780 + <FeedItemRender listmode={true} feed={feed as any} key={feed.uri} /> 781 + ); 782 + })} 783 + </div> 784 + 785 + {/* Loading and "Load More" states */} 786 + {arePostsLoading && feeds.length === 0 && ( 787 + <div className="p-4 text-center text-gray-500">Loading lists...</div> 788 + )} 789 + {isFetchingNextPage && ( 790 + <div className="p-4 text-center text-gray-500">Loading more...</div> 791 + )} 792 + {hasNextPage && !isFetchingNextPage && ( 793 + <button 794 + onClick={() => fetchNextPage()} 795 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 796 + > 797 + Load More Lists 798 + </button> 799 + )} 800 + {feeds.length === 0 && !arePostsLoading && ( 801 + <div className="p-4 text-center text-gray-500">No lists found.</div> 802 + )} 803 + </> 804 + ); 805 + } 806 + 807 + function SelfLikesTab({ did }: { did: string }) { 808 + useReusableTabScrollRestore(`Profile` + did); 809 + const { 810 + data: identity, 811 + isLoading: isIdentityLoading, 812 + error: identityError, 813 + } = useQueryIdentity(did); 814 + 815 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 816 + 817 + const { 818 + data: likesData, 819 + fetchNextPage, 820 + hasNextPage, 821 + isFetchingNextPage, 822 + isLoading: arePostsLoading, 823 + } = useInfiniteQueryAuthorFeed( 824 + resolvedDid, 825 + identity?.pds, 826 + "app.bsky.feed.like" 827 + ); 828 + 829 + const likes = React.useMemo( 830 + () => likesData?.pages.flatMap((page) => page.records) ?? [], 831 + [likesData] 832 + ); 833 + 834 + const { setFastState } = useFastSetLikesFromFeed(); 835 + const seededRef = React.useRef(new Set<string>()); 836 + 837 + useEffect(() => { 838 + for (const like of likes) { 839 + if (!seededRef.current.has(like.uri)) { 840 + seededRef.current.add(like.uri); 841 + const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record; 842 + setFastState(record.subject.uri, { 843 + target: record.subject.uri, 844 + uri: like.uri, 845 + cid: like.cid, 846 + }); 847 + } 848 + } 849 + }, [likes, setFastState]); 850 + 851 + return ( 852 + <> 853 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 854 + Likes 855 + </div> 856 + <div> 857 + {likes.map((like) => { 858 + if ( 859 + !like || 860 + !like?.value || 861 + !like?.value?.subject || 862 + // @ts-expect-error blehhhhh 863 + !like?.value?.subject?.uri 864 + ) 865 + return; 866 + const likeRecord = 867 + like.value as unknown as ATPAPI.AppBskyFeedLike.Record; 868 + return ( 869 + <UniversalPostRendererATURILoader 870 + key={likeRecord.subject.uri} 871 + atUri={likeRecord.subject.uri} 872 + feedviewpost={true} 873 + /> 874 + ); 875 + })} 876 + </div> 877 + 878 + {/* Loading and "Load More" states */} 879 + {arePostsLoading && likes.length === 0 && ( 880 + <div className="p-4 text-center text-gray-500">Loading likes...</div> 881 + )} 882 + {isFetchingNextPage && ( 883 + <div className="p-4 text-center text-gray-500">Loading more...</div> 884 + )} 885 + {hasNextPage && !isFetchingNextPage && ( 886 + <button 887 + onClick={() => fetchNextPage()} 888 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 889 + > 890 + Load More Likes 891 + </button> 892 + )} 893 + {likes.length === 0 && !arePostsLoading && ( 894 + <div className="p-4 text-center text-gray-500">No likes found.</div> 895 + )} 896 </> 897 ); 898 } ··· 948 )} 949 </> 950 ) : ( 951 + <button 952 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 953 + onClick={(e) => { 954 + renderSnack({ 955 + title: "Not Implemented Yet", 956 + description: "Sorry...", 957 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 958 + }); 959 + }} 960 + > 961 Edit Profile 962 </button> 963 )} 964 </> 965 ); 966 + } 967 + 968 + export function BiteButton({ 969 + targetdidorhandle, 970 + }: { 971 + targetdidorhandle: string; 972 + }) { 973 + const { agent } = useAuth(); 974 + const { data: identity } = useQueryIdentity(targetdidorhandle); 975 + const [show] = useAtom(enableBitesAtom); 976 + 977 + if (!show) return; 978 + 979 + return ( 980 + <> 981 + <button 982 + onClick={async (e) => { 983 + e.stopPropagation(); 984 + await sendBite({ 985 + agent: agent || undefined, 986 + targetDid: identity?.did, 987 + }); 988 + }} 989 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 990 + > 991 + Bite 992 + </button> 993 + </> 994 + ); 995 + } 996 + 997 + async function sendBite({ 998 + agent, 999 + targetDid, 1000 + }: { 1001 + agent?: Agent; 1002 + targetDid?: string; 1003 + }) { 1004 + if (!agent?.did || !targetDid) { 1005 + renderSnack({ 1006 + title: "Bite Failed", 1007 + description: "You must be logged-in to bite someone.", 1008 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 1009 + }); 1010 + return; 1011 + } 1012 + const newRecord = { 1013 + repo: agent.did, 1014 + collection: "net.wafrn.feed.bite", 1015 + rkey: TID.next().toString(), 1016 + record: { 1017 + $type: "net.wafrn.feed.bite", 1018 + subject: "at://" + targetDid, 1019 + createdAt: new Date().toISOString(), 1020 + }, 1021 + }; 1022 + 1023 + try { 1024 + await agent.com.atproto.repo.createRecord(newRecord); 1025 + renderSnack({ 1026 + title: "Bite Sent", 1027 + description: "Your bite was delivered.", 1028 + //button: { label: 'Undo', onClick: () => console.log('Undo clicked') }, 1029 + }); 1030 + } catch (err) { 1031 + console.error("Bite failed:", err); 1032 + renderSnack({ 1033 + title: "Bite Failed", 1034 + description: "Your bite failed to be delivered.", 1035 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 1036 + }); 1037 + } 1038 } 1039 1040 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
+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 + }
+188 -17
src/routes/settings.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 2 - import { useAtom } from "jotai"; 3 - import { Slider } from "radix-ui"; 4 5 import { Header } from "~/components/Header"; 6 import Login from "~/components/Login"; ··· 9 defaultconstellationURL, 10 defaulthue, 11 defaultImgCDN, 12 defaultslingshotURL, 13 defaultVideoCDN, 14 hueAtom, 15 imgCDNAtom, 16 slingshotURLAtom, 17 videoCDNAtom, 18 } from "~/utils/atoms"; 19 20 export const Route = createFileRoute("/settings")({ 21 component: Settings, 22 }); 23 24 export function Settings() { 25 return ( 26 <> 27 <Header ··· 37 <div className="lg:hidden"> 38 <Login /> 39 </div> 40 <div className="h-4" /> 41 <TextInputSetting 42 atom={constellationURLAtom} 43 title={"Constellation"} ··· 66 description={"Customize the Slingshot instance to be used by Red Dwarf"} 67 init={defaultVideoCDN} 68 /> 69 70 - <Hue /> 71 - <p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm"> 72 - please restart/refresh the app if changes arent applying correctly 73 </p> 74 </> 75 ); 76 } 77 function Hue() { 78 const [hue, setHue] = useAtom(hueAtom); 79 return ( 80 - <div className="flex flex-col px-4 mt-4 "> 81 - <span className="z-10">Hue</span> 82 - <div className="flex flex-row items-center gap-4"> 83 - <SliderComponent 84 - atom={hueAtom} 85 - max={360} 86 - /> 87 <button 88 onClick={() => setHue(defaulthue ?? 28)} 89 className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 ··· 154 ); 155 } 156 157 - 158 interface SliderProps { 159 atom: typeof hueAtom; 160 min?: number; ··· 168 max = 100, 169 step = 1, 170 }) => { 171 - 172 - const [value, setValue] = useAtom(atom) 173 174 return ( 175 <Slider.Root ··· 186 <Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" /> 187 </Slider.Root> 188 ); 189 - };
··· 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, 176 + description, 177 + }: { 178 + atom: typeof enableBitesAtom; 179 + title?: string; 180 + description?: string; 181 + }) { 182 + const value = useAtomValue(atom); 183 + const setValue = useSetAtom(atom); 184 + 185 + const [hydrated, setHydrated] = useState(false); 186 + // eslint-disable-next-line react-hooks/set-state-in-effect 187 + useEffect(() => setHydrated(true), []); 188 + 189 + if (!hydrated) { 190 + // Avoid rendering Switch until we know storage is loaded 191 + return null; 192 + } 193 + 194 + return ( 195 + <div className="flex items-center gap-4 px-4 "> 196 + <label htmlFor={`switch-${title}`} className="flex flex-row flex-1"> 197 + <div className="flex flex-col"> 198 + <span className="text-md">{title}</span> 199 + <span className="text-sm text-gray-500 dark:text-gray-400"> 200 + {description} 201 + </span> 202 + </div> 203 + </label> 204 + 205 + <Switch.Root 206 + id={`switch-${title}`} 207 + checked={value} 208 + onCheckedChange={(v) => setValue(v)} 209 + className="m3switch root" 210 + > 211 + <Switch.Thumb className="m3switch thumb " /> 212 + </Switch.Root> 213 + </div> 214 + ); 215 + } 216 + 217 function Hue() { 218 const [hue, setHue] = useAtom(hueAtom); 219 return ( 220 + <div className="flex flex-col px-4"> 221 + <span className="z-[2] text-md">Hue</span> 222 + <span className="z-[2] text-sm text-gray-500 dark:text-gray-400"> 223 + Change the colors of the app 224 + </span> 225 + <div className="z-[1] flex flex-row items-center gap-4"> 226 + <SliderComponent atom={hueAtom} max={360} /> 227 <button 228 onClick={() => setHue(defaulthue ?? 28)} 229 className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 ··· 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 }
+66 -2
src/utils/atoms.ts
··· 2 import { atomWithStorage } from "jotai/utils"; 3 import { useEffect } from "react"; 4 5 export const store = createStore(); 6 7 export const quickAuthAtom = atomWithStorage<string | null>( ··· 21 {} 22 ); 23 24 - type NotificationsScrollState = { 25 activeTab: string; 26 scrollPositions: Record<string, number>; 27 }; 28 - export const notificationsScrollAtom = atom<NotificationsScrollState>({ 29 activeTab: "mentions", 30 scrollPositions: {}, 31 }); 32 33 export const likedPostsAtom = atomWithStorage<Record<string, string>>( 34 "likedPosts", 35 {} 36 ); 37 38 export const defaultconstellationURL = "constellation.microcosm.blue"; 39 export const constellationURLAtom = atomWithStorage<string>( 40 "constellationURL", ··· 51 export const videoCDNAtom = atomWithStorage<string>( 52 "videocdnurl", 53 defaultVideoCDN 54 ); 55 56 export const defaulthue = 28; ··· 89 // console.log("atom get ", initial); 90 // document.documentElement.style.setProperty(cssVar, initial.toString()); 91 // }
··· 2 import { atomWithStorage } from "jotai/utils"; 3 import { useEffect } from "react"; 4 5 + import { type ProfilePostsFilter } from "~/routes/profile.$did"; 6 + 7 export const store = createStore(); 8 9 export const quickAuthAtom = atomWithStorage<string | null>( ··· 23 {} 24 ); 25 26 + type TabRouteScrollState = { 27 activeTab: string; 28 scrollPositions: Record<string, number>; 29 }; 30 + /** 31 + * @deprecated should be safe to remove i think 32 + */ 33 + export const notificationsScrollAtom = atom<TabRouteScrollState>({ 34 activeTab: "mentions", 35 scrollPositions: {}, 36 }); 37 38 + export type InteractionFilter = { 39 + likes: boolean; 40 + reposts: boolean; 41 + quotes: boolean; 42 + replies: boolean; 43 + showAll: boolean; 44 + }; 45 + const defaultFilters: InteractionFilter = { 46 + likes: true, 47 + reposts: true, 48 + quotes: true, 49 + replies: true, 50 + showAll: false, 51 + }; 52 + export const postInteractionsFiltersAtom = atomWithStorage<InteractionFilter>( 53 + "postInteractionsFilters", 54 + defaultFilters 55 + ); 56 + 57 + export const reusableTabRouteScrollAtom = atom<Record<string, TabRouteScrollState | undefined> | undefined>({}); 58 + 59 export const likedPostsAtom = atomWithStorage<Record<string, string>>( 60 "likedPosts", 61 {} 62 ); 63 64 + export type LikeRecord = { 65 + uri: string; // at://did/collection/rkey 66 + target: string; 67 + cid: string; 68 + }; 69 + 70 + export const internalLikedPostsAtom = atomWithStorage<Record<string, LikeRecord | null>>( 71 + "internal-liked-posts", 72 + {} 73 + ); 74 + 75 + export const profileChipsAtom = atom<Record<string, ProfilePostsFilter | null>>({}) 76 + 77 export const defaultconstellationURL = "constellation.microcosm.blue"; 78 export const constellationURLAtom = atomWithStorage<string>( 79 "constellationURL", ··· 90 export const videoCDNAtom = atomWithStorage<string>( 91 "videocdnurl", 92 defaultVideoCDN 93 + ); 94 + 95 + export const defaultLycanURL = ""; 96 + export const lycanURLAtom = atomWithStorage<string>( 97 + "lycanURL", 98 + defaultLycanURL 99 ); 100 101 export const defaulthue = 28; ··· 134 // console.log("atom get ", initial); 135 // document.documentElement.style.setProperty(cssVar, initial.toString()); 136 // } 137 + 138 + 139 + 140 + // fun stuff 141 + 142 + export const enableBitesAtom = atomWithStorage<boolean>( 143 + "enableBitesAtom", 144 + false 145 + ); 146 + 147 + export const enableBridgyTextAtom = atomWithStorage<boolean>( 148 + "enableBridgyTextAtom", 149 + false 150 + ); 151 + 152 + export const enableWafrnTextAtom = atomWithStorage<boolean>( 153 + "enableWafrnTextAtom", 154 + false 155 + );
+34
src/utils/likeMutationQueue.ts
···
··· 1 + import { useAtom } from "jotai"; 2 + import { useCallback } from "react"; 3 + 4 + import { type LikeRecord,useLikeMutationQueue as useLikeMutationQueueFromProvider } from "~/providers/LikeMutationQueueProvider"; 5 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 6 + 7 + import { internalLikedPostsAtom } from "./atoms"; 8 + 9 + export function useFastLike(target: string, cid: string) { 10 + const { agent } = useAuth(); 11 + const { fastState, fastToggle, backfillState } = useLikeMutationQueueFromProvider(); 12 + 13 + const liked = fastState(target); 14 + const toggle = () => fastToggle(target, cid); 15 + /** 16 + * 17 + * @deprecated dont use it yet, will cause infinite rerenders 18 + */ 19 + const backfill = () => agent?.did && backfillState(target, agent.did); 20 + 21 + return { liked, toggle, backfill }; 22 + } 23 + 24 + export function useFastSetLikesFromFeed() { 25 + const [_, setLikedPosts] = useAtom(internalLikedPostsAtom); 26 + 27 + const setFastState = useCallback( 28 + (target: string, record: LikeRecord | null) => 29 + setLikedPosts((prev) => ({ ...prev, [target]: record })), 30 + [setLikedPosts] 31 + ); 32 + 33 + return { setFastState }; 34 + }
+2 -2
src/utils/oauthClient.ts
··· 1 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
+409 -158
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 export function useQueryConstellation(query: { 288 method: "/links"; 289 target: string; ··· 346 > 347 | undefined { 348 //if (!query) return; 349 - const [constellationurl] = useAtom(constellationURLAtom) 350 return useQuery( 351 - constructConstellationQuery(query && {constellation: constellationurl, ...query}) 352 ); 353 } 354 ··· 392 }) { 393 return queryOptions({ 394 // The query key includes all dependencies to ensure it refetches when they change 395 - queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }], 396 queryFn: async () => { 397 - if (!options) return undefined as undefined 398 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 399 if (isAuthed) { 400 // Authenticated flow 401 if (!agent || !pdsUrl || !feedServiceDid) { 402 - throw new Error("Missing required info for authenticated feed fetch."); 403 } 404 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 405 const res = await agent.fetchHandler(url, { ··· 409 "Content-Type": "application/json", 410 }, 411 }); 412 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 413 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 414 } else { 415 // Unauthenticated flow (using a public PDS/AppView) 416 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 417 const res = await fetch(url); 418 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 419 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 420 } 421 }, ··· 433 return useQuery(constructFeedSkeletonQuery(options)); 434 } 435 436 - export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { 437 return queryOptions({ 438 - queryKey: ['preferences', agent?.did], 439 queryFn: async () => { 440 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 441 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; ··· 446 }); 447 } 448 export function useQueryPreferences(options: { 449 - agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined 450 }) { 451 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 452 } 453 454 - 455 - 456 export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { 457 return queryOptions({ 458 queryKey: ["arbitrary", uri], 459 queryFn: async () => { 460 - if (!uri) return undefined as undefined 461 const res = await fetch( 462 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 463 ); ··· 468 return undefined; 469 } 470 if (res.status === 400) return undefined; 471 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 472 return undefined; // cache โ€œnot foundโ€ 473 } 474 try { 475 if (!res.ok) throw new Error("Failed to fetch post"); 476 - return (data) as { 477 uri: string; 478 cid: string; 479 value: any; ··· 488 return failureCount < 2; 489 }, 490 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 491 - gcTime: /*0//*/5 * 60 * 1000, 492 }); 493 } 494 export function useQueryArbitrary(uri: string): UseQueryResult< ··· 499 }, 500 Error 501 >; 502 - export function useQueryArbitrary(): UseQueryResult< 503 - undefined, 504 - Error 505 - >; 506 export function useQueryArbitrary(uri?: string): UseQueryResult< 507 - { 508 - uri: string; 509 - cid: string; 510 - value: any; 511 - } | undefined, 512 Error 513 >; 514 export function useQueryArbitrary(uri?: string) { 515 - const [slingshoturl] = useAtom(slingshotURLAtom) 516 return useQuery(constructArbitraryQuery(uri, slingshoturl)); 517 } 518 519 - export function constructFallbackNothingQuery(){ 520 return queryOptions({ 521 queryKey: ["nothing"], 522 queryFn: async () => { 523 - return undefined 524 }, 525 }); 526 } ··· 534 }[]; 535 }; 536 537 - export function constructAuthorFeedQuery(did: string, pdsUrl: string) { 538 return queryOptions({ 539 - queryKey: ['authorFeed', did], 540 queryFn: async ({ pageParam }: QueryFunctionContext) => { 541 const limit = 25; 542 - 543 const cursor = pageParam as string | undefined; 544 - const cursorParam = cursor ? `&cursor=${cursor}` : ''; 545 - 546 - const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`; 547 - 548 const res = await fetch(url); 549 if (!res.ok) throw new Error("Failed to fetch author's posts"); 550 - 551 return res.json() as Promise<ListRecordsResponse>; 552 }, 553 }); 554 } 555 556 - export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) { 557 - const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!); 558 - 559 return useInfiniteQuery({ 560 queryKey, 561 queryFn, ··· 573 isAuthed: boolean; 574 pdsUrl?: string; 575 feedServiceDid?: string; 576 }) { 577 - const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 578 - 579 return queryOptions({ 580 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 581 - 582 - queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 583 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 584 - 585 - if (isAuthed) { 586 if (!agent || !pdsUrl || !feedServiceDid) { 587 - throw new Error("Missing required info for authenticated feed fetch."); 588 } 589 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 590 const res = await agent.fetchHandler(url, { ··· 594 "Content-Type": "application/json", 595 }, 596 }); 597 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 598 return (await res.json()) as FeedSkeletonPage; 599 } else { 600 - const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 601 const res = await fetch(url); 602 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 603 return (await res.json()) as FeedSkeletonPage; 604 } 605 }, ··· 612 isAuthed: boolean; 613 pdsUrl?: string; 614 feedServiceDid?: string; 615 }) { 616 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 617 - 618 - return {...useInfiniteQuery({ 619 - queryKey, 620 - queryFn, 621 - initialPageParam: undefined as never, 622 - getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 623 - staleTime: Infinity, 624 - refetchOnWindowFocus: false, 625 - enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 626 - }), queryKey: queryKey}; 627 } 628 - 629 630 export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 631 - constellation: string, 632 - method: '/links' 633 - target?: string 634 - collection: string 635 - path: string 636 }) { 637 // console.log( 638 // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 639 // query, ··· 642 return infiniteQueryOptions({ 643 enabled: !!query?.target, 644 queryKey: [ 645 - 'reddwarf_constellation', 646 query?.method, 647 query?.target, 648 query?.collection, 649 query?.path, 650 ] as const, 651 652 - queryFn: async ({pageParam}: {pageParam?: string}) => { 653 - if (!query || !query?.target) return undefined 654 655 - const method = query.method 656 - const target = query.target 657 - const collection = query.collection 658 - const path = query.path 659 - const cursor = pageParam 660 661 const res = await fetch( 662 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${ 663 - collection ? `&collection=${encodeURIComponent(collection)}` : '' 664 - }${path ? `&path=${encodeURIComponent(path)}` : ''}${ 665 - cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' 666 - }`, 667 - ) 668 669 - if (!res.ok) throw new Error('Failed to fetch') 670 671 - return (await res.json()) as linksRecordsResponse 672 }, 673 674 - getNextPageParam: lastPage => { 675 - return (lastPage as any)?.cursor ?? undefined 676 }, 677 initialPageParam: undefined, 678 - staleTime: 5 * 60 * 1000, 679 - gcTime: 5 * 60 * 1000, 680 - }) 681 - }
··· 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 298 + export function useQueryConstellationLinksCountDistinctDids(query?: { 299 + method: "/links/count/distinct-dids"; 300 + target: string; 301 + collection: string; 302 + path: string; 303 + cursor?: string; 304 + }): UseQueryResult<linksCountResponse, Error> | undefined { 305 + //if (!query) return; 306 + const [constellationurl] = useAtom(constellationURLAtom); 307 + const queryres = useQuery( 308 + constructConstellationQuery( 309 + query && { constellation: constellationurl, ...query } 310 + ) 311 + ) as unknown as UseQueryResult<linksCountResponse, Error>; 312 + if (!query) { 313 + return undefined as undefined; 314 + } 315 + return queryres as UseQueryResult<linksCountResponse, Error>; 316 + } 317 + 318 export function useQueryConstellation(query: { 319 method: "/links"; 320 target: string; ··· 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, ··· 629 isAuthed: boolean; 630 pdsUrl?: string; 631 feedServiceDid?: string; 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 }, ··· 677 isAuthed: boolean; 678 pdsUrl?: string; 679 feedServiceDid?: string; 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( 714 // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 715 // query, ··· 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({