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 29 "react": "^19.0.0", 30 30 "react-dom": "^19.0.0", 31 31 "react-player": "^3.3.2", 32 + "sonner": "^2.0.7", 32 33 "tailwindcss": "^4.0.6", 33 34 "tanstack-router-keepalive": "^1.0.0" 34 35 }, ··· 12543 12544 "csstype": "^3.1.0", 12544 12545 "seroval": "~1.3.0", 12545 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" 12546 12556 } 12547 12557 }, 12548 12558 "node_modules/source-map": {
+1
package.json
··· 33 33 "react": "^19.0.0", 34 34 "react-dom": "^19.0.0", 35 35 "react-player": "^3.3.2", 36 + "sonner": "^2.0.7", 36 37 "tailwindcss": "^4.0.6", 37 38 "tanstack-router-keepalive": "^1.0.0" 38 39 },
+1
src/auto-imports.d.ts
··· 20 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 21 const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 22 const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default 23 + const IconMdiClose: typeof import('~icons/mdi/close.jsx').default 23 24 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 24 25 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 25 26 }
+6
src/providers/LikeMutationQueueProvider.tsx
··· 5 5 import React, { createContext, use, useCallback, useEffect, useRef } from "react"; 6 6 7 7 import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 + import { renderSnack } from "~/routes/__root"; 8 9 import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms"; 9 10 import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery"; 10 11 ··· 125 126 } 126 127 } catch (err) { 127 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 + }) 128 134 if (mutation.type === 'like') { 129 135 setFastState(mutation.target, null); 130 136 } else if (mutation.type === 'unlike') {
+138 -8
src/routes/__root.tsx
··· 14 14 import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 15 15 import { useAtom } from "jotai"; 16 16 import * as React from "react"; 17 + import { toast as sonnerToast } from "sonner"; 18 + import { Toaster } from "sonner"; 17 19 import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive"; 18 20 19 21 import { Composer } from "~/components/Composer"; ··· 83 85 <LikeMutationQueueProvider> 84 86 <RootDocument> 85 87 <KeepAliveProvider> 88 + <AppToaster /> 86 89 <KeepAliveOutlet /> 87 90 </KeepAliveProvider> 88 91 </RootDocument> ··· 91 94 ); 92 95 } 93 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 + 94 201 function RootDocument({ children }: { children: React.ReactNode }) { 95 202 useAtomCssVar(hueAtom, "--tw-gray-hue"); 96 203 const location = useLocation(); ··· 134 241 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 135 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"> 136 243 <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))"}} /> 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 + /> 138 251 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 139 252 Red Dwarf{" "} 140 253 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 235 348 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 236 349 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 237 350 //active={true} 238 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 351 + onClickCallbback={() => setComposerPost({ kind: "root" })} 239 352 text="Post" 240 353 /> 241 354 </div> ··· 373 486 374 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"> 375 488 <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))"}} /> 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 + /> 377 496 </div> 378 497 <MaterialNavItem 379 498 small ··· 475 594 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 476 595 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 477 596 //active={true} 478 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 597 + onClickCallbback={() => setComposerPost({ kind: "root" })} 479 598 text="Post" 480 599 /> 481 600 </div> ··· 485 604 <button 486 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" 487 606 style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }} 488 - onClick={() => setComposerPost({ kind: 'root' })} 607 + onClick={() => setComposerPost({ kind: "root" })} 489 608 type="button" 490 609 aria-label="Create Post" 491 610 > ··· 502 621 </main> 503 622 504 623 <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> 624 + <div className="px-4 pt-4"> 625 + <Import /> 626 + </div> 506 627 <Login /> 507 628 508 629 <div className="flex-1"></div> 509 630 <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) 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) 511 635 </p> 512 636 </aside> 513 637 </div> ··· 683 807 ) : ( 684 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"> 685 809 <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))"}} /> 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 + /> 687 817 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 688 818 Red Dwarf{" "} 689 819 {/* <span className="text-gray-500 dark:text-gray-400 text-sm">
+99 -39
src/routes/profile.$did/index.tsx
··· 33 33 useQueryProfile, 34 34 } from "~/utils/useQuery"; 35 35 36 + import { renderSnack } from "../__root"; 36 37 import { Chip } from "../notifications"; 37 38 38 39 export const Route = createFileRoute("/profile/$did/")({ ··· 81 82 82 83 const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; 83 84 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) 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 + ); 90 95 91 96 const followercount = resultwhateversure?.data?.total; 92 97 ··· 152 157 also save it persistently 153 158 */} 154 159 <FollowButton targetdidorhandle={did} /> 155 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 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 + > 156 170 ... {/* todo: icon */} 157 171 </button> 158 172 </div> ··· 165 179 {handle} 166 180 </div> 167 181 <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> 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> 169 190 - 170 - <Link to="/profile/$did/follows" params={{did: did}}>Follows</Link> 191 + <Link to="/profile/$did/follows" params={{ did: did }}> 192 + Follows 193 + </Link> 171 194 </div> 172 195 {description && ( 173 196 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> ··· 212 235 } 213 236 214 237 export type ProfilePostsFilter = { 215 - posts: boolean, 216 - replies: boolean, 217 - mediaOnly: boolean, 218 - } 238 + posts: boolean; 239 + replies: boolean; 240 + mediaOnly: boolean; 241 + }; 219 242 export const defaultProfilePostsFilter: ProfilePostsFilter = { 220 243 posts: true, 221 244 replies: true, 222 245 mediaOnly: false, 223 - } 246 + }; 224 247 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); 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; 228 257 229 258 useEffect(() => { 230 259 if (empty) { 231 - toggle("posts") 260 + toggle("posts"); 232 261 } 233 262 }, [empty, toggle]); 234 263 ··· 237 266 <Chip 238 267 state={filters?.posts ?? true} 239 268 text="Posts" 240 - onClick={() => almostEmpty ? null : toggle("posts")} 269 + onClick={() => (almostEmpty ? null : toggle("posts"))} 241 270 /> 242 271 <Chip 243 272 state={filters?.replies ?? true} ··· 258 287 const [filterses, setFilterses] = useAtom(profileChipsAtom); 259 288 const filters = filterses?.[did]; 260 289 const setFilters = (obj: ProfilePostsFilter) => { 261 - setFilterses((prev)=>{ 262 - return{ 290 + setFilterses((prev) => { 291 + return { 263 292 ...prev, 264 - [did]: obj 265 - } 266 - }) 267 - } 268 - useEffect(()=>{ 293 + [did]: obj, 294 + }; 295 + }); 296 + }; 297 + useEffect(() => { 269 298 if (!filters) { 270 299 setFilters(defaultProfilePostsFilter); 271 300 } 272 - }) 301 + }); 273 302 useReusableTabScrollRestore(`Profile` + did); 274 303 const queryClient = useQueryClient(); 275 304 const { ··· 306 335 ); 307 336 308 337 const toggle = (key: keyof ProfilePostsFilter) => { 309 - setFilterses(prev => { 310 - const existing = prev[did] ?? { posts: false, replies: false, mediaOnly: false }; // default 338 + setFilterses((prev) => { 339 + const existing = prev[did] ?? { 340 + posts: false, 341 + replies: false, 342 + mediaOnly: false, 343 + }; // default 311 344 312 345 return { 313 346 ...prev, ··· 805 838 )} 806 839 </> 807 840 ) : ( 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]"> 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 + > 809 851 Edit Profile 810 852 </button> 811 853 )} ··· 822 864 const { data: identity } = useQueryIdentity(targetdidorhandle); 823 865 const [show] = useAtom(enableBitesAtom); 824 866 825 - if (!show) return 867 + if (!show) return; 826 868 827 869 return ( 828 870 <> 829 871 <button 830 - onClick={(e) => { 872 + onClick={async (e) => { 831 873 e.stopPropagation(); 832 - sendBite({ 874 + await sendBite({ 833 875 agent: agent || undefined, 834 876 targetDid: identity?.did, 835 877 }); ··· 842 884 ); 843 885 } 844 886 845 - function sendBite({ 887 + async function sendBite({ 846 888 agent, 847 889 targetDid, 848 890 }: { 849 891 agent?: Agent; 850 892 targetDid?: string; 851 893 }) { 852 - if (!agent?.did || !targetDid) return; 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 + } 853 902 const newRecord = { 854 903 repo: agent.did, 855 904 collection: "net.wafrn.feed.bite", 856 905 rkey: TID.next().toString(), 857 906 record: { 858 907 $type: "net.wafrn.feed.bite", 859 - subject: "at://"+targetDid, 908 + subject: "at://" + targetDid, 860 909 createdAt: new Date().toISOString(), 861 910 }, 862 911 }; 863 912 864 - agent.com.atproto.repo.createRecord(newRecord).catch((err) => { 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) { 865 921 console.error("Bite failed:", err); 866 - }); 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 + } 867 928 } 868 - 869 929 870 930 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 871 931 const { agent } = useAuth();