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

wafrn bites

rimar1337 208521f9 48a6f09a

Changed files
+203 -7
src
+76
src/routes/notifications.tsx
··· 19 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 20 import { 21 21 constellationURLAtom, 22 + enableBitesAtom, 22 23 imgCDNAtom, 23 24 postInteractionsFiltersAtom, 24 25 } from "~/utils/atoms"; ··· 56 57 }); 57 58 58 59 export default function NotificationsTabs() { 60 + const [bitesEnabled] = useAtom(enableBitesAtom); 59 61 return ( 60 62 <ReusableTabRoute 61 63 route={`Notifications`} ··· 63 65 Mentions: <MentionsTab />, 64 66 Follows: <FollowsTab />, 65 67 "Post Interactions": <PostInteractionsTab />, 68 + ...bitesEnabled ? { 69 + Bites: <BitesTab />, 70 + } : {} 66 71 }} 67 72 /> 68 73 ); ··· 180 185 if (isError) return <ErrorState error={error} />; 181 186 182 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." />; 183 258 184 259 return ( 185 260 <> ··· 499 574 500 575 export function NotificationItem({ notification }: { notification: string }) { 501 576 const aturi = new AtUri(notification); 577 + const bite = aturi.collection === "net.wafrn.feed.bite"; 502 578 const navigate = useNavigate(); 503 579 const { data: identity } = useQueryIdentity(aturi.host); 504 580 const resolvedDid = identity?.did;
+58 -2
src/routes/profile.$did/index.tsx
··· 1 - import { RichText } from "@atproto/api"; 1 + import { Agent, RichText } from "@atproto/api"; 2 2 import * as ATPAPI from "@atproto/api"; 3 + import { TID } from "@atproto/common-web"; 3 4 import { useQueryClient } from "@tanstack/react-query"; 4 5 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 5 6 import { useAtom } from "jotai"; ··· 16 17 UniversalPostRendererATURILoader, 17 18 } from "~/components/UniversalPostRenderer"; 18 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 19 - import { imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 20 + import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 20 21 import { 21 22 toggleFollow, 22 23 useGetFollowState, ··· 143 144 </div> 144 145 145 146 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> 147 + <BiteButton targetdidorhandle={did} /> 146 148 {/* 147 149 todo: full follow and unfollow backfill (along with partial likes backfill, 148 150 just enough for it to be useful) ··· 810 812 </> 811 813 ); 812 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 + 813 869 814 870 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 815 871 const { agent } = useAuth();
+55 -2
src/routes/settings.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 - import { useAtom } from "jotai"; 3 - import { Slider } from "radix-ui"; 2 + import { useAtom, useAtomValue, useSetAtom } from "jotai"; 3 + import { Slider, Switch } from "radix-ui"; 4 + import { useEffect,useState } from "react"; 4 5 5 6 import { Header } from "~/components/Header"; 6 7 import Login from "~/components/Login"; ··· 11 12 defaultImgCDN, 12 13 defaultslingshotURL, 13 14 defaultVideoCDN, 15 + enableBitesAtom, 14 16 hueAtom, 15 17 imgCDNAtom, 16 18 slingshotURLAtom, ··· 68 70 /> 69 71 70 72 <Hue /> 73 + <SwitchSetting 74 + atom={enableBitesAtom} 75 + title={"Bites"} 76 + description={"Enable Wafrn Bites"} 77 + //init={false} 78 + /> 71 79 <p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm"> 72 80 please restart/refresh the app if changes arent applying correctly 73 81 </p> 74 82 </> 75 83 ); 76 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 + 77 130 function Hue() { 78 131 const [hue, setHue] = useAtom(hueAtom); 79 132 return (
+9
src/utils/atoms.ts
··· 128 128 // console.log("atom get ", initial); 129 129 // document.documentElement.style.setProperty(cssVar, initial.toString()); 130 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 654 method: '/links' 655 655 target?: string 656 656 collection: string 657 - path: string 657 + path: string, 658 + staleMult?: number 658 659 }) { 660 + const safemult = query?.staleMult || 1; 659 661 // console.log( 660 662 // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 661 663 // query, ··· 697 699 return (lastPage as any)?.cursor ?? undefined 698 700 }, 699 701 initialPageParam: undefined, 700 - staleTime: 5 * 60 * 1000, 701 - gcTime: 5 * 60 * 1000, 702 + staleTime: 5 * 60 * 1000 * safemult, 703 + gcTime: 5 * 60 * 1000 * safemult, 702 704 }) 703 705 }