+1
-1
README.md
+1
-1
README.md
···
8
8
## running dev and build
9
9
in the `vite.config.ts` file you should change these values
10
10
```ts
11
-
const PROD_URL = "https://reddwarf.whey.party"
11
+
const PROD_URL = "https://reddwarf.app"
12
12
const DEV_URL = "https://local3768forumtest.whey.party"
13
13
```
14
14
the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work
+4
package-lock.json
+4
package-lock.json
···
8
8
"dependencies": {
9
9
"@atproto/api": "^0.16.6",
10
10
"@atproto/oauth-client-browser": "^0.3.33",
11
+
"@radix-ui/react-dialog": "^1.1.15",
11
12
"@radix-ui/react-dropdown-menu": "^2.1.16",
13
+
"@radix-ui/react-hover-card": "^1.1.15",
14
+
"@radix-ui/react-slider": "^1.3.6",
12
15
"@tailwindcss/vite": "^4.0.6",
13
16
"@tanstack/query-sync-storage-persister": "^5.85.6",
14
17
"@tanstack/react-devtools": "^0.2.2",
···
2400
2403
"version": "1.1.15",
2401
2404
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
2402
2405
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
2406
+
"license": "MIT",
2403
2407
"dependencies": {
2404
2408
"@radix-ui/primitive": "1.1.3",
2405
2409
"@radix-ui/react-compose-refs": "1.1.2",
+3
package.json
+3
package.json
···
12
12
"dependencies": {
13
13
"@atproto/api": "^0.16.6",
14
14
"@atproto/oauth-client-browser": "^0.3.33",
15
+
"@radix-ui/react-dialog": "^1.1.15",
15
16
"@radix-ui/react-dropdown-menu": "^2.1.16",
17
+
"@radix-ui/react-hover-card": "^1.1.15",
18
+
"@radix-ui/react-slider": "^1.3.6",
16
19
"@tailwindcss/vite": "^4.0.6",
17
20
"@tanstack/query-sync-storage-persister": "^5.85.6",
18
21
"@tanstack/react-devtools": "^0.2.2",
+225
-111
src/components/Composer.tsx
+225
-111
src/components/Composer.tsx
···
1
-
import { RichText } from "@atproto/api";
1
+
import { AppBskyRichtextFacet, RichText } from "@atproto/api";
2
2
import { useAtom } from "jotai";
3
-
import { useEffect, useState } from "react";
3
+
import { Dialog } from "radix-ui";
4
+
import { useEffect, useRef, useState } from "react";
4
5
5
6
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
7
import { composerAtom } from "~/utils/atoms";
7
8
import { useQueryPost } from "~/utils/useQuery";
8
9
9
10
import { ProfileThing } from "./Login";
11
+
import { Button } from "./radix-m3-rd/Button";
10
12
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
11
13
12
-
const MAX_POST_LENGTH = 300
14
+
const MAX_POST_LENGTH = 300;
13
15
14
16
export function Composer() {
15
17
const [composerState, setComposerState] = useAtom(composerAtom);
18
+
const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false);
16
19
const { agent } = useAuth();
17
20
18
21
const [postText, setPostText] = useState("");
···
31
34
composerState.kind === "reply"
32
35
? composerState.parent
33
36
: composerState.kind === "quote"
34
-
? composerState.subject
35
-
: undefined;
37
+
? composerState.subject
38
+
: undefined;
36
39
37
-
const { data: parentPost, isLoading: isParentLoading } = useQueryPost(parentUri);
40
+
const { data: parentPost, isLoading: isParentLoading } =
41
+
useQueryPost(parentUri);
38
42
39
43
async function handlePost() {
40
44
if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return;
···
46
50
const rt = new RichText({ text: postText });
47
51
await rt.detectFacets(agent);
48
52
53
+
if (rt.facets?.length) {
54
+
rt.facets = rt.facets.filter((item) => {
55
+
if (item.$type !== "app.bsky.richtext.facet") return true;
56
+
if (!item.features?.length) return true;
57
+
58
+
item.features = item.features.filter((feature) => {
59
+
if (feature.$type !== "app.bsky.richtext.facet#mention") return true;
60
+
const did = feature.$type === "app.bsky.richtext.facet#mention" ? (feature as AppBskyRichtextFacet.Mention)?.did : undefined;
61
+
return typeof did === "string" && did.startsWith("did:");
62
+
});
63
+
64
+
return item.features.length > 0;
65
+
});
66
+
}
67
+
49
68
const record: Record<string, unknown> = {
50
69
$type: "app.bsky.feed.post",
51
70
text: rt.text,
···
96
115
}
97
116
}
98
117
99
-
if (composerState.kind === "closed") {
100
-
return null;
101
-
}
102
-
103
118
const getPlaceholder = () => {
104
119
switch (composerState.kind) {
105
120
case "reply":
···
111
126
return "What's happening?!";
112
127
}
113
128
};
114
-
129
+
115
130
const charsLeft = MAX_POST_LENGTH - postText.length;
116
131
const isPostButtonDisabled =
117
-
posting ||
118
-
!postText.trim() ||
119
-
isParentLoading ||
120
-
charsLeft < 0;
132
+
posting || !postText.trim() || isParentLoading || charsLeft < 0;
133
+
134
+
function handleAttemptClose() {
135
+
if (postText.trim() && !posting) {
136
+
setCloseConfirmState(true);
137
+
} else {
138
+
setComposerState({ kind: "closed" });
139
+
}
140
+
}
141
+
142
+
function handleConfirmClose() {
143
+
setComposerState({ kind: "closed" });
144
+
setCloseConfirmState(false);
145
+
setPostText("");
146
+
}
121
147
122
148
return (
123
-
<div className="fixed inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 bg-black/40 dark:bg-black/50">
124
-
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4">
125
-
<div className="flex flex-row justify-between p-2">
126
-
<button
127
-
className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
128
-
onClick={() => !posting && setComposerState({ kind: "closed" })}
129
-
disabled={posting}
130
-
aria-label="Close"
131
-
>
132
-
<svg
133
-
xmlns="http://www.w3.org/2000/svg"
134
-
width="20"
135
-
height="20"
136
-
viewBox="0 0 24 24"
137
-
fill="none"
138
-
stroke="currentColor"
139
-
strokeWidth="2.5"
140
-
strokeLinecap="round"
141
-
strokeLinejoin="round"
142
-
>
143
-
<line x1="18" y1="6" x2="6" y2="18"></line>
144
-
<line x1="6" y1="6" x2="18" y2="18"></line>
145
-
</svg>
146
-
</button>
147
-
<div className="flex-1" />
148
-
<div className="flex items-center gap-4">
149
-
<span className={`text-sm ${charsLeft < 0 ? 'text-red-500' : 'text-gray-500'}`}>
150
-
{charsLeft}
151
-
</span>
152
-
153
-
<button
154
-
className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
155
-
onClick={handlePost}
156
-
disabled={isPostButtonDisabled}
157
-
>
158
-
{posting ? "Posting..." : "Post"}
159
-
</button>
160
-
</div>
161
-
</div>
149
+
<>
150
+
<Dialog.Root
151
+
open={composerState.kind !== "closed"}
152
+
onOpenChange={(open) => {
153
+
if (!open) handleAttemptClose();
154
+
}}
155
+
>
156
+
<Dialog.Portal>
157
+
<Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
162
158
163
-
{postSuccess ? (
164
-
<div className="flex flex-col items-center justify-center py-16">
165
-
<span className="text-gray-500 text-6xl mb-4">โ</span>
166
-
<span className="text-xl font-bold text-black dark:text-white">Posted!</span>
167
-
</div>
168
-
) : (
169
-
<div className="px-4">
170
-
{(composerState.kind === "reply") && (
171
-
<div className="mb-1 -mx-4">
172
-
{isParentLoading ? (
173
-
<div className="text-sm text-gray-500 animate-pulse">
174
-
Loading parent post...
175
-
</div>
176
-
) : parentUri ? (
177
-
<UniversalPostRendererATURILoader atUri={parentUri} bottomReplyLine bottomBorder={false} />
178
-
) : (
179
-
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
180
-
Could not load parent post.
159
+
<Dialog.Content className="fixed overflow-y-auto gutter inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]">
160
+
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4">
161
+
<div className="flex flex-row justify-between p-2">
162
+
<Dialog.Close asChild>
163
+
<button
164
+
className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
165
+
disabled={posting}
166
+
aria-label="Close"
167
+
onClick={handleAttemptClose}
168
+
>
169
+
<svg
170
+
xmlns="http://www.w3.org/2000/svg"
171
+
width="20"
172
+
height="20"
173
+
viewBox="0 0 24 24"
174
+
fill="none"
175
+
stroke="currentColor"
176
+
strokeWidth="2.5"
177
+
strokeLinecap="round"
178
+
strokeLinejoin="round"
179
+
>
180
+
<line x1="18" y1="6" x2="6" y2="18"></line>
181
+
<line x1="6" y1="6" x2="18" y2="18"></line>
182
+
</svg>
183
+
</button>
184
+
</Dialog.Close>
185
+
186
+
<div className="flex-1" />
187
+
<div className="flex items-center gap-4">
188
+
<span
189
+
className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`}
190
+
>
191
+
{charsLeft}
192
+
</span>
193
+
<Button
194
+
onClick={handlePost}
195
+
disabled={isPostButtonDisabled}
196
+
>
197
+
{posting ? "Posting..." : "Post"}
198
+
</Button>
199
+
</div>
200
+
</div>
201
+
202
+
{postSuccess ? (
203
+
<div className="flex flex-col items-center justify-center py-16">
204
+
<span className="text-gray-500 text-6xl mb-4">โ</span>
205
+
<span className="text-xl font-bold text-black dark:text-white">
206
+
Posted!
207
+
</span>
208
+
</div>
209
+
) : (
210
+
<div className="px-4">
211
+
{composerState.kind === "reply" && (
212
+
<div className="mb-1 -mx-4">
213
+
{isParentLoading ? (
214
+
<div className="text-sm text-gray-500 animate-pulse">
215
+
Loading parent post...
216
+
</div>
217
+
) : parentUri ? (
218
+
<UniversalPostRendererATURILoader
219
+
atUri={parentUri}
220
+
bottomReplyLine
221
+
bottomBorder={false}
222
+
/>
223
+
) : (
224
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
225
+
Could not load parent post.
226
+
</div>
227
+
)}
228
+
</div>
229
+
)}
230
+
231
+
<div className="flex w-full gap-1 flex-col">
232
+
<ProfileThing agent={agent} large />
233
+
<div className="flex pl-[50px]">
234
+
<AutoGrowTextarea
235
+
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2"
236
+
rows={5}
237
+
placeholder={getPlaceholder()}
238
+
value={postText}
239
+
onChange={(e) => setPostText(e.target.value)}
240
+
disabled={posting}
241
+
autoFocus
242
+
/>
243
+
</div>
181
244
</div>
182
-
)}
245
+
246
+
{composerState.kind === "quote" && (
247
+
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
248
+
{isParentLoading ? (
249
+
<div className="text-sm text-gray-500 animate-pulse">
250
+
Loading parent post...
251
+
</div>
252
+
) : parentUri ? (
253
+
<UniversalPostRendererATURILoader
254
+
atUri={parentUri}
255
+
isQuote
256
+
/>
257
+
) : (
258
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
259
+
Could not load parent post.
260
+
</div>
261
+
)}
262
+
</div>
263
+
)}
264
+
265
+
{postError && (
266
+
<div className="text-red-500 text-sm my-2 text-center">
267
+
{postError}
268
+
</div>
269
+
)}
270
+
</div>
271
+
)}
272
+
</div>
273
+
</Dialog.Content>
274
+
</Dialog.Portal>
275
+
</Dialog.Root>
276
+
277
+
{/* Close confirmation dialog */}
278
+
<Dialog.Root open={closeConfirmState} onOpenChange={setCloseConfirmState}>
279
+
<Dialog.Portal>
280
+
281
+
<Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
282
+
283
+
<Dialog.Content className="fixed gutter inset-0 z-50 flex items-start justify-center pt-30 sm:pt-40">
284
+
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-md relative mx-4 py-6">
285
+
<div className="text-xl mb-4 text-center">
286
+
Discard your post?
183
287
</div>
184
-
)}
185
-
186
-
<div className="flex w-full gap-1 flex-col">
187
-
<ProfileThing agent={agent} large/>
188
-
<div className="flex pl-[50px]">
189
-
<textarea
190
-
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white"
191
-
rows={5}
192
-
placeholder={getPlaceholder()}
193
-
value={postText}
194
-
onChange={(e) => setPostText(e.target.value)}
195
-
disabled={posting}
196
-
autoFocus
197
-
/>
288
+
<div className="text-md mb-4 text-center">
289
+
You will lose your draft
198
290
</div>
199
-
</div>
200
-
{(composerState.kind === "quote") && (
201
-
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
202
-
{isParentLoading ? (
203
-
<div className="text-sm text-gray-500 animate-pulse">
204
-
Loading parent post...
205
-
</div>
206
-
) : parentUri ? (
207
-
<UniversalPostRendererATURILoader atUri={parentUri} isQuote />
208
-
) : (
209
-
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
210
-
Could not load parent post.
211
-
</div>
212
-
)}
291
+
<div className="flex justify-end gap-2 px-6">
292
+
<Button
293
+
onClick={handleConfirmClose}
294
+
>
295
+
Discard
296
+
</Button>
297
+
<Button
298
+
variant={"outlined"}
299
+
onClick={() => setCloseConfirmState(false)}
300
+
>
301
+
Cancel
302
+
</Button>
213
303
</div>
214
-
)}
304
+
</div>
305
+
</Dialog.Content>
306
+
</Dialog.Portal>
307
+
</Dialog.Root>
308
+
</>
309
+
);
310
+
}
215
311
216
-
{postError && (
217
-
<div className="text-red-500 text-sm my-2 text-center">{postError}</div>
218
-
)}
219
-
220
-
</div>
221
-
)}
222
-
</div>
223
-
</div>
312
+
function AutoGrowTextarea({
313
+
value,
314
+
className,
315
+
onChange,
316
+
...props
317
+
}: React.DetailedHTMLProps<
318
+
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
319
+
HTMLTextAreaElement
320
+
>) {
321
+
const ref = useRef<HTMLTextAreaElement>(null);
322
+
323
+
useEffect(() => {
324
+
const el = ref.current;
325
+
if (!el) return;
326
+
el.style.height = "auto";
327
+
el.style.height = el.scrollHeight + "px";
328
+
}, [value]);
329
+
330
+
return (
331
+
<textarea
332
+
ref={ref}
333
+
className={className}
334
+
value={value}
335
+
onChange={onChange}
336
+
{...props}
337
+
/>
224
338
);
225
-
}
339
+
}
+150
src/components/Import.tsx
+150
src/components/Import.tsx
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { useNavigate, type UseNavigateResult } from "@tanstack/react-router";
3
+
import { useState } from "react";
4
+
5
+
/**
6
+
* Basically the best equivalent to Search that i can do
7
+
*/
8
+
export function Import() {
9
+
const [textInput, setTextInput] = useState<string | undefined>();
10
+
const navigate = useNavigate();
11
+
12
+
const handleEnter = () => {
13
+
if (!textInput) return;
14
+
handleImport({
15
+
text: textInput,
16
+
navigate,
17
+
});
18
+
};
19
+
20
+
return (
21
+
<div className="w-full relative">
22
+
<IconMaterialSymbolsSearch className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" />
23
+
24
+
<input
25
+
type="text"
26
+
placeholder="Import..."
27
+
value={textInput}
28
+
onChange={(e) => setTextInput(e.target.value)}
29
+
onKeyDown={(e) => {
30
+
if (e.key === "Enter") handleEnter();
31
+
}}
32
+
className="w-full h-12 pl-12 pr-4 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-500 box-border transition"
33
+
/>
34
+
</div>
35
+
);
36
+
}
37
+
38
+
function handleImport({
39
+
text,
40
+
navigate,
41
+
}: {
42
+
text: string;
43
+
navigate: UseNavigateResult<string>;
44
+
}) {
45
+
const trimmed = text.trim();
46
+
// parse text
47
+
/**
48
+
* text might be
49
+
* 1. bsky dot app url (reddwarf link segments might be uri encoded,)
50
+
* 2. aturi
51
+
* 3. plain handle
52
+
* 4. plain did
53
+
*/
54
+
55
+
// 1. Check if itโs a URL
56
+
try {
57
+
const url = new URL(text);
58
+
const knownHosts = [
59
+
"bsky.app",
60
+
"social.daniela.lol",
61
+
"deer.social",
62
+
"reddwarf.whey.party",
63
+
"reddwarf.app",
64
+
"main.bsky.dev",
65
+
"catsky.social",
66
+
"blacksky.community",
67
+
"red-dwarf-social-app.whey.party",
68
+
"zeppelin.social",
69
+
];
70
+
if (knownHosts.includes(url.hostname)) {
71
+
// parse path to get URI or handle
72
+
const path = decodeURIComponent(url.pathname.slice(1)); // remove leading /
73
+
console.log("BSky URL path:", path);
74
+
navigate({
75
+
to: `/${path}`,
76
+
});
77
+
return;
78
+
}
79
+
} catch {
80
+
// not a URL, continue
81
+
}
82
+
83
+
// 2. Check if text looks like an at-uri
84
+
try {
85
+
if (text.startsWith("at://")) {
86
+
console.log("AT URI detected:", text);
87
+
const aturi = new AtUri(text);
88
+
switch (aturi.collection) {
89
+
case "app.bsky.feed.post": {
90
+
navigate({
91
+
to: "/profile/$did/post/$rkey",
92
+
params: {
93
+
did: aturi.host,
94
+
rkey: aturi.rkey,
95
+
},
96
+
});
97
+
return;
98
+
}
99
+
case "app.bsky.actor.profile": {
100
+
navigate({
101
+
to: "/profile/$did",
102
+
params: {
103
+
did: aturi.host,
104
+
},
105
+
});
106
+
return;
107
+
}
108
+
// todo add more handlers as more routes are added. like feeds, lists, etc etc thanks!
109
+
default: {
110
+
// continue
111
+
}
112
+
}
113
+
}
114
+
} catch {
115
+
// continue
116
+
}
117
+
118
+
// 3. Plain handle (starts with @)
119
+
try {
120
+
if (text.startsWith("@")) {
121
+
const handle = text.slice(1);
122
+
console.log("Handle detected:", handle);
123
+
navigate({ to: "/profile/$did", params: { did: handle } });
124
+
return;
125
+
}
126
+
} catch {
127
+
// continue
128
+
}
129
+
130
+
// 4. Plain DID (starts with did:)
131
+
try {
132
+
if (text.startsWith("did:")) {
133
+
console.log("did detected:", text);
134
+
navigate({ to: "/profile/$did", params: { did: text } });
135
+
return;
136
+
}
137
+
} catch {
138
+
// continue
139
+
}
140
+
141
+
// if all else fails
142
+
143
+
// try {
144
+
// // probably a user?
145
+
// navigate({ to: "/profile/$did", params: { did: text } });
146
+
// return;
147
+
// } catch {
148
+
// // continue
149
+
// }
150
+
}
+32
-6
src/components/InfiniteCustomFeed.tsx
+32
-6
src/components/InfiniteCustomFeed.tsx
···
1
+
import { useQueryClient } from "@tanstack/react-query";
1
2
import * as React from "react";
2
3
3
4
//import { useInView } from "react-intersection-observer";
···
37
38
isFetchingNextPage,
38
39
refetch,
39
40
isRefetching,
41
+
queryKey,
40
42
} = useInfiniteQueryFeedSkeleton({
41
43
feedUri: feedUri,
42
44
agent: agent ?? undefined,
···
44
46
pdsUrl: pdsUrl,
45
47
feedServiceDid: feedServiceDid,
46
48
});
49
+
const queryClient = useQueryClient();
50
+
47
51
48
52
const handleRefresh = () => {
53
+
queryClient.removeQueries({queryKey: queryKey});
54
+
//queryClient.invalidateQueries(["infinite-feed", feedUri] as const);
49
55
refetch();
50
56
};
51
57
58
+
const allPosts = React.useMemo(() => {
59
+
const flattenedPosts = data?.pages.flatMap((page) => page?.feed) ?? [];
60
+
61
+
const seenUris = new Set<string>();
62
+
63
+
return flattenedPosts.filter((item) => {
64
+
if (!item?.post) return false;
65
+
66
+
if (seenUris.has(item.post)) {
67
+
return false;
68
+
}
69
+
70
+
seenUris.add(item.post);
71
+
72
+
return true;
73
+
});
74
+
}, [data]);
75
+
52
76
//const { ref, inView } = useInView();
53
77
54
78
// React.useEffect(() => {
···
67
91
);
68
92
}
69
93
70
-
const allPosts =
71
-
data?.pages.flatMap((page) => {
72
-
if (page) return page.feed;
73
-
}) ?? [];
94
+
// const allPosts =
95
+
// data?.pages.flatMap((page) => {
96
+
// if (page) return page.feed;
97
+
// }) ?? [];
74
98
75
99
if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) {
76
100
return (
···
116
140
className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed"
117
141
aria-label="Refresh feed"
118
142
>
119
-
<RefreshIcon className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} />
143
+
<RefreshIcon
144
+
className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`}
145
+
/>
120
146
</button>
121
147
</>
122
148
);
···
139
165
d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"
140
166
></path>
141
167
</svg>
142
-
);
168
+
);
+70
-25
src/components/Login.tsx
+70
-25
src/components/Login.tsx
···
1
1
// src/components/Login.tsx
2
2
import AtpAgent, { Agent } from "@atproto/api";
3
+
import { useAtom } from "jotai";
3
4
import React, { useEffect, useRef, useState } from "react";
4
5
5
6
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
+
import { imgCDNAtom } from "~/utils/atoms";
6
8
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
9
+
10
+
import { Button } from "./radix-m3-rd/Button";
7
11
8
12
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
9
13
export default function Login({
···
22
26
className={
23
27
compact
24
28
? "flex items-center justify-center p-1"
25
-
: "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]"
29
+
: "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-4 mx-4 flex justify-center items-center h-[280px]"
26
30
}
27
31
>
28
32
<span
···
41
45
// Large view
42
46
if (!compact) {
43
47
return (
44
-
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4">
48
+
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4">
45
49
<div className="flex flex-col items-center justify-center text-center">
46
50
<p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100">
47
51
You are logged in!
48
52
</p>
49
53
<ProfileThing agent={agent} large />
50
-
<button
54
+
<Button
51
55
onClick={logout}
52
-
className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded-full px-6 py-2 font-semibold text-base transition-colors"
56
+
className="mt-4"
53
57
>
54
58
Log out
55
-
</button>
59
+
</Button>
56
60
</div>
57
61
</div>
58
62
);
···
75
79
if (!compact) {
76
80
// Large view renders the form directly in the card
77
81
return (
78
-
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4">
82
+
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4">
79
83
<UnifiedLoginForm />
80
84
</div>
81
85
);
···
161
165
onClick={onClick}
162
166
className={`px-4 py-2 text-sm font-medium transition-colors rounded-full flex-1 ${
163
167
active
164
-
? "text-gray-950 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500"
168
+
? "text-gray-50 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500"
165
169
: "text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200"
166
170
}`}
167
171
>
···
190
194
<p className="text-xs text-gray-500 dark:text-gray-400">
191
195
Sign in with AT. Your password is never shared.
192
196
</p>
193
-
<input
197
+
{/* <input
194
198
type="text"
195
199
placeholder="handle.bsky.social"
196
200
value={handle}
197
201
onChange={(e) => setHandle(e.target.value)}
198
202
className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
199
-
/>
200
-
<button
201
-
type="submit"
202
-
className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors"
203
-
>
204
-
Log in
205
-
</button>
203
+
/> */}
204
+
<div className="flex flex-col gap-3">
205
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
206
+
<input
207
+
type="text"
208
+
placeholder=" "
209
+
value={handle}
210
+
onChange={(e) => setHandle(e.target.value)}
211
+
/>
212
+
<label>AT Handle</label>
213
+
</div>
214
+
<button
215
+
type="submit"
216
+
className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors"
217
+
>
218
+
Log in
219
+
</button>
220
+
</div>
206
221
</form>
207
222
);
208
223
};
···
235
250
<p className="text-xs text-red-500 dark:text-red-400">
236
251
Warning: Less secure. Use an App Password.
237
252
</p>
238
-
<input
253
+
{/* <input
239
254
type="text"
240
255
placeholder="handle.bsky.social"
241
256
value={user}
···
257
272
value={serviceURL}
258
273
onChange={(e) => setServiceURL(e.target.value)}
259
274
className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
260
-
/>
275
+
/> */}
276
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
277
+
<input
278
+
type="text"
279
+
placeholder=" "
280
+
value={user}
281
+
onChange={(e) => setUser(e.target.value)}
282
+
/>
283
+
<label>AT Handle</label>
284
+
</div>
285
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
286
+
<input
287
+
type="text"
288
+
placeholder=" "
289
+
value={password}
290
+
onChange={(e) => setPassword(e.target.value)}
291
+
/>
292
+
<label>App Password</label>
293
+
</div>
294
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
295
+
<input
296
+
type="text"
297
+
placeholder=" "
298
+
value={serviceURL}
299
+
onChange={(e) => setServiceURL(e.target.value)}
300
+
/>
301
+
<label>PDS</label>
302
+
</div>
261
303
{error && <p className="text-xs text-red-500">{error}</p>}
262
304
<button
263
305
type="submit"
···
271
313
272
314
// --- Profile Component (now supports a `large` prop for styling) ---
273
315
export const ProfileThing = ({
274
-
agent: _unused,
316
+
agent,
275
317
large = false,
276
318
}: {
277
-
agent?: Agent | null;
319
+
agent: Agent | null;
278
320
large?: boolean;
279
321
}) => {
280
-
const { agent } = useAuth();
281
-
const did = ((agent as AtpAgent).session?.did ?? (agent as AtpAgent)?.assertDid ?? agent?.did) as
282
-
| string
283
-
| undefined;
322
+
const did = ((agent as AtpAgent)?.session?.did ??
323
+
(agent as AtpAgent)?.assertDid ??
324
+
agent?.did) as string | undefined;
284
325
const { data: identity } = useQueryIdentity(did);
285
-
const { data: profiledata } = useQueryProfile(`at://${did}/app.bsky.actor.profile/self`);
326
+
const { data: profiledata } = useQueryProfile(
327
+
`at://${did}/app.bsky.actor.profile/self`
328
+
);
286
329
const profile = profiledata?.value;
330
+
331
+
const [imgcdn] = useAtom(imgCDNAtom)
287
332
288
333
function getAvatarUrl(p: typeof profile) {
289
334
const link = p?.avatar?.ref?.["$link"];
290
335
if (!link || !did) return null;
291
-
return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`;
336
+
return `https://${imgcdn}/img/avatar/plain/${did}/${link}@jpeg`;
292
337
}
293
338
294
339
if (!profiledata) {
+6
src/components/Star.tsx
+6
src/components/Star.tsx
···
1
+
import type { SVGProps } from 'react';
2
+
import React from 'react';
3
+
4
+
export function FluentEmojiHighContrastGlowingStar(props: SVGProps<SVGSVGElement>) {
5
+
return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} viewBox="0 0 32 32" {...props}><g fill="currentColor"><path d="m28.979 17.003l-3.108.214c-.834.06-1.178 1.079-.542 1.608l2.388 1.955c.521.428 1.314.204 1.523-.428l.709-2.127c.219-.632-.292-1.273-.97-1.222M21.75 2.691l-.72 2.9c-.2.78.66 1.41 1.34.98l2.54-1.58c.55-.34.58-1.14.05-1.52l-1.78-1.29a.912.912 0 0 0-1.43.51M6.43 4.995l2.53 1.58c.68.43 1.54-.19 1.35-.98l-.72-2.9a.92.92 0 0 0-1.43-.52l-1.78 1.29c-.53.4-.5 1.19.05 1.53M4.185 20.713l2.29-1.92c.62-.52.29-1.53-.51-1.58l-2.98-.21a.92.92 0 0 0-.94 1.2l.68 2.09c.2.62.97.84 1.46.42m13.61 7.292l-1.12-2.77c-.3-.75-1.36-.75-1.66 0l-1.12 2.77c-.24.6.2 1.26.85 1.26h2.2a.92.92 0 0 0 .85-1.26"></path><path d="m17.565 3.324l1.726 3.72c.326.694.967 1.18 1.717 1.29l4.056.624c1.835.278 2.575 2.53 1.293 3.859L23.268 16a2.28 2.28 0 0 0-.612 1.964l.71 4.374c.307 1.885-1.687 3.293-3.354 2.37l-3.405-1.894a2.25 2.25 0 0 0-2.21 0l-3.404 1.895c-1.668.922-3.661-.486-3.355-2.37l.71-4.375A2.28 2.28 0 0 0 7.736 16l-3.088-3.184c-1.293-1.34-.543-3.581 1.293-3.859l4.055-.625a2.3 2.3 0 0 0 1.717-1.29l1.727-3.719c.819-1.765 3.306-1.765 4.124 0"></path></g></svg>);
6
+
}
+154
-68
src/components/UniversalPostRenderer.tsx
+154
-68
src/components/UniversalPostRenderer.tsx
···
2
2
import DOMPurify from "dompurify";
3
3
import { useAtom } from "jotai";
4
4
import { DropdownMenu } from "radix-ui";
5
+
import { HoverCard } from "radix-ui";
5
6
import * as React from "react";
6
7
import { type SVGProps } from "react";
7
8
8
-
import { composerAtom, likedPostsAtom } from "~/utils/atoms";
9
+
import {
10
+
composerAtom,
11
+
constellationURLAtom,
12
+
imgCDNAtom,
13
+
likedPostsAtom,
14
+
} from "~/utils/atoms";
9
15
import { useHydratedEmbed } from "~/utils/useHydrated";
10
16
import {
11
17
useQueryConstellation,
···
150
156
maxReplies,
151
157
isQuote,
152
158
}: UniversalPostRendererATURILoaderProps) {
159
+
// todo remove this once tree rendering is implemented, use a prop like isTree
160
+
const TEMPLINEAR = true;
153
161
// /*mass comment*/ console.log("atUri", atUri);
154
162
//const { get, set } = usePersistentStore();
155
163
//const [record, setRecord] = React.useState<any>(null);
···
401
409
// path: ".reply.parent.uri",
402
410
// });
403
411
412
+
const [constellationurl] = useAtom(constellationURLAtom);
413
+
404
414
const infinitequeryresults = useInfiniteQuery({
405
415
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
406
416
{
417
+
constellation: constellationurl,
407
418
method: "/links",
408
419
target: atUri,
409
420
collection: "app.bsky.feed.post",
···
422
433
423
434
// auto-fetch all pages
424
435
useEffect(() => {
425
-
if (!maxReplies || isQuote) return;
436
+
if (!maxReplies || isQuote || TEMPLINEAR) return;
426
437
if (
427
438
infinitequeryresults.hasNextPage &&
428
439
!infinitequeryresults.isFetchingNextPage
···
430
441
console.log("Fetching the next page...");
431
442
infinitequeryresults.fetchNextPage();
432
443
}
433
-
}, [infinitequeryresults]);
444
+
}, [TEMPLINEAR, infinitequeryresults, isQuote, maxReplies]);
434
445
435
446
const replyAturis = repliesData
436
447
? repliesData.pages.flatMap((page) =>
···
507
518
? true
508
519
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
509
520
? false
510
-
: bottomReplyLine
521
+
: (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine
511
522
}
512
523
topReplyLine={topReplyLine}
513
524
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
529
540
maxReplies={maxReplies}
530
541
isQuote={isQuote}
531
542
/>
543
+
<>
544
+
{(maxReplies && maxReplies === 0 && replies && replies > 0) ? (
545
+
<>
546
+
{/* <div>hello</div> */}
547
+
<MoreReplies atUri={atUri} />
548
+
</>
549
+
) : (<></>)}
550
+
</>
532
551
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
533
552
<>
534
553
{/* <span>hello {maxReplies}</span> */}
···
553
572
maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
554
573
}
555
574
/>
556
-
{maxReplies && maxReplies - 1 === 0 && replies && replies > 0 && (
557
-
<MoreReplies atUri={oldestOpsReplyElseNewestNonOpsReply} />
558
-
)}
559
575
</>
560
576
)}
561
577
</>
···
596
612
);
597
613
}
598
614
599
-
function getAvatarUrl(opProfile: any, did: string) {
615
+
function getAvatarUrl(opProfile: any, did: string, cdn: string) {
600
616
const link = opProfile?.value?.avatar?.ref?.["$link"];
601
617
if (!link) return null;
602
-
return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`;
618
+
return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`;
603
619
}
604
620
605
621
export function UniversalPostRendererRawRecordShim({
···
720
736
error: embedError,
721
737
} = useHydratedEmbed(postRecord?.value?.embed, resolved?.did);
722
738
739
+
const [imgcdn] = useAtom(imgCDNAtom);
740
+
723
741
const parsedaturi = new AtUri(aturi); //parseAtUri(aturi);
724
742
743
+
const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>(
744
+
() => ({
745
+
did: resolved?.did || "",
746
+
handle: resolved?.handle || "",
747
+
displayName: profileRecord?.value?.displayName || "",
748
+
avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "",
749
+
viewer: undefined,
750
+
labels: profileRecord?.labels || undefined,
751
+
verification: undefined,
752
+
}),
753
+
[imgcdn, profileRecord, resolved?.did, resolved?.handle]
754
+
);
755
+
756
+
const fakeprofileviewdetailed =
757
+
React.useMemo<AppBskyActorDefs.ProfileViewDetailed>(
758
+
() => ({
759
+
...fakeprofileviewbasic,
760
+
$type: "app.bsky.actor.defs#profileViewDetailed",
761
+
description: profileRecord?.value?.description || undefined,
762
+
}),
763
+
[fakeprofileviewbasic, profileRecord?.value?.description]
764
+
);
765
+
725
766
const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(
726
767
() => ({
727
768
$type: "app.bsky.feed.defs#postView",
728
769
uri: aturi,
729
770
cid: postRecord?.cid || "",
730
-
author: {
731
-
did: resolved?.did || "",
732
-
handle: resolved?.handle || "",
733
-
displayName: profileRecord?.value?.displayName || "",
734
-
avatar: getAvatarUrl(profileRecord, resolved?.did) || "",
735
-
viewer: undefined,
736
-
labels: profileRecord?.labels || undefined,
737
-
verification: undefined,
738
-
},
771
+
author: fakeprofileviewbasic,
739
772
record: postRecord?.value || {},
740
773
embed: hydratedEmbed ?? undefined,
741
774
replyCount: repliesCount ?? 0,
···
752
785
postRecord?.cid,
753
786
postRecord?.value,
754
787
postRecord?.labels,
755
-
resolved?.did,
756
-
resolved?.handle,
757
-
profileRecord,
788
+
fakeprofileviewbasic,
758
789
hydratedEmbed,
759
790
repliesCount,
760
791
repostsCount,
···
831
862
}
832
863
}}
833
864
post={fakepost}
865
+
uprrrsauthor={fakeprofileviewdetailed}
834
866
salt={aturi}
835
867
bottomReplyLine={bottomReplyLine}
836
868
topReplyLine={topReplyLine}
···
883
915
{...props}
884
916
>
885
917
<path
886
-
fill="oklch(0.704 0.05 28)"
918
+
fill="var(--color-gray-400)"
887
919
d="M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12z"
888
920
></path>
889
921
</svg>
···
900
932
{...props}
901
933
>
902
934
<path
903
-
fill="oklch(0.704 0.05 28)"
935
+
fill="var(--color-gray-400)"
904
936
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
905
937
></path>
906
938
</svg>
···
951
983
{...props}
952
984
>
953
985
<path
954
-
fill="oklch(0.704 0.05 28)"
986
+
fill="var(--color-gray-400)"
955
987
d="m12.1 18.55l-.1.1l-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5c0 2.89-3.14 5.74-7.9 10.05M16.5 3c-1.74 0-3.41.81-4.5 2.08C10.91 3.81 9.24 3 7.5 3C4.42 3 2 5.41 2 8.5c0 3.77 3.4 6.86 8.55 11.53L12 21.35l1.45-1.32C18.6 15.36 22 12.27 22 8.5C22 5.41 19.58 3 16.5 3"
956
988
></path>
957
989
</svg>
···
968
1000
{...props}
969
1001
>
970
1002
<path
971
-
fill="oklch(0.704 0.05 28)"
1003
+
fill="var(--color-gray-400)"
972
1004
d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3a3 3 0 0 0-3-3a3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08"
973
1005
></path>
974
1006
</svg>
···
985
1017
{...props}
986
1018
>
987
1019
<path
988
-
fill="oklch(0.704 0.05 28)"
1020
+
fill="var(--color-gray-400)"
989
1021
d="M16 12a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"
990
1022
></path>
991
1023
</svg>
···
1002
1034
{...props}
1003
1035
>
1004
1036
<path
1005
-
fill="oklch(0.704 0.05 28)"
1037
+
fill="var(--color-gray-400)"
1006
1038
d="M17.9 17.39c-.26-.8-1.01-1.39-1.9-1.39h-1v-3a1 1 0 0 0-1-1H8v-2h2a1 1 0 0 0 1-1V7h2a2 2 0 0 0 2-2v-.41a7.984 7.984 0 0 1 2.9 12.8M11 19.93c-3.95-.49-7-3.85-7-7.93c0-.62.08-1.22.21-1.79L9 15v1a2 2 0 0 0 2 2m1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"
1007
1039
></path>
1008
1040
</svg>
···
1036
1068
{...props}
1037
1069
>
1038
1070
<path
1039
-
fill="oklch(0.704 0.05 28)"
1071
+
fill="var(--color-gray-400)"
1040
1072
d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11"
1041
1073
></path>
1042
1074
</svg>
···
1090
1122
{...props}
1091
1123
>
1092
1124
<path
1093
-
fill="oklch(0.704 0.05 28)"
1125
+
fill="var(--color-gray-400)"
1094
1126
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
1095
1127
></path>
1096
1128
</svg>
···
1107
1139
{...props}
1108
1140
>
1109
1141
<path
1110
-
fill="oklch(0.704 0.05 28)"
1142
+
fill="var(--color-gray-400)"
1111
1143
d="M6 5.75L10.25 10H7v6h6.5l2 2H7a2 2 0 0 1-2-2v-6H1.75zm12 12.5L13.75 14H17V8h-6.5l-2-2H17a2 2 0 0 1 2 2v6h3.25z"
1112
1144
></path>
1113
1145
</svg>
···
1135
1167
//import Masonry from "@mui/lab/Masonry";
1136
1168
import {
1137
1169
type $Typed,
1170
+
AppBskyActorDefs,
1138
1171
AppBskyEmbedDefs,
1139
1172
AppBskyEmbedExternal,
1140
1173
AppBskyEmbedImages,
···
1164
1197
1165
1198
import defaultpfp from "~/../public/favicon.png";
1166
1199
import { useAuth } from "~/providers/UnifiedAuthProvider";
1200
+
import { FollowButton, Mutual } from "~/routes/profile.$did";
1167
1201
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1168
1202
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1169
1203
// import type {
···
1272
1306
1273
1307
function UniversalPostRenderer({
1274
1308
post,
1309
+
uprrrsauthor,
1275
1310
//setMainItem,
1276
1311
//isMainItem,
1277
1312
onPostClick,
···
1296
1331
maxReplies,
1297
1332
}: {
1298
1333
post: PostView;
1334
+
uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed;
1299
1335
// optional for now because i havent ported every use to this yet
1300
1336
// setMainItem?: React.Dispatch<
1301
1337
// React.SetStateAction<AppBskyFeedDefs.FeedViewPost>
···
1479
1515
className="bg-gray-500 dark:bg-gray-400"
1480
1516
/>
1481
1517
)}
1482
-
<div
1483
-
style={{
1484
-
position: "absolute",
1485
-
//top: isRepost ? "calc(16px + 1rem)" : 16,
1486
-
//left: 16,
1487
-
zIndex: 1,
1488
-
top: isRepost
1489
-
? "calc(16px + 1rem)"
1490
-
: isQuote
1491
-
? 12
1492
-
: topReplyLine
1493
-
? 8
1494
-
: 16,
1495
-
left: isQuote ? 12 : 16,
1496
-
}}
1497
-
onClick={onProfileClick}
1498
-
>
1499
-
<img
1500
-
src={post.author.avatar || defaultpfp}
1501
-
alt="avatar"
1502
-
// transition={{
1503
-
// type: "spring",
1504
-
// stiffness: 260,
1505
-
// damping: 20,
1506
-
// }}
1507
-
style={{
1508
-
borderRadius: "50%",
1509
-
marginRight: 12,
1510
-
objectFit: "cover",
1511
-
//background: theme.border,
1512
-
//border: `1px solid ${theme.border}`,
1513
-
width: isQuote ? 16 : 42,
1514
-
height: isQuote ? 16 : 42,
1515
-
}}
1516
-
className="border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1517
-
/>
1518
-
</div>
1518
+
<HoverCard.Root>
1519
+
<HoverCard.Trigger asChild>
1520
+
<div
1521
+
className={`absolute`}
1522
+
style={{
1523
+
top: isRepost
1524
+
? "calc(16px + 1rem)"
1525
+
: isQuote
1526
+
? 12
1527
+
: topReplyLine
1528
+
? 8
1529
+
: 16,
1530
+
left: isQuote ? 12 : 16,
1531
+
}}
1532
+
onClick={onProfileClick}
1533
+
>
1534
+
<img
1535
+
src={post.author.avatar || defaultpfp}
1536
+
alt="avatar"
1537
+
className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
1538
+
style={{
1539
+
width: isQuote ? 16 : 42,
1540
+
height: isQuote ? 16 : 42,
1541
+
}}
1542
+
/>
1543
+
</div>
1544
+
</HoverCard.Trigger>
1545
+
<HoverCard.Portal>
1546
+
<HoverCard.Content
1547
+
className="rounded-md p-4 w-72 bg-gray-50 dark:bg-gray-900 shadow-lg border border-gray-300 dark:border-gray-800 animate-slide-fade z-50"
1548
+
side={"bottom"}
1549
+
sideOffset={5}
1550
+
onClick={onProfileClick}
1551
+
>
1552
+
<div className="flex flex-col gap-2">
1553
+
<div className="flex flex-row">
1554
+
<img
1555
+
src={post.author.avatar || defaultpfp}
1556
+
alt="avatar"
1557
+
className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1558
+
/>
1559
+
<div className=" flex-1 flex flex-row align-middle justify-end">
1560
+
<div className=" flex flex-col justify-start">
1561
+
<FollowButton targetdidorhandle={post.author.did} />
1562
+
</div>
1563
+
</div>
1564
+
</div>
1565
+
<div className="flex flex-col gap-3">
1566
+
<div>
1567
+
<div className="text-gray-900 dark:text-gray-100 font-medium text-md">
1568
+
{post.author.displayName || post.author.handle}{" "}
1569
+
</div>
1570
+
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1571
+
<Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "}
1572
+
</div>
1573
+
</div>
1574
+
{uprrrsauthor?.description && (
1575
+
<div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3">
1576
+
{uprrrsauthor.description}
1577
+
</div>
1578
+
)}
1579
+
{/* <div className="flex gap-4">
1580
+
<div className="flex gap-1">
1581
+
<div className="font-medium text-gray-900 dark:text-gray-100">
1582
+
0
1583
+
</div>
1584
+
<div className="text-gray-500 dark:text-gray-400">
1585
+
Following
1586
+
</div>
1587
+
</div>
1588
+
<div className="flex gap-1">
1589
+
<div className="font-medium text-gray-900 dark:text-gray-100">
1590
+
2,900
1591
+
</div>
1592
+
<div className="text-gray-500 dark:text-gray-400">
1593
+
Followers
1594
+
</div>
1595
+
</div>
1596
+
</div> */}
1597
+
</div>
1598
+
</div>
1599
+
1600
+
{/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */}
1601
+
</HoverCard.Content>
1602
+
</HoverCard.Portal>
1603
+
</HoverCard.Root>
1604
+
1519
1605
<div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}>
1520
1606
<div
1521
1607
style={{
···
2571
2657
return { start, end, feature: f.features[0] };
2572
2658
});
2573
2659
}
2574
-
function renderTextWithFacets({
2660
+
export function renderTextWithFacets({
2575
2661
text,
2576
2662
facets,
2577
2663
navigate,
+59
src/components/radix-m3-rd/Button.tsx
+59
src/components/radix-m3-rd/Button.tsx
···
1
+
import { Slot } from "@radix-ui/react-slot";
2
+
import clsx from "clsx";
3
+
import * as React from "react";
4
+
5
+
export type ButtonVariant = "filled" | "outlined" | "text" | "secondary";
6
+
export type ButtonSize = "sm" | "md" | "lg";
7
+
8
+
const variantClasses: Record<ButtonVariant, string> = {
9
+
filled:
10
+
"bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500",
11
+
secondary:
12
+
"bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500",
13
+
outlined:
14
+
"border border-gray-800 text-gray-800 hover:bg-gray-100 dark:border-gray-200 dark:text-gray-200 dark:hover:bg-gray-800/10",
15
+
text: "bg-transparent text-gray-800 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800/10",
16
+
};
17
+
18
+
const sizeClasses: Record<ButtonSize, string> = {
19
+
sm: "px-3 py-1.5 text-sm",
20
+
md: "px-4 py-2 text-base",
21
+
lg: "px-6 py-3 text-lg",
22
+
};
23
+
24
+
export function Button({
25
+
variant = "filled",
26
+
size = "md",
27
+
asChild = false,
28
+
ref,
29
+
className,
30
+
children,
31
+
...props
32
+
}: {
33
+
variant?: ButtonVariant;
34
+
size?: ButtonSize;
35
+
asChild?: boolean;
36
+
className?: string;
37
+
children?: React.ReactNode;
38
+
ref?: React.Ref<HTMLButtonElement>;
39
+
} & React.ComponentPropsWithoutRef<"button">) {
40
+
const Comp = asChild ? Slot : "button";
41
+
42
+
return (
43
+
<Comp
44
+
ref={ref}
45
+
className={clsx(
46
+
//focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-gray-500 dark:focus:ring-gray-300
47
+
"inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
48
+
variantClasses[variant],
49
+
sizeClasses[size],
50
+
className
51
+
)}
52
+
{...props}
53
+
>
54
+
{children}
55
+
</Comp>
56
+
);
57
+
}
58
+
59
+
Button.displayName = "Button";
+2
src/main.tsx
+2
src/main.tsx
+26
-23
src/providers/UnifiedAuthProvider.tsx
+26
-23
src/providers/UnifiedAuthProvider.tsx
···
1
-
// src/providers/UnifiedAuthProvider.tsx
2
-
// Import both Agent and the (soon to be deprecated) AtpAgent
3
1
import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api";
4
2
import {
5
3
type OAuthSession,
···
7
5
TokenRefreshError,
8
6
TokenRevokedError,
9
7
} from "@atproto/oauth-client-browser";
8
+
import { useAtom } from "jotai";
10
9
import React, {
11
10
createContext,
12
11
use,
···
15
14
useState,
16
15
} from "react";
17
16
18
-
import { oauthClient } from "../utils/oauthClient"; // Adjust path if needed
17
+
import { quickAuthAtom } from "~/utils/atoms";
18
+
19
+
import { oauthClient } from "../utils/oauthClient";
19
20
20
-
// Define the unified status and authentication method
21
21
type AuthStatus = "loading" | "signedIn" | "signedOut";
22
22
type AuthMethod = "password" | "oauth" | null;
23
23
24
24
interface AuthContextValue {
25
-
agent: Agent | null; // The agent is typed as the base class `Agent`
25
+
agent: Agent | null;
26
26
status: AuthStatus;
27
27
authMethod: AuthMethod;
28
28
loginWithPassword: (
···
41
41
}: {
42
42
children: React.ReactNode;
43
43
}) => {
44
-
// The state is typed as the base class `Agent`, which accepts both `Agent` and `AtpAgent` instances.
45
44
const [agent, setAgent] = useState<Agent | null>(null);
46
45
const [status, setStatus] = useState<AuthStatus>("loading");
47
46
const [authMethod, setAuthMethod] = useState<AuthMethod>(null);
48
47
const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null);
48
+
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
49
49
50
-
// Unified Initialization Logic
51
50
const initialize = useCallback(async () => {
52
-
// --- 1. Try OAuth initialization first ---
53
51
try {
54
52
const oauthResult = await oauthClient.init();
55
53
if (oauthResult) {
56
54
// /*mass comment*/ console.log("OAuth session restored.");
57
-
const apiAgent = new Agent(oauthResult.session); // Standard Agent
55
+
const apiAgent = new Agent(oauthResult.session);
58
56
setAgent(apiAgent);
59
57
setOauthSession(oauthResult.session);
60
58
setAuthMethod("oauth");
61
59
setStatus("signedIn");
62
-
return; // Success
60
+
setQuickAuth(apiAgent?.did || null);
61
+
return;
63
62
}
64
63
} catch (e) {
65
64
console.error("OAuth init failed, checking password session.", e);
65
+
if (!quickAuth) {
66
+
// quickAuth restoration. if last used method is oauth we immediately call for oauth redo
67
+
// (and set a persistent atom somewhere to not retry again if it failed)
68
+
}
66
69
}
67
70
68
-
// --- 2. If no OAuth, try password-based session using AtpAgent ---
69
71
try {
70
72
const service = localStorage.getItem("service");
71
73
const sessionString = localStorage.getItem("sess");
72
74
73
75
if (service && sessionString) {
74
76
// /*mass comment*/ console.log("Resuming password-based session using AtpAgent...");
75
-
// Use the original, working AtpAgent logic
76
77
const apiAgent = new AtpAgent({ service });
77
78
const session: AtpSessionData = JSON.parse(sessionString);
78
79
await apiAgent.resumeSession(session);
79
80
80
81
// /*mass comment*/ console.log("Password-based session resumed successfully.");
81
-
setAgent(apiAgent); // This works because AtpAgent is a subclass of Agent
82
+
setAgent(apiAgent);
82
83
setAuthMethod("password");
83
84
setStatus("signedIn");
84
-
return; // Success
85
+
setQuickAuth(apiAgent?.did || null);
86
+
return;
85
87
}
86
88
} catch (e) {
87
89
console.error("Failed to resume password-based session.", e);
···
89
91
localStorage.removeItem("service");
90
92
}
91
93
92
-
// --- 3. If neither worked, user is signed out ---
93
94
// /*mass comment*/ console.log("No active session found.");
94
95
setStatus("signedOut");
95
96
setAgent(null);
96
97
setAuthMethod(null);
97
-
}, []);
98
+
// do we want to null it here?
99
+
setQuickAuth(null);
100
+
}, [quickAuth, setQuickAuth]);
98
101
99
102
useEffect(() => {
100
103
const handleOAuthSessionDeleted = (
···
105
108
setOauthSession(null);
106
109
setAuthMethod(null);
107
110
setStatus("signedOut");
111
+
setQuickAuth(null);
108
112
};
109
113
110
114
oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener);
···
113
117
return () => {
114
118
oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener);
115
119
};
116
-
}, [initialize]);
120
+
}, [initialize, setQuickAuth]);
117
121
118
-
// --- Login Methods ---
119
122
const loginWithPassword = async (
120
123
user: string,
121
124
password: string,
···
125
128
setStatus("loading");
126
129
try {
127
130
let sessionData: AtpSessionData | undefined;
128
-
// Use the AtpAgent for its simple login and session persistence
129
131
const apiAgent = new AtpAgent({
130
132
service,
131
133
persistSession: (_evt, sess) => {
···
137
139
if (sessionData) {
138
140
localStorage.setItem("service", service);
139
141
localStorage.setItem("sess", JSON.stringify(sessionData));
140
-
setAgent(apiAgent); // Store the AtpAgent instance in our state
142
+
setAgent(apiAgent);
141
143
setAuthMethod("password");
142
144
setStatus("signedIn");
145
+
setQuickAuth(apiAgent?.did || null);
143
146
// /*mass comment*/ console.log("Successfully logged in with password.");
144
147
} else {
145
148
throw new Error("Session data not persisted after login.");
···
147
150
} catch (e) {
148
151
console.error("Password login failed:", e);
149
152
setStatus("signedOut");
153
+
setQuickAuth(null);
150
154
throw e;
151
155
}
152
156
};
···
161
165
}
162
166
}, [status]);
163
167
164
-
// --- Unified Logout ---
165
168
const logout = useCallback(async () => {
166
169
if (status !== "signedIn" || !agent) return;
167
170
setStatus("loading");
···
173
176
} else if (authMethod === "password") {
174
177
localStorage.removeItem("service");
175
178
localStorage.removeItem("sess");
176
-
// AtpAgent has its own logout methods
177
179
await (agent as AtpAgent).com.atproto.server.deleteSession();
178
180
// /*mass comment*/ console.log("Password-based session deleted.");
179
181
}
···
184
186
setAuthMethod(null);
185
187
setOauthSession(null);
186
188
setStatus("signedOut");
189
+
setQuickAuth(null);
187
190
}
188
-
}, [status, authMethod, agent, oauthSession]);
191
+
}, [status, agent, authMethod, oauthSession, setQuickAuth]);
189
192
190
193
return (
191
194
<AuthContext
+35
-33
src/routes/__root.tsx
+35
-33
src/routes/__root.tsx
···
18
18
19
19
import { Composer } from "~/components/Composer";
20
20
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
21
+
import { Import } from "~/components/Import";
21
22
import Login from "~/components/Login";
22
23
import { NotFound } from "~/components/NotFound";
24
+
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
23
25
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
24
-
import { composerAtom } from "~/utils/atoms";
26
+
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
25
27
import { seo } from "~/utils/seo";
26
28
27
29
export const Route = createRootRouteWithContext<{
···
87
89
}
88
90
89
91
function RootDocument({ children }: { children: React.ReactNode }) {
92
+
useAtomCssVar(hueAtom, "--tw-gray-hue");
90
93
const location = useLocation();
91
94
const navigate = useNavigate();
92
95
const { agent } = useAuth();
···
128
131
<div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950">
129
132
<nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start">
130
133
<div className="flex items-center gap-3 mb-4">
131
-
<img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" />
134
+
<FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
132
135
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
133
136
Red Dwarf{" "}
134
137
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
152
155
/>
153
156
154
157
<MaterialNavItem
158
+
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
159
+
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
160
+
active={locationEnum === "search"}
161
+
onClickCallbback={() =>
162
+
navigate({
163
+
to: "/search",
164
+
//params: { did: agent.assertDid },
165
+
})
166
+
}
167
+
text="Explore"
168
+
/>
169
+
<MaterialNavItem
155
170
InactiveIcon={
156
171
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
157
172
}
···
178
193
})
179
194
}
180
195
text="Feeds"
181
-
/>
182
-
<MaterialNavItem
183
-
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
184
-
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
185
-
active={locationEnum === "search"}
186
-
onClickCallbback={() =>
187
-
navigate({
188
-
to: "/search",
189
-
//params: { did: agent.assertDid },
190
-
})
191
-
}
192
-
text="Search"
193
196
/>
194
197
<MaterialNavItem
195
198
InactiveIcon={
···
367
370
368
371
<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">
369
372
<div className="flex items-center gap-3 mb-4">
370
-
<img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" />
373
+
<FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
371
374
</div>
372
375
<MaterialNavItem
373
376
small
···
387
390
388
391
<MaterialNavItem
389
392
small
393
+
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
394
+
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
395
+
active={locationEnum === "search"}
396
+
onClickCallbback={() =>
397
+
navigate({
398
+
to: "/search",
399
+
//params: { did: agent.assertDid },
400
+
})
401
+
}
402
+
text="Explore"
403
+
/>
404
+
<MaterialNavItem
405
+
small
390
406
InactiveIcon={
391
407
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
392
408
}
···
417
433
/>
418
434
<MaterialNavItem
419
435
small
420
-
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
421
-
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
422
-
active={locationEnum === "search"}
423
-
onClickCallbback={() =>
424
-
navigate({
425
-
to: "/search",
426
-
//params: { did: agent.assertDid },
427
-
})
428
-
}
429
-
text="Search"
430
-
/>
431
-
<MaterialNavItem
432
-
small
433
436
InactiveIcon={
434
437
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
435
438
}
···
496
499
</main>
497
500
498
501
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
502
+
<div className="px-4 pt-4"><Import /></div>
499
503
<Login />
500
504
501
505
<div className="flex-1"></div>
502
506
<p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4">
503
-
Red Dwarf is a bluesky client that uses Constellation and direct PDS
504
-
queries. Skylite would be a self-hosted bluesky "instance". Stay
505
-
tuned for the release of Skylite.
507
+
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)
506
508
</p>
507
509
</aside>
508
510
</div>
···
551
553
//params: { did: agent.assertDid },
552
554
})
553
555
}
554
-
text="Search"
556
+
text="Explore"
555
557
/>
556
558
{/* <Link
557
559
to="/search"
···
678
680
) : (
679
681
<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">
680
682
<div className="flex items-center gap-2">
681
-
<img src="/redstar.png" alt="Red Dwarf Logo" className="w-6 h-6" />
683
+
<FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
682
684
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
683
685
Red Dwarf{" "}
684
686
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
+40
-40
src/routes/index.tsx
+40
-40
src/routes/index.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
2
import { useAtom } from "jotai";
3
3
import * as React from "react";
4
-
import { useEffect, useLayoutEffect } from "react";
4
+
import { useLayoutEffect, useState } from "react";
5
5
6
6
import { Header } from "~/components/Header";
7
7
import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed";
8
8
import { useAuth } from "~/providers/UnifiedAuthProvider";
9
9
import {
10
-
agentAtom,
11
-
authedAtom,
12
10
feedScrollPositionsAtom,
13
11
isAtTopAtom,
12
+
quickAuthAtom,
14
13
selectedFeedUriAtom,
15
-
store,
16
14
} from "~/utils/atoms";
17
15
//import { usePersistentStore } from "~/providers/PersistentStoreProvider";
18
16
import {
···
107
105
} = useAuth();
108
106
const authed = !!agent?.did;
109
107
110
-
useEffect(() => {
111
-
if (agent?.did) {
112
-
store.set(authedAtom, true);
113
-
} else {
114
-
store.set(authedAtom, false);
115
-
}
116
-
}, [status, agent, authed]);
117
-
useEffect(() => {
118
-
if (agent) {
119
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
120
-
// @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent
121
-
store.set(agentAtom, agent);
122
-
} else {
123
-
store.set(agentAtom, null);
124
-
}
125
-
}, [status, agent, authed]);
108
+
// i dont remember why this is even here
109
+
// useEffect(() => {
110
+
// if (agent?.did) {
111
+
// store.set(authedAtom, true);
112
+
// } else {
113
+
// store.set(authedAtom, false);
114
+
// }
115
+
// }, [status, agent, authed]);
116
+
// useEffect(() => {
117
+
// if (agent) {
118
+
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
119
+
// // @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent
120
+
// store.set(agentAtom, agent);
121
+
// } else {
122
+
// store.set(agentAtom, null);
123
+
// }
124
+
// }, [status, agent, authed]);
126
125
127
126
//const { get, set } = usePersistentStore();
128
127
// const [feed, setFeed] = React.useState<any[]>([]);
···
162
161
163
162
// const savedFeeds = savedFeedsPref?.items || [];
164
163
165
-
const identityresultmaybe = useQueryIdentity(agent?.did);
164
+
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
165
+
const isAuthRestoring = quickAuth ? status === "loading" : false;
166
+
167
+
const identityresultmaybe = useQueryIdentity(!isAuthRestoring ? agent?.did : undefined);
166
168
const identity = identityresultmaybe?.data;
167
169
168
170
const prefsresultmaybe = useQueryPreferences({
169
-
agent: agent ?? undefined,
170
-
pdsUrl: identity?.pds,
171
+
agent: !isAuthRestoring ? (agent ?? undefined) : undefined,
172
+
pdsUrl: !isAuthRestoring ? (identity?.pds) : undefined,
171
173
});
172
174
const prefs = prefsresultmaybe?.data;
173
175
···
178
180
return savedFeedsPref?.items || [];
179
181
}, [prefs]);
180
182
181
-
const [persistentSelectedFeed, setPersistentSelectedFeed] =
182
-
useAtom(selectedFeedUriAtom); // React.useState<string | null>(null);
183
-
const [unauthedSelectedFeed, setUnauthedSelectedFeed] = React.useState(
184
-
persistentSelectedFeed
185
-
); // React.useState<string | null>(null);
183
+
const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom);
184
+
const [unauthedSelectedFeed, setUnauthedSelectedFeed] = useState(persistentSelectedFeed);
186
185
const selectedFeed = agent?.did
187
186
? persistentSelectedFeed
188
187
: unauthedSelectedFeed;
···
306
305
}, [scrollPositions]);
307
306
308
307
useLayoutEffect(() => {
308
+
if (isAuthRestoring) return;
309
309
const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0;
310
310
311
311
window.scrollTo({ top: savedPosition, behavior: "instant" });
312
312
// eslint-disable-next-line react-hooks/exhaustive-deps
313
-
}, [selectedFeed]);
313
+
}, [selectedFeed, isAuthRestoring]);
314
314
315
315
useLayoutEffect(() => {
316
-
if (!selectedFeed) return;
316
+
if (!selectedFeed || isAuthRestoring) return;
317
317
318
318
const handleScroll = () => {
319
319
scrollPositionsRef.current = {
···
328
328
329
329
setScrollPositions(scrollPositionsRef.current);
330
330
};
331
-
}, [selectedFeed, setScrollPositions]);
331
+
}, [isAuthRestoring, selectedFeed, setScrollPositions]);
332
332
333
-
const feedGengetrecordquery = useQueryArbitrary(selectedFeed ?? undefined);
334
-
const feedServiceDid = (feedGengetrecordquery?.data?.value as any)?.did;
333
+
const feedGengetrecordquery = useQueryArbitrary(!isAuthRestoring ? selectedFeed ?? undefined : undefined);
334
+
const feedServiceDid = !isAuthRestoring ? (feedGengetrecordquery?.data?.value as any)?.did as string | undefined : undefined;
335
335
336
336
// const {
337
337
// data: feedData,
···
347
347
348
348
// const feed = feedData?.feed || [];
349
349
350
-
const isReadyForAuthedFeed =
351
-
authed && agent && identity?.pds && feedServiceDid;
352
-
const isReadyForUnauthedFeed = !authed && selectedFeed;
350
+
const isReadyForAuthedFeed = !isAuthRestoring && authed && agent && identity?.pds && feedServiceDid;
351
+
const isReadyForUnauthedFeed = !isAuthRestoring && !authed && selectedFeed;
353
352
354
353
355
354
const [isAtTop] = useAtom(isAtTopAtom);
···
358
357
<div
359
358
className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`}
360
359
>
361
-
{savedFeeds.length > 0 ? (
360
+
{!isAuthRestoring && savedFeeds.length > 0 ? (
362
361
<div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}>
363
362
{savedFeeds.map((item: any, idx: number) => {
364
363
const label = item.value.split("/").pop() || item.value;
···
410
409
/>
411
410
))} */}
412
411
413
-
{authed && (!identity?.pds || !feedServiceDid) && (
412
+
{isAuthRestoring || authed && (!identity?.pds || !feedServiceDid) && (
414
413
<div className="p-4 text-center text-gray-500">
415
414
Preparing your feed...
416
415
</div>
417
416
)}
418
417
419
-
{isReadyForAuthedFeed || isReadyForUnauthedFeed ? (
418
+
{!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? (
420
419
<InfiniteCustomFeed
420
+
key={selectedFeed!}
421
421
feedUri={selectedFeed!}
422
422
pdsUrl={identity?.pds}
423
423
feedServiceDid={feedServiceDid}
424
424
/>
425
425
) : (
426
426
<div className="p-4 text-center text-gray-500">
427
-
Select a feed to get started.
427
+
Loading.......
428
428
</div>
429
429
)}
430
430
{/* {false && restoringScrollPosition && (
+7
-3
src/routes/notifications.tsx
+7
-3
src/routes/notifications.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
+
import { useAtom } from "jotai";
2
3
import React, { useEffect, useRef,useState } from "react";
3
4
4
5
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
+
import { constellationURLAtom } from "~/utils/atoms";
5
7
6
8
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
7
9
···
70
72
}
71
73
}
72
74
75
+
const [constellationURL] = useAtom(constellationURLAtom)
76
+
73
77
useEffect(() => {
74
78
if (!did) return;
75
79
setLoading(true);
76
80
setError(null);
77
81
const urls = [
78
-
`https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`,
79
-
`https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`,
80
-
`https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`,
82
+
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`,
83
+
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`,
84
+
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`,
81
85
];
82
86
let ignore = false;
83
87
Promise.all(
+181
-53
src/routes/profile.$did/index.tsx
+181
-53
src/routes/profile.$did/index.tsx
···
1
+
import { RichText } from "@atproto/api";
1
2
import { useQueryClient } from "@tanstack/react-query";
2
-
import { createFileRoute } from "@tanstack/react-router";
3
-
import React from "react";
3
+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
5
+
import React, { type ReactNode, useEffect, useState } from "react";
4
6
5
7
import { Header } from "~/components/Header";
6
-
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
8
+
import { Button } from "~/components/radix-m3-rd/Button";
9
+
import {
10
+
renderTextWithFacets,
11
+
UniversalPostRendererATURILoader,
12
+
} from "~/components/UniversalPostRenderer";
7
13
import { useAuth } from "~/providers/UnifiedAuthProvider";
8
-
import { toggleFollow, useGetFollowState } from "~/utils/followState";
14
+
import { imgCDNAtom } from "~/utils/atoms";
15
+
import {
16
+
toggleFollow,
17
+
useGetFollowState,
18
+
useGetOneToOneState,
19
+
} from "~/utils/followState";
9
20
import {
10
21
useInfiniteQueryAuthorFeed,
11
22
useQueryIdentity,
···
19
30
function ProfileComponent() {
20
31
// booo bad this is not always the did it might be a handle, use identity.did instead
21
32
const { did } = Route.useParams();
33
+
const navigate = useNavigate();
22
34
const queryClient = useQueryClient();
23
-
const { agent } = useAuth();
24
35
const {
25
36
data: identity,
26
37
isLoading: isIdentityLoading,
27
38
error: identityError,
28
39
} = useQueryIdentity(did);
29
-
30
-
const followRecords = useGetFollowState({
31
-
target: identity?.did || did,
32
-
user: agent?.did,
33
-
});
34
40
35
41
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
36
42
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
···
67
73
[postsData]
68
74
);
69
75
76
+
const [imgcdn] = useAtom(imgCDNAtom);
77
+
70
78
function getAvatarUrl(p: typeof profile) {
71
79
const link = p?.avatar?.ref?.["$link"];
72
80
if (!link || !resolvedDid) return null;
73
-
return `https://cdn.bsky.app/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
81
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
74
82
}
75
83
function getBannerUrl(p: typeof profile) {
76
84
const link = p?.banner?.ref?.["$link"];
77
85
if (!link || !resolvedDid) return null;
78
-
return `https://cdn.bsky.app/img/banner/plain/${resolvedDid}/${link}@jpeg`;
86
+
return `https://${imgcdn}/img/banner/plain/${resolvedDid}/${link}@jpeg`;
79
87
}
80
88
81
89
const displayName =
···
162
170
also delay the backfill to be on demand because it would be pretty intense
163
171
also save it persistently
164
172
*/}
165
-
{identity?.did !== agent?.did ? (
166
-
<>
167
-
{!(followRecords?.length && followRecords?.length > 0) ? (
168
-
<button
169
-
onClick={() =>
170
-
toggleFollow({
171
-
agent: agent || undefined,
172
-
targetDid: identity?.did,
173
-
followRecords: followRecords,
174
-
queryClient: queryClient,
175
-
})
176
-
}
177
-
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
178
-
>
179
-
Follow
180
-
</button>
181
-
) : (
182
-
<button
183
-
onClick={() =>
184
-
toggleFollow({
185
-
agent: agent || undefined,
186
-
targetDid: identity?.did,
187
-
followRecords: followRecords,
188
-
queryClient: queryClient,
189
-
})
190
-
}
191
-
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
192
-
>
193
-
Unfollow
194
-
</button>
195
-
)}
196
-
</>
197
-
) : (
198
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
199
-
Edit Profile
200
-
</button>
201
-
)}
202
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
173
+
<FollowButton targetdidorhandle={did} />
174
+
<Button className="rounded-full" variant={"secondary"}>
203
175
... {/* todo: icon */}
204
-
</button>
176
+
</Button>
205
177
</div>
206
178
207
179
{/* Info Card */}
208
180
<div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100">
209
181
<div className="font-bold text-2xl">{displayName}</div>
210
-
<div className="text-gray-500 dark:text-gray-400 text-base mb-3">
182
+
<div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1">
183
+
<Mutual targetdidorhandle={did} />
211
184
{handle}
212
185
</div>
213
186
{description && (
214
187
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
215
-
{description}
188
+
{/* {description} */}
189
+
<RichTextRenderer key={did} description={description} />
216
190
</div>
217
191
)}
218
192
</div>
···
255
229
</>
256
230
);
257
231
}
232
+
233
+
export function FollowButton({
234
+
targetdidorhandle,
235
+
}: {
236
+
targetdidorhandle: string;
237
+
}) {
238
+
const { agent } = useAuth();
239
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
240
+
const queryClient = useQueryClient();
241
+
242
+
const followRecords = useGetFollowState({
243
+
target: identity?.did ?? targetdidorhandle,
244
+
user: agent?.did,
245
+
});
246
+
247
+
return (
248
+
<>
249
+
{identity?.did !== agent?.did ? (
250
+
<>
251
+
{!(followRecords?.length && followRecords?.length > 0) ? (
252
+
<Button
253
+
onClick={(e) => {
254
+
e.stopPropagation();
255
+
toggleFollow({
256
+
agent: agent || undefined,
257
+
targetDid: identity?.did,
258
+
followRecords: followRecords,
259
+
queryClient: queryClient,
260
+
});
261
+
}}
262
+
>
263
+
Follow
264
+
</Button>
265
+
) : (
266
+
<Button
267
+
onClick={(e) => {
268
+
e.stopPropagation();
269
+
toggleFollow({
270
+
agent: agent || undefined,
271
+
targetDid: identity?.did,
272
+
followRecords: followRecords,
273
+
queryClient: queryClient,
274
+
});
275
+
}}
276
+
>
277
+
Unfollow
278
+
</Button>
279
+
)}
280
+
</>
281
+
) : (
282
+
<Button variant={"secondary"}>Edit Profile</Button>
283
+
)}
284
+
</>
285
+
);
286
+
}
287
+
288
+
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
289
+
const { agent } = useAuth();
290
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
291
+
292
+
const theyFollowYouRes = useGetOneToOneState(
293
+
agent?.did
294
+
? {
295
+
target: agent?.did,
296
+
user: identity?.did ?? targetdidorhandle,
297
+
collection: "app.bsky.graph.follow",
298
+
path: ".subject",
299
+
}
300
+
: undefined
301
+
);
302
+
303
+
const youFollowThemRes = useGetFollowState({
304
+
target: identity?.did ?? targetdidorhandle,
305
+
user: agent?.did,
306
+
});
307
+
308
+
const theyFollowYou: boolean =
309
+
!!theyFollowYouRes?.length && theyFollowYouRes.length > 0;
310
+
const youFollowThem: boolean =
311
+
!!youFollowThemRes?.length && youFollowThemRes.length > 0;
312
+
313
+
return (
314
+
<>
315
+
{/* if not self */}
316
+
{identity?.did !== agent?.did ? (
317
+
<>
318
+
{theyFollowYou ? (
319
+
<>
320
+
{youFollowThem ? (
321
+
<div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center">
322
+
mutuals
323
+
</div>
324
+
) : (
325
+
<div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center">
326
+
follows you
327
+
</div>
328
+
)}
329
+
</>
330
+
) : (
331
+
<></>
332
+
)}
333
+
</>
334
+
) : (
335
+
// lmao can someone be mutuals with themselves ??
336
+
<></>
337
+
)}
338
+
</>
339
+
);
340
+
}
341
+
342
+
export function RichTextRenderer({ description }: { description: string }) {
343
+
const [richDescription, setRichDescription] = useState<string | ReactNode[]>(
344
+
description
345
+
);
346
+
const { agent } = useAuth();
347
+
const navigate = useNavigate();
348
+
349
+
useEffect(() => {
350
+
let mounted = true;
351
+
352
+
// setRichDescription(description);
353
+
354
+
async function processRichText() {
355
+
try {
356
+
if (!agent?.did) return;
357
+
const rt = new RichText({ text: description });
358
+
await rt.detectFacets(agent);
359
+
360
+
if (!mounted) return;
361
+
362
+
if (rt.facets) {
363
+
setRichDescription(
364
+
renderTextWithFacets({ text: rt.text, facets: rt.facets, navigate })
365
+
);
366
+
} else {
367
+
setRichDescription(rt.text);
368
+
}
369
+
} catch (error) {
370
+
console.error("Failed to detect facets:", error);
371
+
if (mounted) {
372
+
setRichDescription(description);
373
+
}
374
+
}
375
+
}
376
+
377
+
processRichText();
378
+
379
+
return () => {
380
+
mounted = false;
381
+
};
382
+
}, [description, agent, navigate]);
383
+
384
+
return <>{richDescription}</>;
385
+
}
+1
-1
src/routes/profile.$did/post.$rkey.image.$i.tsx
+1
-1
src/routes/profile.$did/post.$rkey.image.$i.tsx
···
85
85
e.stopPropagation();
86
86
e.nativeEvent.stopImmediatePropagation();
87
87
}}
88
-
className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white"
88
+
className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter disablescroll border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white"
89
89
>
90
90
<ProfilePostComponent
91
91
key={`/profile/${did}/post/${rkey}`}
+9
-1
src/routes/profile.$did/post.$rkey.tsx
+9
-1
src/routes/profile.$did/post.$rkey.tsx
···
1
1
import { AtUri } from "@atproto/api";
2
2
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
3
import { createFileRoute, Outlet } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
4
5
import React, { useLayoutEffect } from "react";
5
6
6
7
import { Header } from "~/components/Header";
7
8
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
9
+
import { constellationURLAtom, slingshotURLAtom } from "~/utils/atoms";
8
10
//import { usePersistentStore } from '~/providers/PersistentStoreProvider';
9
11
import {
10
12
constructPostQuery,
···
275
277
// path: ".reply.parent.uri",
276
278
// });
277
279
// const replies = repliesData?.linking_records.slice(0, 50) ?? [];
280
+
const [constellationurl] = useAtom(constellationURLAtom)
281
+
278
282
const infinitequeryresults = useInfiniteQuery({
279
283
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
280
284
{
285
+
constellation: constellationurl,
281
286
method: "/links",
282
287
target: atUri,
283
288
collection: "app.bsky.feed.post",
···
386
391
}
387
392
}, [parents, layoutReady]);
388
393
394
+
395
+
const [slingshoturl] = useAtom(slingshotURLAtom)
396
+
389
397
React.useEffect(() => {
390
398
if (parentsLoading) {
391
399
setLayoutReady(false);
···
414
422
while (currentParentUri && safetyCounter < MAX_PARENTS) {
415
423
try {
416
424
const parentPost = await queryClient.fetchQuery(
417
-
constructPostQuery(currentParentUri)
425
+
constructPostQuery(currentParentUri, slingshoturl)
418
426
);
419
427
if (!parentPost) break;
420
428
parentChain.push(parentPost);
+50
-1
src/routes/search.tsx
+50
-1
src/routes/search.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
2
3
+
import { Header } from "~/components/Header";
4
+
import { Import } from "~/components/Import";
5
+
3
6
export const Route = createFileRoute("/search")({
4
7
component: Search,
5
8
});
6
9
7
10
export function Search() {
8
-
return <div className="p-6">Search page (coming soon)</div>;
11
+
return (
12
+
<>
13
+
<Header
14
+
title="Explore"
15
+
backButtonCallback={() => {
16
+
if (window.history.length > 1) {
17
+
window.history.back();
18
+
} else {
19
+
window.location.assign("/");
20
+
}
21
+
}}
22
+
/>
23
+
<div className=" flex flex-col items-center mt-4 mx-4 gap-4">
24
+
<Import />
25
+
<div className="flex flex-col">
26
+
<p className="text-gray-600 dark:text-gray-400">
27
+
Sorry we dont have search. But instead, you can load some of these
28
+
types of content into Red Dwarf:
29
+
</p>
30
+
<ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400">
31
+
<li>
32
+
Bluesky URLs from supported clients (like{" "}
33
+
<code className="text-sm">bsky.app</code> or{" "}
34
+
<code className="text-sm">deer.social</code>).
35
+
</li>
36
+
<li>
37
+
AT-URIs (e.g.,{" "}
38
+
<code className="text-sm">at://did:example/collection/item</code>
39
+
).
40
+
</li>
41
+
<li>
42
+
Plain handles (like{" "}
43
+
<code className="text-sm">@username.bsky.social</code>).
44
+
</li>
45
+
<li>
46
+
Direct DIDs (Decentralized Identifiers, starting with{" "}
47
+
<code className="text-sm">did:</code>).
48
+
</li>
49
+
</ul>
50
+
<p className="mt-2 text-gray-600 dark:text-gray-400">
51
+
Simply paste one of these into the import field above and press
52
+
Enter to load the content.
53
+
</p>
54
+
</div>
55
+
</div>
56
+
</>
57
+
);
9
58
}
+164
-1
src/routes/settings.tsx
+164
-1
src/routes/settings.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
+
import { useAtom } from "jotai";
3
+
import { Slider } from "radix-ui";
2
4
3
5
import { Header } from "~/components/Header";
4
6
import Login from "~/components/Login";
7
+
import {
8
+
constellationURLAtom,
9
+
defaultconstellationURL,
10
+
defaulthue,
11
+
defaultImgCDN,
12
+
defaultslingshotURL,
13
+
defaultVideoCDN,
14
+
hueAtom,
15
+
imgCDNAtom,
16
+
slingshotURLAtom,
17
+
videoCDNAtom,
18
+
} from "~/utils/atoms";
5
19
6
20
export const Route = createFileRoute("/settings")({
7
21
component: Settings,
···
20
34
}
21
35
}}
22
36
/>
23
-
<Login />
37
+
<div className="lg:hidden">
38
+
<Login />
39
+
</div>
40
+
<div className="h-4" />
41
+
<TextInputSetting
42
+
atom={constellationURLAtom}
43
+
title={"Constellation"}
44
+
description={
45
+
"Customize the Constellation instance to be used by Red Dwarf"
46
+
}
47
+
init={defaultconstellationURL}
48
+
/>
49
+
<TextInputSetting
50
+
atom={slingshotURLAtom}
51
+
title={"Slingshot"}
52
+
description={"Customize the Slingshot instance to be used by Red Dwarf"}
53
+
init={defaultslingshotURL}
54
+
/>
55
+
<TextInputSetting
56
+
atom={imgCDNAtom}
57
+
title={"Image CDN"}
58
+
description={
59
+
"Customize the Constellation instance to be used by Red Dwarf"
60
+
}
61
+
init={defaultImgCDN}
62
+
/>
63
+
<TextInputSetting
64
+
atom={videoCDNAtom}
65
+
title={"Video CDN"}
66
+
description={"Customize the Slingshot instance to be used by Red Dwarf"}
67
+
init={defaultVideoCDN}
68
+
/>
69
+
70
+
<Hue />
71
+
<p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm">
72
+
please restart/refresh the app if changes arent applying correctly
73
+
</p>
24
74
</>
25
75
);
26
76
}
77
+
function Hue() {
78
+
const [hue, setHue] = useAtom(hueAtom);
79
+
return (
80
+
<div className="flex flex-col px-4 mt-4 ">
81
+
<span className="z-10">Hue</span>
82
+
<div className="flex flex-row items-center gap-4">
83
+
<SliderComponent
84
+
atom={hueAtom}
85
+
max={360}
86
+
/>
87
+
<button
88
+
onClick={() => setHue(defaulthue ?? 28)}
89
+
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
90
+
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
91
+
>
92
+
Reset
93
+
</button>
94
+
</div>
95
+
</div>
96
+
);
97
+
}
98
+
99
+
export function TextInputSetting({
100
+
atom,
101
+
title,
102
+
description,
103
+
init,
104
+
}: {
105
+
atom: typeof constellationURLAtom;
106
+
title?: string;
107
+
description?: string;
108
+
init?: string;
109
+
}) {
110
+
const [value, setValue] = useAtom(atom);
111
+
return (
112
+
<div className="flex flex-col gap-2 px-4 py-2">
113
+
{/* <div>
114
+
{title && (
115
+
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
116
+
{title}
117
+
</h3>
118
+
)}
119
+
{description && (
120
+
<p className="text-sm text-gray-500 dark:text-gray-400">
121
+
{description}
122
+
</p>
123
+
)}
124
+
</div> */}
125
+
126
+
<div className="flex flex-row gap-2 items-center">
127
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
128
+
<input
129
+
type="text"
130
+
placeholder=" "
131
+
value={value}
132
+
onChange={(e) => setValue(e.target.value)}
133
+
/>
134
+
<label>{title}</label>
135
+
</div>
136
+
{/* <input
137
+
type="text"
138
+
value={value}
139
+
onChange={(e) => setValue(e.target.value)}
140
+
className="flex-1 px-3 py-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700
141
+
text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400
142
+
focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600"
143
+
placeholder="Enter value..."
144
+
/> */}
145
+
<button
146
+
onClick={() => setValue(init ?? "")}
147
+
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
148
+
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
149
+
>
150
+
Reset
151
+
</button>
152
+
</div>
153
+
</div>
154
+
);
155
+
}
156
+
157
+
158
+
interface SliderProps {
159
+
atom: typeof hueAtom;
160
+
min?: number;
161
+
max?: number;
162
+
step?: number;
163
+
}
164
+
165
+
export const SliderComponent: React.FC<SliderProps> = ({
166
+
atom,
167
+
min = 0,
168
+
max = 100,
169
+
step = 1,
170
+
}) => {
171
+
172
+
const [value, setValue] = useAtom(atom)
173
+
174
+
return (
175
+
<Slider.Root
176
+
className="relative flex items-center w-full h-4"
177
+
value={[value]}
178
+
min={min}
179
+
max={max}
180
+
step={step}
181
+
onValueChange={(v: number[]) => setValue(v[0])}
182
+
>
183
+
<Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full">
184
+
<Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" />
185
+
</Slider.Track>
186
+
<Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" />
187
+
</Slider.Root>
188
+
);
189
+
};
+139
-13
src/styles/app.css
+139
-13
src/styles/app.css
···
15
15
--color-gray-950: oklch(0.129 0.050 222.000);
16
16
} */
17
17
18
+
:root {
19
+
--safe-hue: var(--tw-gray-hue, 28)
20
+
}
21
+
18
22
@theme {
19
-
--color-gray-50: oklch(0.984 0.012 28);
20
-
--color-gray-100: oklch(0.968 0.017 28);
21
-
--color-gray-200: oklch(0.929 0.025 28);
22
-
--color-gray-300: oklch(0.869 0.035 28);
23
-
--color-gray-400: oklch(0.704 0.05 28);
24
-
--color-gray-500: oklch(0.554 0.06 28);
25
-
--color-gray-600: oklch(0.446 0.058 28);
26
-
--color-gray-700: oklch(0.372 0.058 28);
27
-
--color-gray-800: oklch(0.279 0.055 28);
28
-
--color-gray-900: oklch(0.208 0.055 28);
29
-
--color-gray-950: oklch(0.129 0.055 28);
23
+
--color-gray-50: oklch(0.984 0.012 var(--safe-hue));
24
+
--color-gray-100: oklch(0.968 0.017 var(--safe-hue));
25
+
--color-gray-200: oklch(0.929 0.025 var(--safe-hue));
26
+
--color-gray-300: oklch(0.869 0.035 var(--safe-hue));
27
+
--color-gray-400: oklch(0.704 0.05 var(--safe-hue));
28
+
--color-gray-500: oklch(0.554 0.06 var(--safe-hue));
29
+
--color-gray-600: oklch(0.446 0.058 var(--safe-hue));
30
+
--color-gray-700: oklch(0.372 0.058 var(--safe-hue));
31
+
--color-gray-800: oklch(0.279 0.055 var(--safe-hue));
32
+
--color-gray-900: oklch(0.208 0.055 var(--safe-hue));
33
+
--color-gray-950: oklch(0.129 0.055 var(--safe-hue));
30
34
}
31
35
32
36
@layer base {
···
48
52
}
49
53
}
50
54
55
+
.gutter{
56
+
scrollbar-gutter: stable both-edges;
57
+
}
58
+
51
59
@media (width >= 64rem /* 1024px */) {
52
60
html:not(:has(.disablegutter)),
53
61
body:not(:has(.disablegutter)) {
54
62
scrollbar-gutter: stable both-edges !important;
55
63
}
56
-
html:has(.disablegutter),
57
-
body:has(.disablegutter) {
64
+
html:has(.disablescroll),
65
+
body:has(.disablescroll) {
58
66
scrollbar-width: none;
59
67
overflow-y: hidden;
60
68
}
···
105
113
:root {
106
114
--shadow-opacity: calc(1 - var(--is-top));
107
115
--tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15));
116
+
}
117
+
118
+
119
+
/* m3 input */
120
+
:root {
121
+
--m3input-radius: 6px;
122
+
--m3input-border-width: .0625rem;
123
+
--m3input-font-size: 16px;
124
+
--m3input-transition: 150ms cubic-bezier(.2, .8, .2, 1);
125
+
/* light theme */
126
+
--m3input-bg: var(--color-gray-50);
127
+
--m3input-border-color: var(--color-gray-400);
128
+
--m3input-label-color: var(--color-gray-500);
129
+
--m3input-text-color: var(--color-gray-900);
130
+
--m3input-focus-color: var(--color-gray-600);
131
+
}
132
+
133
+
@media (prefers-color-scheme: dark) {
134
+
:root {
135
+
--m3input-bg: var(--color-gray-950);
136
+
--m3input-border-color: var(--color-gray-700);
137
+
--m3input-label-color: var(--color-gray-400);
138
+
--m3input-text-color: var(--color-gray-50);
139
+
--m3input-focus-color: var(--color-gray-400);
140
+
}
141
+
}
142
+
143
+
/* reset page *//*
144
+
html,
145
+
body {
146
+
background: var(--m3input-bg);
147
+
margin: 0;
148
+
padding: 1rem;
149
+
color: var(--m3input-text-color);
150
+
font-family: system-ui, sans-serif;
151
+
font-size: var(--m3input-font-size);
152
+
}*/
153
+
154
+
/* base wrapper */
155
+
.m3input-field.m3input-label.m3input-border {
156
+
position: relative;
157
+
display: inline-block;
158
+
width: 100%;
159
+
/*max-width: 400px;*/
160
+
}
161
+
162
+
/* size variants */
163
+
.m3input-field.size-sm {
164
+
--m3input-h: 40px;
165
+
}
166
+
167
+
.m3input-field.size-md {
168
+
--m3input-h: 48px;
169
+
}
170
+
171
+
.m3input-field.size-lg {
172
+
--m3input-h: 56px;
173
+
}
174
+
175
+
.m3input-field.size-xl {
176
+
--m3input-h: 64px;
177
+
}
178
+
179
+
.m3input-field.m3input-label.m3input-border:not(.size-sm):not(.size-md):not(.size-lg):not(.size-xl) {
180
+
--m3input-h: 48px;
181
+
}
182
+
183
+
/* outlined input */
184
+
.m3input-field.m3input-label.m3input-border input {
185
+
width: 100%;
186
+
height: var(--m3input-h);
187
+
border: var(--m3input-border-width) solid var(--m3input-border-color);
188
+
border-radius: var(--m3input-radius);
189
+
background: var(--m3input-bg);
190
+
color: var(--m3input-text-color);
191
+
font-size: var(--m3input-font-size);
192
+
padding: 0 12px;
193
+
box-sizing: border-box;
194
+
outline: none;
195
+
transition: border-color var(--m3input-transition), box-shadow var(--m3input-transition);
196
+
}
197
+
198
+
/* focus ring */
199
+
.m3input-field.m3input-label.m3input-border input:focus {
200
+
border-color: var(--m3input-focus-color);
201
+
/*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/
202
+
}
203
+
204
+
/* label */
205
+
.m3input-field.m3input-label.m3input-border label {
206
+
position: absolute;
207
+
left: 12px;
208
+
top: 50%;
209
+
transform: translateY(-50%);
210
+
background: var(--m3input-bg);
211
+
padding: 0 .25em;
212
+
color: var(--m3input-label-color);
213
+
pointer-events: none;
214
+
transition: all var(--m3input-transition);
215
+
}
216
+
217
+
/* float on focus or when filled */
218
+
.m3input-field.m3input-label.m3input-border input:focus+label,
219
+
.m3input-field.m3input-label.m3input-border input:not(:placeholder-shown)+label {
220
+
top: 0;
221
+
transform: translateY(-50%) scale(.78);
222
+
left: 0;
223
+
color: var(--m3input-focus-color);
224
+
}
225
+
226
+
/* placeholder trick */
227
+
.m3input-field.m3input-label.m3input-border input::placeholder {
228
+
color: transparent;
229
+
}
230
+
231
+
/* radix i love you but like cmon man */
232
+
body[data-scroll-locked]{
233
+
margin-left: var(--removed-body-scroll-bar-size) !important;
108
234
}
+61
-13
src/utils/atoms.ts
+61
-13
src/utils/atoms.ts
···
1
-
import type Agent from "@atproto/api";
2
-
import { atom, createStore } from "jotai";
3
-
import { atomWithStorage } from 'jotai/utils';
1
+
import { atom, createStore, useAtomValue } from "jotai";
2
+
import { atomWithStorage } from "jotai/utils";
3
+
import { useEffect } from "react";
4
4
5
5
export const store = createStore();
6
6
7
+
export const quickAuthAtom = atomWithStorage<string | null>(
8
+
"quickAuth",
9
+
null
10
+
);
11
+
7
12
export const selectedFeedUriAtom = atomWithStorage<string | null>(
8
-
'selectedFeedUri',
13
+
"selectedFeedUri",
9
14
null
10
15
);
11
16
12
17
//export const feedScrollPositionsAtom = atom<Record<string, number>>({});
13
18
14
19
export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>(
15
-
'feedscrollpositions',
20
+
"feedscrollpositions",
16
21
{}
17
22
);
18
23
19
24
export const likedPostsAtom = atomWithStorage<Record<string, string>>(
20
-
'likedPosts',
25
+
"likedPosts",
21
26
{}
22
27
);
23
28
29
+
export const defaultconstellationURL = "constellation.microcosm.blue";
30
+
export const constellationURLAtom = atomWithStorage<string>(
31
+
"constellationURL",
32
+
defaultconstellationURL
33
+
);
34
+
export const defaultslingshotURL = "slingshot.microcosm.blue";
35
+
export const slingshotURLAtom = atomWithStorage<string>(
36
+
"slingshotURL",
37
+
defaultslingshotURL
38
+
);
39
+
export const defaultImgCDN = "cdn.bsky.app";
40
+
export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN);
41
+
export const defaultVideoCDN = "video.bsky.app";
42
+
export const videoCDNAtom = atomWithStorage<string>(
43
+
"videocdnurl",
44
+
defaultVideoCDN
45
+
);
46
+
47
+
export const defaulthue = 28;
48
+
export const hueAtom = atomWithStorage<number>("hue", defaulthue);
49
+
24
50
export const isAtTopAtom = atom<boolean>(true);
25
51
26
52
type ComposerState =
27
-
| { kind: 'closed' }
28
-
| { kind: 'root' }
29
-
| { kind: 'reply'; parent: string }
30
-
| { kind: 'quote'; subject: string };
31
-
export const composerAtom = atom<ComposerState>({ kind: 'closed' });
53
+
| { kind: "closed" }
54
+
| { kind: "root" }
55
+
| { kind: "reply"; parent: string }
56
+
| { kind: "quote"; subject: string };
57
+
export const composerAtom = atom<ComposerState>({ kind: "closed" });
58
+
59
+
//export const agentAtom = atom<Agent | null>(null);
60
+
//export const authedAtom = atom<boolean>(false);
61
+
62
+
export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) {
63
+
const value = useAtomValue(atom);
64
+
65
+
useEffect(() => {
66
+
document.documentElement.style.setProperty(cssVar, value.toString());
67
+
}, [value, cssVar]);
68
+
69
+
useEffect(() => {
70
+
document.documentElement.style.setProperty(cssVar, value.toString());
71
+
}, []);
72
+
}
32
73
33
-
export const agentAtom = atom<Agent|null>(null);
34
-
export const authedAtom = atom<boolean>(false);
74
+
hueAtom.onMount = (setAtom) => {
75
+
const stored = localStorage.getItem("hue");
76
+
if (stored != null) setAtom(Number(stored));
77
+
};
78
+
// export function initAtomToCssVar(atom: typeof hueAtom, cssVar: string) {
79
+
// const initial = store.get(atom);
80
+
// console.log("atom get ", initial);
81
+
// document.documentElement.style.setProperty(cssVar, initial.toString());
82
+
// }
+33
src/utils/followState.ts
+33
src/utils/followState.ts
···
128
128
};
129
129
});
130
130
}
131
+
132
+
133
+
134
+
export function useGetOneToOneState(params?: {
135
+
target: string;
136
+
user: string;
137
+
collection: string;
138
+
path: string;
139
+
}): string[] | undefined {
140
+
const { data: arbitrarydata } = useQueryConstellation(
141
+
params && params.user
142
+
? {
143
+
method: "/links",
144
+
target: params.target,
145
+
// @ts-expect-error overloading sucks so much
146
+
collection: params.collection,
147
+
path: params.path,
148
+
dids: [params.user],
149
+
}
150
+
: { method: "undefined", target: "whatever" }
151
+
// overloading sucks so much
152
+
) as { data: linksRecordsResponse | undefined };
153
+
if (!params || !params.user) return undefined;
154
+
const data = arbitrarydata?.linking_records.slice(0, 50) ?? [];
155
+
156
+
if (data.length > 0) {
157
+
return data.map((linksRecord) => {
158
+
return `at://${linksRecord.did}/${linksRecord.collection}/${linksRecord.rkey}`;
159
+
});
160
+
}
161
+
162
+
return undefined;
163
+
}
+53
-23
src/utils/useHydrated.ts
+53
-23
src/utils/useHydrated.ts
···
9
9
AppBskyFeedPost,
10
10
AtUri,
11
11
} from "@atproto/api";
12
+
import { useAtom } from "jotai";
12
13
import { useMemo } from "react";
13
14
14
-
import { useQueryIdentity,useQueryPost, useQueryProfile } from "./useQuery";
15
+
import { imgCDNAtom, videoCDNAtom } from "./atoms";
16
+
import { useQueryIdentity, useQueryPost, useQueryProfile } from "./useQuery";
15
17
16
-
type QueryResultData<T extends (...args: any) => any> = ReturnType<T> extends
17
-
| { data: infer D }
18
-
| undefined
19
-
? D
20
-
: never;
18
+
type QueryResultData<T extends (...args: any) => any> =
19
+
ReturnType<T> extends { data: infer D } | undefined ? D : never;
21
20
22
21
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
23
22
return obj as $Typed<T>;
···
26
25
export function hydrateEmbedImages(
27
26
embed: AppBskyEmbedImages.Main,
28
27
did: string,
28
+
cdn: string
29
29
): $Typed<AppBskyEmbedImages.View> {
30
30
return asTyped({
31
31
$type: "app.bsky.embed.images#view" as const,
···
34
34
const link = img.image.ref?.["$link"];
35
35
if (!link) return null;
36
36
return {
37
-
thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${link}@jpeg`,
38
-
fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${link}@jpeg`,
37
+
thumb: `https://${cdn}/img/feed_thumbnail/plain/${did}/${link}@jpeg`,
38
+
fullsize: `https://${cdn}/img/feed_fullsize/plain/${did}/${link}@jpeg`,
39
39
alt: img.alt || "",
40
40
aspectRatio: img.aspectRatio,
41
41
};
···
47
47
export function hydrateEmbedExternal(
48
48
embed: AppBskyEmbedExternal.Main,
49
49
did: string,
50
+
cdn: string
50
51
): $Typed<AppBskyEmbedExternal.View> {
51
52
return asTyped({
52
53
$type: "app.bsky.embed.external#view" as const,
···
55
56
title: embed.external.title,
56
57
description: embed.external.description,
57
58
thumb: embed.external.thumb?.ref?.$link
58
-
? `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg`
59
+
? `https://${cdn}/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg`
59
60
: undefined,
60
61
},
61
62
});
···
64
65
export function hydrateEmbedVideo(
65
66
embed: AppBskyEmbedVideo.Main,
66
67
did: string,
68
+
videocdn: string
67
69
): $Typed<AppBskyEmbedVideo.View> {
68
70
const videoLink = embed.video.ref.$link;
69
71
return asTyped({
70
72
$type: "app.bsky.embed.video#view" as const,
71
-
playlist: `https://video.bsky.app/watch/${did}/${videoLink}/playlist.m3u8`,
72
-
thumbnail: `https://video.bsky.app/watch/${did}/${videoLink}/thumbnail.jpg`,
73
+
playlist: `https://${videocdn}/watch/${did}/${videoLink}/playlist.m3u8`,
74
+
thumbnail: `https://${videocdn}/watch/${did}/${videoLink}/thumbnail.jpg`,
73
75
aspectRatio: embed.aspectRatio,
74
76
cid: videoLink,
75
77
});
···
80
82
quotedPost: QueryResultData<typeof useQueryPost>,
81
83
quotedProfile: QueryResultData<typeof useQueryProfile>,
82
84
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
85
+
cdn: string
83
86
): $Typed<AppBskyEmbedRecord.View> | undefined {
84
87
if (!quotedPost || !quotedProfile || !quotedIdentity) {
85
88
return undefined;
···
91
94
handle: quotedIdentity.handle,
92
95
displayName: quotedProfile.value.displayName ?? quotedIdentity.handle,
93
96
avatar: quotedProfile.value.avatar?.ref?.$link
94
-
? `https://cdn.bsky.app/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg`
97
+
? `https://${cdn}/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg`
95
98
: undefined,
96
99
viewer: {},
97
100
labels: [],
···
122
125
quotedPost: QueryResultData<typeof useQueryPost>,
123
126
quotedProfile: QueryResultData<typeof useQueryProfile>,
124
127
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
128
+
cdn: string
125
129
): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined {
126
130
const hydratedRecord = hydrateEmbedRecord(
127
131
embed.record,
128
132
quotedPost,
129
133
quotedProfile,
130
134
quotedIdentity,
135
+
cdn
131
136
);
132
137
133
138
if (!hydratedRecord) return undefined;
···
148
153
149
154
export function useHydratedEmbed(
150
155
embed: AppBskyFeedPost.Record["embed"],
151
-
postAuthorDid: string | undefined,
156
+
postAuthorDid: string | undefined
152
157
) {
153
158
const recordInfo = useMemo(() => {
154
159
if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
···
181
186
error: profileError,
182
187
} = useQueryProfile(profileUri);
183
188
189
+
const [imgcdn] = useAtom(imgCDNAtom);
190
+
const [videocdn] = useAtom(videoCDNAtom);
191
+
184
192
const queryidentityresult = useQueryIdentity(quotedAuthorDid);
185
193
186
194
const hydratedEmbed: HydratedEmbedView | undefined = (() => {
187
195
if (!embed || !postAuthorDid) return undefined;
188
196
189
-
if (isRecordType && (!usequerypostresults?.data || !quotedProfile || !queryidentityresult?.data)) {
197
+
if (
198
+
isRecordType &&
199
+
(!usequerypostresults?.data ||
200
+
!quotedProfile ||
201
+
!queryidentityresult?.data)
202
+
) {
190
203
return undefined;
191
204
}
192
205
193
206
try {
194
207
if (AppBskyEmbedImages.isMain(embed)) {
195
-
return hydrateEmbedImages(embed, postAuthorDid);
208
+
return hydrateEmbedImages(embed, postAuthorDid, imgcdn);
196
209
} else if (AppBskyEmbedExternal.isMain(embed)) {
197
-
return hydrateEmbedExternal(embed, postAuthorDid);
210
+
return hydrateEmbedExternal(embed, postAuthorDid, imgcdn);
198
211
} else if (AppBskyEmbedVideo.isMain(embed)) {
199
-
return hydrateEmbedVideo(embed, postAuthorDid);
212
+
return hydrateEmbedVideo(embed, postAuthorDid, videocdn);
200
213
} else if (AppBskyEmbedRecord.isMain(embed)) {
201
214
return hydrateEmbedRecord(
202
215
embed,
203
216
usequerypostresults?.data,
204
217
quotedProfile,
205
218
queryidentityresult?.data,
219
+
imgcdn
206
220
);
207
221
} else if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
208
222
let hydratedMedia:
···
212
226
| undefined;
213
227
214
228
if (AppBskyEmbedImages.isMain(embed.media)) {
215
-
hydratedMedia = hydrateEmbedImages(embed.media, postAuthorDid);
229
+
hydratedMedia = hydrateEmbedImages(
230
+
embed.media,
231
+
postAuthorDid,
232
+
imgcdn
233
+
);
216
234
} else if (AppBskyEmbedExternal.isMain(embed.media)) {
217
-
hydratedMedia = hydrateEmbedExternal(embed.media, postAuthorDid);
235
+
hydratedMedia = hydrateEmbedExternal(
236
+
embed.media,
237
+
postAuthorDid,
238
+
imgcdn
239
+
);
218
240
} else if (AppBskyEmbedVideo.isMain(embed.media)) {
219
-
hydratedMedia = hydrateEmbedVideo(embed.media, postAuthorDid);
241
+
hydratedMedia = hydrateEmbedVideo(
242
+
embed.media,
243
+
postAuthorDid,
244
+
videocdn
245
+
);
220
246
}
221
247
222
248
if (hydratedMedia) {
···
226
252
usequerypostresults?.data,
227
253
quotedProfile,
228
254
queryidentityresult?.data,
255
+
imgcdn
229
256
);
230
257
}
231
258
}
···
236
263
})();
237
264
238
265
const isLoading = isRecordType
239
-
? usequerypostresults?.isLoading || isLoadingProfile || queryidentityresult?.isLoading
266
+
? usequerypostresults?.isLoading ||
267
+
isLoadingProfile ||
268
+
queryidentityresult?.isLoading
240
269
: false;
241
270
242
-
const error = usequerypostresults?.error || profileError || queryidentityresult?.error;
271
+
const error =
272
+
usequerypostresults?.error || profileError || queryidentityresult?.error;
243
273
244
274
return { data: hydratedEmbed, isLoading, error };
245
-
}
275
+
}
+27
-18
src/utils/useQuery.ts
+27
-18
src/utils/useQuery.ts
···
6
6
useInfiniteQuery,
7
7
useQuery,
8
8
type UseQueryResult} from "@tanstack/react-query";
9
+
import { useAtom } from "jotai";
9
10
10
-
export function constructIdentityQuery(didorhandle?: string) {
11
+
import { constellationURLAtom, slingshotURLAtom } from "./atoms";
12
+
13
+
export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) {
11
14
return queryOptions({
12
15
queryKey: ["identity", didorhandle],
13
16
queryFn: async () => {
14
17
if (!didorhandle) return undefined as undefined
15
18
const res = await fetch(
16
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
19
+
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
17
20
);
18
21
if (!res.ok) throw new Error("Failed to fetch post");
19
22
try {
···
55
58
Error
56
59
>
57
60
export function useQueryIdentity(didorhandle?: string) {
58
-
return useQuery(constructIdentityQuery(didorhandle));
61
+
const [slingshoturl] = useAtom(slingshotURLAtom)
62
+
return useQuery(constructIdentityQuery(didorhandle, slingshoturl));
59
63
}
60
64
61
-
export function constructPostQuery(uri?: string) {
65
+
export function constructPostQuery(uri?: string, slingshoturl?: string) {
62
66
return queryOptions({
63
67
queryKey: ["post", uri],
64
68
queryFn: async () => {
65
69
if (!uri) return undefined as undefined
66
70
const res = await fetch(
67
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
71
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
68
72
);
69
73
let data: any;
70
74
try {
···
118
122
Error
119
123
>
120
124
export function useQueryPost(uri?: string) {
121
-
return useQuery(constructPostQuery(uri));
125
+
const [slingshoturl] = useAtom(slingshotURLAtom)
126
+
return useQuery(constructPostQuery(uri, slingshoturl));
122
127
}
123
128
124
-
export function constructProfileQuery(uri?: string) {
129
+
export function constructProfileQuery(uri?: string, slingshoturl?: string) {
125
130
return queryOptions({
126
131
queryKey: ["profile", uri],
127
132
queryFn: async () => {
128
133
if (!uri) return undefined as undefined
129
134
const res = await fetch(
130
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
135
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
131
136
);
132
137
let data: any;
133
138
try {
···
181
186
Error
182
187
>
183
188
export function useQueryProfile(uri?: string) {
184
-
return useQuery(constructProfileQuery(uri));
189
+
const [slingshoturl] = useAtom(slingshotURLAtom)
190
+
return useQuery(constructProfileQuery(uri, slingshoturl));
185
191
}
186
192
187
193
// export function constructConstellationQuery(
···
217
223
// target: string
218
224
// ): QueryOptions<linksAllResponse, Error>;
219
225
export function constructConstellationQuery(query?:{
226
+
constellation: string,
220
227
method:
221
228
| "/links"
222
229
| "/links/distinct-dids"
···
250
257
const cursor = query.cursor
251
258
const dids = query?.dids
252
259
const res = await fetch(
253
-
`https://constellation.microcosm.blue${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
260
+
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
254
261
);
255
262
if (!res.ok) throw new Error("Failed to fetch post");
256
263
try {
···
339
346
>
340
347
| undefined {
341
348
//if (!query) return;
349
+
const [constellationurl] = useAtom(constellationURLAtom)
342
350
return useQuery(
343
-
constructConstellationQuery(query)
351
+
constructConstellationQuery(query && {constellation: constellationurl, ...query})
344
352
);
345
353
}
346
354
···
445
453
446
454
447
455
448
-
export function constructArbitraryQuery(uri?: string) {
456
+
export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
449
457
return queryOptions({
450
458
queryKey: ["arbitrary", uri],
451
459
queryFn: async () => {
452
460
if (!uri) return undefined as undefined
453
461
const res = await fetch(
454
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
462
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
455
463
);
456
464
let data: any;
457
465
try {
···
504
512
Error
505
513
>;
506
514
export function useQueryArbitrary(uri?: string) {
507
-
return useQuery(constructArbitraryQuery(uri));
515
+
const [slingshoturl] = useAtom(slingshotURLAtom)
516
+
return useQuery(constructArbitraryQuery(uri, slingshoturl));
508
517
}
509
518
510
519
export function constructFallbackNothingQuery(){
···
606
615
}) {
607
616
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
608
617
609
-
return useInfiniteQuery({
618
+
return {...useInfiniteQuery({
610
619
queryKey,
611
620
queryFn,
612
621
initialPageParam: undefined as never,
···
614
623
staleTime: Infinity,
615
624
refetchOnWindowFocus: false,
616
625
enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
617
-
});
626
+
}), queryKey: queryKey};
618
627
}
619
628
620
629
621
630
export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
631
+
constellation: string,
622
632
method: '/links'
623
633
target?: string
624
634
collection: string
625
635
path: string
626
636
}) {
627
-
const constellationHost = 'constellation.microcosm.blue'
628
637
console.log(
629
638
'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
630
639
query,
···
650
659
const cursor = pageParam
651
660
652
661
const res = await fetch(
653
-
`https://${constellationHost}${method}?target=${encodeURIComponent(target)}${
662
+
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
654
663
collection ? `&collection=${encodeURIComponent(collection)}` : ''
655
664
}${path ? `&path=${encodeURIComponent(path)}` : ''}${
656
665
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
+1
-1
vite.config.ts
+1
-1
vite.config.ts