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

Configure Feed

Select the types of activity you want to include in your feed.

wafrn bites

rimar1337 208521f9 48a6f09a

+203 -7
+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 }