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

sonner toasts

rimar1337 7edbb928 6f18b579

+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 },
+1
src/auto-imports.d.ts
··· 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default 23 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 24 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 25 }
··· 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default 23 + const IconMdiClose: typeof import('~icons/mdi/close.jsx').default 24 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 25 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 26 }
+6
src/providers/LikeMutationQueueProvider.tsx
··· 5 import React, { createContext, use, useCallback, useEffect, useRef } from "react"; 6 7 import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms"; 9 import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery"; 10 ··· 125 } 126 } catch (err) { 127 console.error("Like mutation failed, reverting:", err); 128 if (mutation.type === 'like') { 129 setFastState(mutation.target, null); 130 } else if (mutation.type === 'unlike') {
··· 5 import React, { createContext, use, useCallback, useEffect, useRef } from "react"; 6 7 import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 + import { renderSnack } from "~/routes/__root"; 9 import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms"; 10 import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery"; 11 ··· 126 } 127 } catch (err) { 128 console.error("Like mutation failed, reverting:", err); 129 + renderSnack({ 130 + title: 'Like Mutation Failed', 131 + description: 'Please try again.', 132 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 133 + }) 134 if (mutation.type === 'like') { 135 setFastState(mutation.target, null); 136 } else if (mutation.type === 'unlike') {
+138 -8
src/routes/__root.tsx
··· 14 import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 15 import { useAtom } from "jotai"; 16 import * as React from "react"; 17 import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive"; 18 19 import { Composer } from "~/components/Composer"; ··· 83 <LikeMutationQueueProvider> 84 <RootDocument> 85 <KeepAliveProvider> 86 <KeepAliveOutlet /> 87 </KeepAliveProvider> 88 </RootDocument> ··· 91 ); 92 } 93 94 function RootDocument({ children }: { children: React.ReactNode }) { 95 useAtomCssVar(hueAtom, "--tw-gray-hue"); 96 const location = useLocation(); ··· 134 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 135 <nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start"> 136 <div className="flex items-center gap-3 mb-4"> 137 - <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 138 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 139 Red Dwarf{" "} 140 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 235 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 236 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 237 //active={true} 238 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 239 text="Post" 240 /> 241 </div> ··· 373 374 <nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start"> 375 <div className="flex items-center gap-3 mb-4"> 376 - <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 377 </div> 378 <MaterialNavItem 379 small ··· 475 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 476 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 477 //active={true} 478 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 479 text="Post" 480 /> 481 </div> ··· 485 <button 486 className="lg:hidden fixed bottom-22 right-4 z-50 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-2xl w-14 h-14 flex items-center justify-center transition-all" 487 style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }} 488 - onClick={() => setComposerPost({ kind: 'root' })} 489 type="button" 490 aria-label="Create Post" 491 > ··· 502 </main> 503 504 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 505 - <div className="px-4 pt-4"><Import /></div> 506 <Login /> 507 508 <div className="flex-1"></div> 509 <p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4"> 510 - Red Dwarf is a Bluesky client that does not rely on any Bluesky API App Servers. Instead, it uses Microcosm to fetch records directly from each users' PDS (via Slingshot) and connect them using backlinks (via Constellation) 511 </p> 512 </aside> 513 </div> ··· 683 ) : ( 684 <div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10"> 685 <div className="flex items-center gap-2"> 686 - <FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 687 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 688 Red Dwarf{" "} 689 {/* <span className="text-gray-500 dark:text-gray-400 text-sm">
··· 14 import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 15 import { useAtom } from "jotai"; 16 import * as React from "react"; 17 + import { toast as sonnerToast } from "sonner"; 18 + import { Toaster } from "sonner"; 19 import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive"; 20 21 import { Composer } from "~/components/Composer"; ··· 85 <LikeMutationQueueProvider> 86 <RootDocument> 87 <KeepAliveProvider> 88 + <AppToaster /> 89 <KeepAliveOutlet /> 90 </KeepAliveProvider> 91 </RootDocument> ··· 94 ); 95 } 96 97 + export function AppToaster() { 98 + return ( 99 + <Toaster 100 + position="bottom-center" 101 + toastOptions={{ 102 + duration: 4000, 103 + }} 104 + /> 105 + ); 106 + } 107 + 108 + export function renderSnack({ 109 + title, 110 + description, 111 + button, 112 + }: Omit<ToastProps, "id">) { 113 + return sonnerToast.custom((id) => ( 114 + <Snack 115 + id={id} 116 + title={title} 117 + description={description} 118 + button={ 119 + button?.label 120 + ? { 121 + label: button?.label, 122 + onClick: () => { 123 + button?.onClick?.(); 124 + }, 125 + } 126 + : undefined 127 + } 128 + /> 129 + )); 130 + } 131 + 132 + function Snack(props: ToastProps) { 133 + const { title, description, button, id } = props; 134 + 135 + return ( 136 + <div 137 + role="status" 138 + aria-live="polite" 139 + className=" 140 + w-full md:max-w-[520px] 141 + flex items-center justify-between 142 + rounded-md 143 + px-4 py-3 144 + shadow-sm 145 + dark:bg-gray-300 dark:text-gray-900 146 + bg-gray-700 text-gray-100 147 + ring-1 dark:ring-gray-200 ring-gray-800 148 + " 149 + > 150 + <div className="flex-1 min-w-0"> 151 + <p className="text-sm font-medium truncate">{title}</p> 152 + {description ? ( 153 + <p className="mt-1 text-sm dark:text-gray-600 text-gray-300 truncate"> 154 + {description} 155 + </p> 156 + ) : null} 157 + </div> 158 + 159 + {button ? ( 160 + <div className="ml-4 flex-shrink-0"> 161 + <button 162 + className=" 163 + text-sm font-medium 164 + px-3 py-1 rounded-md 165 + bg-gray-200 text-gray-900 166 + hover:bg-gray-300 167 + dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 168 + focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-300 dark:focus:ring-gray-700 169 + " 170 + onClick={() => { 171 + button.onClick(); 172 + sonnerToast.dismiss(id); 173 + }} 174 + > 175 + {button.label} 176 + </button> 177 + </div> 178 + ) : null} 179 + <button className=" ml-4" 180 + onClick={() => { 181 + sonnerToast.dismiss(id); 182 + }} 183 + > 184 + <IconMdiClose /> 185 + </button> 186 + </div> 187 + ); 188 + } 189 + 190 + /* Types */ 191 + interface ToastProps { 192 + id: string | number; 193 + title: string; 194 + description?: string; 195 + button?: { 196 + label: string; 197 + onClick: () => void; 198 + }; 199 + } 200 + 201 function RootDocument({ children }: { children: React.ReactNode }) { 202 useAtomCssVar(hueAtom, "--tw-gray-hue"); 203 const location = useLocation(); ··· 241 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 242 <nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start"> 243 <div className="flex items-center gap-3 mb-4"> 244 + <FluentEmojiHighContrastGlowingStar 245 + className="h-8 w-8" 246 + style={{ 247 + color: 248 + "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))", 249 + }} 250 + /> 251 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 252 Red Dwarf{" "} 253 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 348 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 349 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 350 //active={true} 351 + onClickCallbback={() => setComposerPost({ kind: "root" })} 352 text="Post" 353 /> 354 </div> ··· 486 487 <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"> 488 <div className="flex items-center gap-3 mb-4"> 489 + <FluentEmojiHighContrastGlowingStar 490 + className="h-8 w-8" 491 + style={{ 492 + color: 493 + "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))", 494 + }} 495 + /> 496 </div> 497 <MaterialNavItem 498 small ··· 594 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 595 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 596 //active={true} 597 + onClickCallbback={() => setComposerPost({ kind: "root" })} 598 text="Post" 599 /> 600 </div> ··· 604 <button 605 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" 606 style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }} 607 + onClick={() => setComposerPost({ kind: "root" })} 608 type="button" 609 aria-label="Create Post" 610 > ··· 621 </main> 622 623 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 624 + <div className="px-4 pt-4"> 625 + <Import /> 626 + </div> 627 <Login /> 628 629 <div className="flex-1"></div> 630 <p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4"> 631 + Red Dwarf is a Bluesky client that does not rely on any Bluesky API 632 + App Servers. Instead, it uses Microcosm to fetch records directly 633 + from each users' PDS (via Slingshot) and connect them using 634 + backlinks (via Constellation) 635 </p> 636 </aside> 637 </div> ··· 807 ) : ( 808 <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"> 809 <div className="flex items-center gap-2"> 810 + <FluentEmojiHighContrastGlowingStar 811 + className="h-6 w-6" 812 + style={{ 813 + color: 814 + "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))", 815 + }} 816 + /> 817 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 818 Red Dwarf{" "} 819 {/* <span className="text-gray-500 dark:text-gray-400 text-sm">
+99 -39
src/routes/profile.$did/index.tsx
··· 33 useQueryProfile, 34 } from "~/utils/useQuery"; 35 36 import { Chip } from "../notifications"; 37 38 export const Route = createFileRoute("/profile/$did/")({ ··· 81 82 const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; 83 84 - const resultwhateversure = useQueryConstellationLinksCountDistinctDids(resolvedDid ? { 85 - method: "/links/count/distinct-dids", 86 - collection: "app.bsky.graph.follow", 87 - target: resolvedDid, 88 - path: ".subject" 89 - } : undefined) 90 91 const followercount = resultwhateversure?.data?.total; 92 ··· 152 also save it persistently 153 */} 154 <FollowButton targetdidorhandle={did} /> 155 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 156 ... {/* todo: icon */} 157 </button> 158 </div> ··· 165 {handle} 166 </div> 167 <div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2"> 168 - <Link to="/profile/$did/followers" params={{did: did}}>{followercount && (<span className="mr-1 text-gray-900 dark:text-gray-200 font-medium">{followercount}</span>)}Followers</Link> 169 - 170 - <Link to="/profile/$did/follows" params={{did: did}}>Follows</Link> 171 </div> 172 {description && ( 173 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> ··· 212 } 213 214 export type ProfilePostsFilter = { 215 - posts: boolean, 216 - replies: boolean, 217 - mediaOnly: boolean, 218 - } 219 export const defaultProfilePostsFilter: ProfilePostsFilter = { 220 posts: true, 221 replies: true, 222 mediaOnly: false, 223 - } 224 225 - function ProfilePostsFilterChipBar({filters, toggle}:{filters: ProfilePostsFilter | null, toggle: (key: keyof ProfilePostsFilter) => void}) { 226 - const empty = (!filters?.replies && !filters?.posts); 227 - const almostEmpty = (!filters?.replies && filters?.posts); 228 229 useEffect(() => { 230 if (empty) { 231 - toggle("posts") 232 } 233 }, [empty, toggle]); 234 ··· 237 <Chip 238 state={filters?.posts ?? true} 239 text="Posts" 240 - onClick={() => almostEmpty ? null : toggle("posts")} 241 /> 242 <Chip 243 state={filters?.replies ?? true} ··· 258 const [filterses, setFilterses] = useAtom(profileChipsAtom); 259 const filters = filterses?.[did]; 260 const setFilters = (obj: ProfilePostsFilter) => { 261 - setFilterses((prev)=>{ 262 - return{ 263 ...prev, 264 - [did]: obj 265 - } 266 - }) 267 - } 268 - useEffect(()=>{ 269 if (!filters) { 270 setFilters(defaultProfilePostsFilter); 271 } 272 - }) 273 useReusableTabScrollRestore(`Profile` + did); 274 const queryClient = useQueryClient(); 275 const { ··· 306 ); 307 308 const toggle = (key: keyof ProfilePostsFilter) => { 309 - setFilterses(prev => { 310 - const existing = prev[did] ?? { posts: false, replies: false, mediaOnly: false }; // default 311 312 return { 313 ...prev, ··· 805 )} 806 </> 807 ) : ( 808 - <button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"> 809 Edit Profile 810 </button> 811 )} ··· 822 const { data: identity } = useQueryIdentity(targetdidorhandle); 823 const [show] = useAtom(enableBitesAtom); 824 825 - if (!show) return 826 827 return ( 828 <> 829 <button 830 - onClick={(e) => { 831 e.stopPropagation(); 832 - sendBite({ 833 agent: agent || undefined, 834 targetDid: identity?.did, 835 }); ··· 842 ); 843 } 844 845 - function sendBite({ 846 agent, 847 targetDid, 848 }: { 849 agent?: Agent; 850 targetDid?: string; 851 }) { 852 - if (!agent?.did || !targetDid) return; 853 const newRecord = { 854 repo: agent.did, 855 collection: "net.wafrn.feed.bite", 856 rkey: TID.next().toString(), 857 record: { 858 $type: "net.wafrn.feed.bite", 859 - subject: "at://"+targetDid, 860 createdAt: new Date().toISOString(), 861 }, 862 }; 863 864 - agent.com.atproto.repo.createRecord(newRecord).catch((err) => { 865 console.error("Bite failed:", err); 866 - }); 867 } 868 - 869 870 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 871 const { agent } = useAuth();
··· 33 useQueryProfile, 34 } from "~/utils/useQuery"; 35 36 + import { renderSnack } from "../__root"; 37 import { Chip } from "../notifications"; 38 39 export const Route = createFileRoute("/profile/$did/")({ ··· 82 83 const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; 84 85 + const resultwhateversure = useQueryConstellationLinksCountDistinctDids( 86 + resolvedDid 87 + ? { 88 + method: "/links/count/distinct-dids", 89 + collection: "app.bsky.graph.follow", 90 + target: resolvedDid, 91 + path: ".subject", 92 + } 93 + : undefined 94 + ); 95 96 const followercount = resultwhateversure?.data?.total; 97 ··· 157 also save it persistently 158 */} 159 <FollowButton targetdidorhandle={did} /> 160 + <button 161 + className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 162 + onClick={(e) => { 163 + renderSnack({ 164 + title: "Not Implemented Yet", 165 + description: "Sorry...", 166 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 167 + }); 168 + }} 169 + > 170 ... {/* todo: icon */} 171 </button> 172 </div> ··· 179 {handle} 180 </div> 181 <div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2"> 182 + <Link to="/profile/$did/followers" params={{ did: did }}> 183 + {followercount && ( 184 + <span className="mr-1 text-gray-900 dark:text-gray-200 font-medium"> 185 + {followercount} 186 + </span> 187 + )} 188 + Followers 189 + </Link> 190 - 191 + <Link to="/profile/$did/follows" params={{ did: did }}> 192 + Follows 193 + </Link> 194 </div> 195 {description && ( 196 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> ··· 235 } 236 237 export type ProfilePostsFilter = { 238 + posts: boolean; 239 + replies: boolean; 240 + mediaOnly: boolean; 241 + }; 242 export const defaultProfilePostsFilter: ProfilePostsFilter = { 243 posts: true, 244 replies: true, 245 mediaOnly: false, 246 + }; 247 248 + function ProfilePostsFilterChipBar({ 249 + filters, 250 + toggle, 251 + }: { 252 + filters: ProfilePostsFilter | null; 253 + toggle: (key: keyof ProfilePostsFilter) => void; 254 + }) { 255 + const empty = !filters?.replies && !filters?.posts; 256 + const almostEmpty = !filters?.replies && filters?.posts; 257 258 useEffect(() => { 259 if (empty) { 260 + toggle("posts"); 261 } 262 }, [empty, toggle]); 263 ··· 266 <Chip 267 state={filters?.posts ?? true} 268 text="Posts" 269 + onClick={() => (almostEmpty ? null : toggle("posts"))} 270 /> 271 <Chip 272 state={filters?.replies ?? true} ··· 287 const [filterses, setFilterses] = useAtom(profileChipsAtom); 288 const filters = filterses?.[did]; 289 const setFilters = (obj: ProfilePostsFilter) => { 290 + setFilterses((prev) => { 291 + return { 292 ...prev, 293 + [did]: obj, 294 + }; 295 + }); 296 + }; 297 + useEffect(() => { 298 if (!filters) { 299 setFilters(defaultProfilePostsFilter); 300 } 301 + }); 302 useReusableTabScrollRestore(`Profile` + did); 303 const queryClient = useQueryClient(); 304 const { ··· 335 ); 336 337 const toggle = (key: keyof ProfilePostsFilter) => { 338 + setFilterses((prev) => { 339 + const existing = prev[did] ?? { 340 + posts: false, 341 + replies: false, 342 + mediaOnly: false, 343 + }; // default 344 345 return { 346 ...prev, ··· 838 )} 839 </> 840 ) : ( 841 + <button 842 + 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]" 843 + onClick={(e) => { 844 + renderSnack({ 845 + title: "Not Implemented Yet", 846 + description: "Sorry...", 847 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 848 + }); 849 + }} 850 + > 851 Edit Profile 852 </button> 853 )} ··· 864 const { data: identity } = useQueryIdentity(targetdidorhandle); 865 const [show] = useAtom(enableBitesAtom); 866 867 + if (!show) return; 868 869 return ( 870 <> 871 <button 872 + onClick={async (e) => { 873 e.stopPropagation(); 874 + await sendBite({ 875 agent: agent || undefined, 876 targetDid: identity?.did, 877 }); ··· 884 ); 885 } 886 887 + async function sendBite({ 888 agent, 889 targetDid, 890 }: { 891 agent?: Agent; 892 targetDid?: string; 893 }) { 894 + if (!agent?.did || !targetDid) { 895 + renderSnack({ 896 + title: "Bite Failed", 897 + description: "You must be logged-in to bite someone.", 898 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 899 + }); 900 + return; 901 + } 902 const newRecord = { 903 repo: agent.did, 904 collection: "net.wafrn.feed.bite", 905 rkey: TID.next().toString(), 906 record: { 907 $type: "net.wafrn.feed.bite", 908 + subject: "at://" + targetDid, 909 createdAt: new Date().toISOString(), 910 }, 911 }; 912 913 + try { 914 + await agent.com.atproto.repo.createRecord(newRecord); 915 + renderSnack({ 916 + title: "Bite Sent", 917 + description: "Your bite was delivered.", 918 + //button: { label: 'Undo', onClick: () => console.log('Undo clicked') }, 919 + }); 920 + } catch (err) { 921 console.error("Bite failed:", err); 922 + renderSnack({ 923 + title: "Bite Failed", 924 + description: "Your bite failed to be delivered.", 925 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 926 + }); 927 + } 928 } 929 930 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 931 const { agent } = useAuth();