+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
+34
-30
src/routes/__root.tsx
+34
-30
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
···
385
388
text="Home"
386
389
/>
387
390
391
+
<MaterialNavItem
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
+
/>
388
404
<MaterialNavItem
389
405
small
390
406
InactiveIcon={
···
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>
···
549
553
//params: { did: agent.assertDid },
550
554
})
551
555
}
552
-
text="Search"
556
+
text="Explore"
553
557
/>
554
558
{/* <Link
555
559
to="/search"
···
676
680
) : (
677
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">
678
682
<div className="flex items-center gap-2">
679
-
<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))"}} />
680
684
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
681
685
Red Dwarf{" "}
682
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