+10
package-lock.json
+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
+1
package.json
+1
src/auto-imports.d.ts
+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
+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
+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
+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();