an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app

wafrn bites

rimar1337 208521f9 48a6f09a

+203 -7
+76
src/routes/notifications.tsx
··· 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 import { 21 constellationURLAtom, 22 imgCDNAtom, 23 postInteractionsFiltersAtom, 24 } from "~/utils/atoms"; ··· 56 }); 57 58 export default function NotificationsTabs() { 59 return ( 60 <ReusableTabRoute 61 route={`Notifications`} ··· 63 Mentions: <MentionsTab />, 64 Follows: <FollowsTab />, 65 "Post Interactions": <PostInteractionsTab />, 66 }} 67 /> 68 ); ··· 180 if (isError) return <ErrorState error={error} />; 181 182 if (!followsAturis?.length) return <EmptyState text="No follows yet." />; 183 184 return ( 185 <> ··· 499 500 export function NotificationItem({ notification }: { notification: string }) { 501 const aturi = new AtUri(notification); 502 const navigate = useNavigate(); 503 const { data: identity } = useQueryIdentity(aturi.host); 504 const resolvedDid = identity?.did;
··· 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 import { 21 constellationURLAtom, 22 + enableBitesAtom, 23 imgCDNAtom, 24 postInteractionsFiltersAtom, 25 } from "~/utils/atoms"; ··· 57 }); 58 59 export default function NotificationsTabs() { 60 + const [bitesEnabled] = useAtom(enableBitesAtom); 61 return ( 62 <ReusableTabRoute 63 route={`Notifications`} ··· 65 Mentions: <MentionsTab />, 66 Follows: <FollowsTab />, 67 "Post Interactions": <PostInteractionsTab />, 68 + ...bitesEnabled ? { 69 + Bites: <BitesTab />, 70 + } : {} 71 }} 72 /> 73 ); ··· 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 <> ··· 574 575 export function NotificationItem({ notification }: { notification: string }) { 576 const aturi = new AtUri(notification); 577 + const bite = aturi.collection === "net.wafrn.feed.bite"; 578 const navigate = useNavigate(); 579 const { data: identity } = useQueryIdentity(aturi.host); 580 const resolvedDid = identity?.did;
+58 -2
src/routes/profile.$did/index.tsx
··· 1 - import { RichText } from "@atproto/api"; 2 import * as ATPAPI from "@atproto/api"; 3 import { useQueryClient } from "@tanstack/react-query"; 4 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 5 import { useAtom } from "jotai"; ··· 16 UniversalPostRendererATURILoader, 17 } from "~/components/UniversalPostRenderer"; 18 import { useAuth } from "~/providers/UnifiedAuthProvider"; 19 - import { imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 20 import { 21 toggleFollow, 22 useGetFollowState, ··· 143 </div> 144 145 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> 146 {/* 147 todo: full follow and unfollow backfill (along with partial likes backfill, 148 just enough for it to be useful) ··· 810 </> 811 ); 812 } 813 814 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 815 const { agent } = useAuth();
··· 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"; ··· 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, ··· 144 </div> 145 146 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> 147 + <BiteButton targetdidorhandle={did} /> 148 {/* 149 todo: full follow and unfollow backfill (along with partial likes backfill, 150 just enough for it to be useful) ··· 812 </> 813 ); 814 } 815 + 816 + export function BiteButton({ 817 + targetdidorhandle, 818 + }: { 819 + targetdidorhandle: string; 820 + }) { 821 + const { agent } = useAuth(); 822 + const { data: identity } = useQueryIdentity(targetdidorhandle); 823 + const [show] = useAtom(enableBitesAtom); 824 + 825 + if (!show) return 826 + 827 + return ( 828 + <> 829 + <button 830 + onClick={(e) => { 831 + e.stopPropagation(); 832 + sendBite({ 833 + agent: agent || undefined, 834 + targetDid: identity?.did, 835 + }); 836 + }} 837 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 838 + > 839 + Bite 840 + </button> 841 + </> 842 + ); 843 + } 844 + 845 + function sendBite({ 846 + agent, 847 + targetDid, 848 + }: { 849 + agent?: Agent; 850 + targetDid?: string; 851 + }) { 852 + if (!agent?.did || !targetDid) return; 853 + const newRecord = { 854 + repo: agent.did, 855 + collection: "net.wafrn.feed.bite", 856 + rkey: TID.next().toString(), 857 + record: { 858 + $type: "net.wafrn.feed.bite", 859 + subject: "at://"+targetDid, 860 + createdAt: new Date().toISOString(), 861 + }, 862 + }; 863 + 864 + agent.com.atproto.repo.createRecord(newRecord).catch((err) => { 865 + console.error("Bite failed:", err); 866 + }); 867 + } 868 + 869 870 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 871 const { agent } = useAuth();
+55 -2
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"; ··· 11 defaultImgCDN, 12 defaultslingshotURL, 13 defaultVideoCDN, 14 hueAtom, 15 imgCDNAtom, 16 slingshotURLAtom, ··· 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 (
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 + import { useAtom, useAtomValue, useSetAtom } from "jotai"; 3 + import { Slider, Switch } from "radix-ui"; 4 + import { useEffect,useState } from "react"; 5 6 import { Header } from "~/components/Header"; 7 import Login from "~/components/Login"; ··· 12 defaultImgCDN, 13 defaultslingshotURL, 14 defaultVideoCDN, 15 + enableBitesAtom, 16 hueAtom, 17 imgCDNAtom, 18 slingshotURLAtom, ··· 70 /> 71 72 <Hue /> 73 + <SwitchSetting 74 + atom={enableBitesAtom} 75 + title={"Bites"} 76 + description={"Enable Wafrn Bites"} 77 + //init={false} 78 + /> 79 <p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm"> 80 please restart/refresh the app if changes arent applying correctly 81 </p> 82 </> 83 ); 84 } 85 + 86 + export function SwitchSetting({ 87 + atom, 88 + title, 89 + description, 90 + }: { 91 + atom: typeof enableBitesAtom; 92 + title?: string; 93 + description?: string; 94 + }) { 95 + const value = useAtomValue(atom); 96 + const setValue = useSetAtom(atom); 97 + 98 + const [hydrated, setHydrated] = useState(false); 99 + // eslint-disable-next-line react-hooks/set-state-in-effect 100 + useEffect(() => setHydrated(true), []); 101 + 102 + if (!hydrated) { 103 + // Avoid rendering Switch until we know storage is loaded 104 + return null; 105 + } 106 + 107 + return ( 108 + <div className="flex items-center gap-4 px-4 py-2"> 109 + <div className="flex flex-col"> 110 + <label htmlFor="switch-demo" className="text-lg"> 111 + {title} 112 + </label> 113 + <span className="text-sm">{description}</span> 114 + </div> 115 + 116 + <Switch.Root 117 + id="switch-demo" 118 + checked={value} 119 + onCheckedChange={(v) => setValue(v)} 120 + className="w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-blue-500 transition-colors" 121 + > 122 + <Switch.Thumb 123 + className="block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]" 124 + /> 125 + </Switch.Root> 126 + </div> 127 + ); 128 + } 129 + 130 function Hue() { 131 const [hue, setHue] = useAtom(hueAtom); 132 return (
+9
src/utils/atoms.ts
··· 128 // console.log("atom get ", initial); 129 // document.documentElement.style.setProperty(cssVar, initial.toString()); 130 // }
··· 128 // console.log("atom get ", initial); 129 // document.documentElement.style.setProperty(cssVar, initial.toString()); 130 // } 131 + 132 + 133 + 134 + // fun stuff 135 + 136 + export const enableBitesAtom = atomWithStorage<boolean>( 137 + "enableBitesAtom", 138 + false 139 + );
+5 -3
src/utils/useQuery.ts
··· 654 method: '/links' 655 target?: string 656 collection: string 657 - path: string 658 }) { 659 // console.log( 660 // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 661 // query, ··· 697 return (lastPage as any)?.cursor ?? undefined 698 }, 699 initialPageParam: undefined, 700 - staleTime: 5 * 60 * 1000, 701 - gcTime: 5 * 60 * 1000, 702 }) 703 }
··· 654 method: '/links' 655 target?: string 656 collection: string 657 + path: string, 658 + staleMult?: number 659 }) { 660 + const safemult = query?.staleMult || 1; 661 // console.log( 662 // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 663 // query, ··· 699 return (lastPage as any)?.cursor ?? undefined 700 }, 701 initialPageParam: undefined, 702 + staleTime: 5 * 60 * 1000 * safemult, 703 + gcTime: 5 * 60 * 1000 * safemult, 704 }) 705 }