+1
-1
README.md
+1
-1
README.md
···
8
## running dev and build
9
in the `vite.config.ts` file you should change these values
10
```ts
11
-
const PROD_URL = "https://reddwarf.whey.party"
12
const DEV_URL = "https://local3768forumtest.whey.party"
13
```
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
···
8
## running dev and build
9
in the `vite.config.ts` file you should change these values
10
```ts
11
+
const PROD_URL = "https://reddwarf.app"
12
const DEV_URL = "https://local3768forumtest.whey.party"
13
```
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
"dependencies": {
9
"@atproto/api": "^0.16.6",
10
"@atproto/oauth-client-browser": "^0.3.33",
11
"@radix-ui/react-dropdown-menu": "^2.1.16",
12
"@tailwindcss/vite": "^4.0.6",
13
"@tanstack/query-sync-storage-persister": "^5.85.6",
14
"@tanstack/react-devtools": "^0.2.2",
···
2400
"version": "1.1.15",
2401
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
2402
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
2403
"dependencies": {
2404
"@radix-ui/primitive": "1.1.3",
2405
"@radix-ui/react-compose-refs": "1.1.2",
···
8
"dependencies": {
9
"@atproto/api": "^0.16.6",
10
"@atproto/oauth-client-browser": "^0.3.33",
11
+
"@radix-ui/react-dialog": "^1.1.15",
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",
15
"@tailwindcss/vite": "^4.0.6",
16
"@tanstack/query-sync-storage-persister": "^5.85.6",
17
"@tanstack/react-devtools": "^0.2.2",
···
2403
"version": "1.1.15",
2404
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
2405
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
2406
+
"license": "MIT",
2407
"dependencies": {
2408
"@radix-ui/primitive": "1.1.3",
2409
"@radix-ui/react-compose-refs": "1.1.2",
+3
package.json
+3
package.json
···
12
"dependencies": {
13
"@atproto/api": "^0.16.6",
14
"@atproto/oauth-client-browser": "^0.3.33",
15
+
"@radix-ui/react-dialog": "^1.1.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",
19
"@tailwindcss/vite": "^4.0.6",
20
"@tanstack/query-sync-storage-persister": "^5.85.6",
21
"@tanstack/react-devtools": "^0.2.2",
+174
-107
src/components/Composer.tsx
+174
-107
src/components/Composer.tsx
···
1
-
import { RichText } from "@atproto/api";
2
import { useAtom } from "jotai";
3
-
import { useEffect, useState } from "react";
4
5
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
import { composerAtom } from "~/utils/atoms";
···
9
import { ProfileThing } from "./Login";
10
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
11
12
-
const MAX_POST_LENGTH = 300
13
14
export function Composer() {
15
const [composerState, setComposerState] = useAtom(composerAtom);
···
31
composerState.kind === "reply"
32
? composerState.parent
33
: composerState.kind === "quote"
34
-
? composerState.subject
35
-
: undefined;
36
37
-
const { data: parentPost, isLoading: isParentLoading } = useQueryPost(parentUri);
38
39
async function handlePost() {
40
if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return;
···
46
const rt = new RichText({ text: postText });
47
await rt.detectFacets(agent);
48
49
const record: Record<string, unknown> = {
50
$type: "app.bsky.feed.post",
51
text: rt.text,
···
95
setPosting(false);
96
}
97
}
98
-
99
-
if (composerState.kind === "closed") {
100
-
return null;
101
-
}
102
103
const getPlaceholder = () => {
104
switch (composerState.kind) {
···
111
return "What's happening?!";
112
}
113
};
114
-
115
const charsLeft = MAX_POST_LENGTH - postText.length;
116
const isPostButtonDisabled =
117
-
posting ||
118
-
!postText.trim() ||
119
-
isParentLoading ||
120
-
charsLeft < 0;
121
122
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>
162
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.
181
-
</div>
182
-
)}
183
-
</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
-
/>
198
</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
)}
213
</div>
214
)}
215
-
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>
224
);
225
-
}
···
1
+
import { AppBskyRichtextFacet, RichText } from "@atproto/api";
2
import { useAtom } from "jotai";
3
+
import { Dialog } from "radix-ui";
4
+
import { useEffect, useRef, useState } from "react";
5
6
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
import { composerAtom } from "~/utils/atoms";
···
10
import { ProfileThing } from "./Login";
11
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
12
13
+
const MAX_POST_LENGTH = 300;
14
15
export function Composer() {
16
const [composerState, setComposerState] = useAtom(composerAtom);
···
32
composerState.kind === "reply"
33
? composerState.parent
34
: composerState.kind === "quote"
35
+
? composerState.subject
36
+
: undefined;
37
38
+
const { data: parentPost, isLoading: isParentLoading } =
39
+
useQueryPost(parentUri);
40
41
async function handlePost() {
42
if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return;
···
48
const rt = new RichText({ text: postText });
49
await rt.detectFacets(agent);
50
51
+
if (rt.facets?.length) {
52
+
rt.facets = rt.facets.filter((item) => {
53
+
if (item.$type !== "app.bsky.richtext.facet") return true;
54
+
if (!item.features?.length) return true;
55
+
56
+
item.features = item.features.filter((feature) => {
57
+
if (feature.$type !== "app.bsky.richtext.facet#mention") return true;
58
+
const did = feature.$type === "app.bsky.richtext.facet#mention" ? (feature as AppBskyRichtextFacet.Mention)?.did : undefined;
59
+
return typeof did === "string" && did.startsWith("did:");
60
+
});
61
+
62
+
return item.features.length > 0;
63
+
});
64
+
}
65
+
66
const record: Record<string, unknown> = {
67
$type: "app.bsky.feed.post",
68
text: rt.text,
···
112
setPosting(false);
113
}
114
}
115
+
// if (composerState.kind === "closed") {
116
+
// return null;
117
+
// }
118
119
const getPlaceholder = () => {
120
switch (composerState.kind) {
···
127
return "What's happening?!";
128
}
129
};
130
+
131
const charsLeft = MAX_POST_LENGTH - postText.length;
132
const isPostButtonDisabled =
133
+
posting || !postText.trim() || isParentLoading || charsLeft < 0;
134
135
return (
136
+
<Dialog.Root
137
+
open={composerState.kind !== "closed"}
138
+
onOpenChange={(open) => {
139
+
if (!open) setComposerState({ kind: "closed" });
140
+
}}
141
+
>
142
+
<Dialog.Portal>
143
+
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
144
145
+
<Dialog.Content className="fixed overflow-y-scroll inset-0 z-50 flex items-start justify-center py-10 sm:py-20">
146
+
<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">
147
+
<div className="flex flex-row justify-between p-2">
148
+
<Dialog.Close asChild>
149
+
<button
150
+
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"
151
disabled={posting}
152
+
aria-label="Close"
153
+
>
154
+
<svg
155
+
xmlns="http://www.w3.org/2000/svg"
156
+
width="20"
157
+
height="20"
158
+
viewBox="0 0 24 24"
159
+
fill="none"
160
+
stroke="currentColor"
161
+
strokeWidth="2.5"
162
+
strokeLinecap="round"
163
+
strokeLinejoin="round"
164
+
>
165
+
<line x1="18" y1="6" x2="6" y2="18"></line>
166
+
<line x1="6" y1="6" x2="18" y2="18"></line>
167
+
</svg>
168
+
</button>
169
+
</Dialog.Close>
170
+
171
+
<div className="flex-1" />
172
+
<div className="flex items-center gap-4">
173
+
<span
174
+
className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`}
175
+
>
176
+
{charsLeft}
177
+
</span>
178
+
<button
179
+
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"
180
+
onClick={handlePost}
181
+
disabled={isPostButtonDisabled}
182
+
>
183
+
{posting ? "Posting..." : "Post"}
184
+
</button>
185
</div>
186
</div>
187
+
188
+
{postSuccess ? (
189
+
<div className="flex flex-col items-center justify-center py-16">
190
+
<span className="text-gray-500 text-6xl mb-4">โ</span>
191
+
<span className="text-xl font-bold text-black dark:text-white">
192
+
Posted!
193
+
</span>
194
+
</div>
195
+
) : (
196
+
<div className="px-4">
197
+
{composerState.kind === "reply" && (
198
+
<div className="mb-1 -mx-4">
199
+
{isParentLoading ? (
200
+
<div className="text-sm text-gray-500 animate-pulse">
201
+
Loading parent post...
202
+
</div>
203
+
) : parentUri ? (
204
+
<UniversalPostRendererATURILoader
205
+
atUri={parentUri}
206
+
bottomReplyLine
207
+
bottomBorder={false}
208
+
/>
209
+
) : (
210
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
211
+
Could not load parent post.
212
+
</div>
213
+
)}
214
</div>
215
+
)}
216
+
217
+
<div className="flex w-full gap-1 flex-col">
218
+
<ProfileThing agent={agent} large />
219
+
<div className="flex pl-[50px]">
220
+
<AutoGrowTextarea
221
+
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2"
222
+
rows={5}
223
+
placeholder={getPlaceholder()}
224
+
value={postText}
225
+
onChange={(e) => setPostText(e.target.value)}
226
+
disabled={posting}
227
+
autoFocus
228
+
/>
229
+
</div>
230
+
</div>
231
+
232
+
{composerState.kind === "quote" && (
233
+
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
234
+
{isParentLoading ? (
235
+
<div className="text-sm text-gray-500 animate-pulse">
236
+
Loading parent post...
237
+
</div>
238
+
) : parentUri ? (
239
+
<UniversalPostRendererATURILoader
240
+
atUri={parentUri}
241
+
isQuote
242
+
/>
243
+
) : (
244
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
245
+
Could not load parent post.
246
+
</div>
247
+
)}
248
+
</div>
249
+
)}
250
+
251
+
{postError && (
252
+
<div className="text-red-500 text-sm my-2 text-center">
253
+
{postError}
254
</div>
255
)}
256
</div>
257
)}
258
</div>
259
+
</Dialog.Content>
260
+
</Dialog.Portal>
261
+
</Dialog.Root>
262
+
);
263
+
}
264
+
265
+
function AutoGrowTextarea({
266
+
value,
267
+
className,
268
+
onChange,
269
+
...props
270
+
}: React.DetailedHTMLProps<
271
+
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
272
+
HTMLTextAreaElement
273
+
>) {
274
+
const ref = useRef<HTMLTextAreaElement>(null);
275
+
276
+
useEffect(() => {
277
+
const el = ref.current;
278
+
if (!el) return;
279
+
el.style.height = "auto";
280
+
el.style.height = el.scrollHeight + "px";
281
+
}, [value]);
282
+
283
+
return (
284
+
<textarea
285
+
ref={ref}
286
+
className={className}
287
+
value={value}
288
+
onChange={onChange}
289
+
{...props}
290
+
/>
291
);
292
+
}
+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 * as React from "react";
2
3
//import { useInView } from "react-intersection-observer";
···
37
isFetchingNextPage,
38
refetch,
39
isRefetching,
40
} = useInfiniteQueryFeedSkeleton({
41
feedUri: feedUri,
42
agent: agent ?? undefined,
···
44
pdsUrl: pdsUrl,
45
feedServiceDid: feedServiceDid,
46
});
47
48
const handleRefresh = () => {
49
refetch();
50
};
51
52
//const { ref, inView } = useInView();
53
54
// React.useEffect(() => {
···
67
);
68
}
69
70
-
const allPosts =
71
-
data?.pages.flatMap((page) => {
72
-
if (page) return page.feed;
73
-
}) ?? [];
74
75
if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) {
76
return (
···
116
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
aria-label="Refresh feed"
118
>
119
-
<RefreshIcon className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} />
120
</button>
121
</>
122
);
···
139
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
></path>
141
</svg>
142
-
);
···
1
+
import { useQueryClient } from "@tanstack/react-query";
2
import * as React from "react";
3
4
//import { useInView } from "react-intersection-observer";
···
38
isFetchingNextPage,
39
refetch,
40
isRefetching,
41
+
queryKey,
42
} = useInfiniteQueryFeedSkeleton({
43
feedUri: feedUri,
44
agent: agent ?? undefined,
···
46
pdsUrl: pdsUrl,
47
feedServiceDid: feedServiceDid,
48
});
49
+
const queryClient = useQueryClient();
50
+
51
52
const handleRefresh = () => {
53
+
queryClient.removeQueries({queryKey: queryKey});
54
+
//queryClient.invalidateQueries(["infinite-feed", feedUri] as const);
55
refetch();
56
};
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
+
76
//const { ref, inView } = useInView();
77
78
// React.useEffect(() => {
···
91
);
92
}
93
94
+
// const allPosts =
95
+
// data?.pages.flatMap((page) => {
96
+
// if (page) return page.feed;
97
+
// }) ?? [];
98
99
if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) {
100
return (
···
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"
141
aria-label="Refresh feed"
142
>
143
+
<RefreshIcon
144
+
className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`}
145
+
/>
146
</button>
147
</>
148
);
···
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"
166
></path>
167
</svg>
168
+
);
+78
-34
src/components/Login.tsx
+78
-34
src/components/Login.tsx
···
1
// src/components/Login.tsx
2
-
import { Agent } from "@atproto/api";
3
import React, { useEffect, useRef, useState } from "react";
4
5
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
7
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
8
export default function Login({
···
21
className={
22
compact
23
? "flex items-center justify-center p-1"
24
-
: "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]"
25
}
26
>
27
<span
···
40
// Large view
41
if (!compact) {
42
return (
43
-
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4">
44
<div className="flex flex-col items-center justify-center text-center">
45
<p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100">
46
You are logged in!
···
74
if (!compact) {
75
// Large view renders the form directly in the card
76
return (
77
-
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4">
78
<UnifiedLoginForm />
79
</div>
80
);
···
110
// --- 3. Helper components for layouts, forms, and UI ---
111
112
// A new component to contain the logic for the compact dropdown
113
-
const CompactLoginButton = ({popup}:{popup?: boolean}) => {
114
const [showForm, setShowForm] = useState(false);
115
const formRef = useRef<HTMLDivElement>(null);
116
···
137
Log in
138
</button>
139
{showForm && (
140
-
<div className={`absolute ${popup ? `bottom-[calc(100%)]` :`top-full`} right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50`}>
141
<UnifiedLoginForm />
142
</div>
143
)}
···
158
onClick={onClick}
159
className={`px-4 py-2 text-sm font-medium transition-colors rounded-full flex-1 ${
160
active
161
-
? "text-gray-950 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500"
162
: "text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200"
163
}`}
164
>
···
187
<p className="text-xs text-gray-500 dark:text-gray-400">
188
Sign in with AT. Your password is never shared.
189
</p>
190
-
<input
191
type="text"
192
placeholder="handle.bsky.social"
193
value={handle}
194
onChange={(e) => setHandle(e.target.value)}
195
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"
196
-
/>
197
-
<button
198
-
type="submit"
199
-
className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors"
200
-
>
201
-
Log in
202
-
</button>
203
</form>
204
);
205
};
···
232
<p className="text-xs text-red-500 dark:text-red-400">
233
Warning: Less secure. Use an App Password.
234
</p>
235
-
<input
236
type="text"
237
placeholder="handle.bsky.social"
238
value={user}
···
254
value={serviceURL}
255
onChange={(e) => setServiceURL(e.target.value)}
256
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"
257
-
/>
258
{error && <p className="text-xs text-red-500">{error}</p>}
259
<button
260
type="submit"
···
274
agent: Agent | null;
275
large?: boolean;
276
}) => {
277
-
const [profile, setProfile] = useState<any>(null);
278
279
-
useEffect(() => {
280
-
const fetchUser = async () => {
281
-
const did = (agent as any)?.session?.did ?? (agent as any)?.assertDid;
282
-
if (!did) return;
283
-
try {
284
-
const res = await agent!.getProfile({ actor: did });
285
-
setProfile(res.data);
286
-
} catch (e) {
287
-
console.error("Failed to fetch profile", e);
288
-
}
289
-
};
290
-
if (agent) fetchUser();
291
-
}, [agent]);
292
293
-
if (!profile) {
294
return (
295
// Skeleton loader
296
<div
···
316
className={`flex flex-row items-center gap-2.5 ${large ? "mb-1" : ""}`}
317
>
318
<img
319
-
src={profile?.avatar}
320
alt="avatar"
321
className={`object-cover rounded-full ${large ? "w-10 h-10" : "w-[30px] h-[30px]"}`}
322
/>
···
329
<div
330
className={` ${large ? "text-gray-500 dark:text-gray-400 text-sm" : "text-gray-500 dark:text-gray-400 text-xs"}`}
331
>
332
-
@{profile?.handle}
333
</div>
334
</div>
335
</div>
···
1
// src/components/Login.tsx
2
+
import AtpAgent, { Agent } from "@atproto/api";
3
+
import { useAtom } from "jotai";
4
import React, { useEffect, useRef, useState } from "react";
5
6
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
+
import { imgCDNAtom } from "~/utils/atoms";
8
+
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
9
10
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
11
export default function Login({
···
24
className={
25
compact
26
? "flex items-center justify-center p-1"
27
+
: "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]"
28
}
29
>
30
<span
···
43
// Large view
44
if (!compact) {
45
return (
46
+
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4">
47
<div className="flex flex-col items-center justify-center text-center">
48
<p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100">
49
You are logged in!
···
77
if (!compact) {
78
// Large view renders the form directly in the card
79
return (
80
+
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4">
81
<UnifiedLoginForm />
82
</div>
83
);
···
113
// --- 3. Helper components for layouts, forms, and UI ---
114
115
// A new component to contain the logic for the compact dropdown
116
+
const CompactLoginButton = ({ popup }: { popup?: boolean }) => {
117
const [showForm, setShowForm] = useState(false);
118
const formRef = useRef<HTMLDivElement>(null);
119
···
140
Log in
141
</button>
142
{showForm && (
143
+
<div
144
+
className={`absolute ${popup ? `bottom-[calc(100%)]` : `top-full`} right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50`}
145
+
>
146
<UnifiedLoginForm />
147
</div>
148
)}
···
163
onClick={onClick}
164
className={`px-4 py-2 text-sm font-medium transition-colors rounded-full flex-1 ${
165
active
166
+
? "text-gray-50 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500"
167
: "text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200"
168
}`}
169
>
···
192
<p className="text-xs text-gray-500 dark:text-gray-400">
193
Sign in with AT. Your password is never shared.
194
</p>
195
+
{/* <input
196
type="text"
197
placeholder="handle.bsky.social"
198
value={handle}
199
onChange={(e) => setHandle(e.target.value)}
200
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"
201
+
/> */}
202
+
<div className="flex flex-col gap-3">
203
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
204
+
<input
205
+
type="text"
206
+
placeholder=" "
207
+
value={handle}
208
+
onChange={(e) => setHandle(e.target.value)}
209
+
/>
210
+
<label>AT Handle</label>
211
+
</div>
212
+
<button
213
+
type="submit"
214
+
className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors"
215
+
>
216
+
Log in
217
+
</button>
218
+
</div>
219
</form>
220
);
221
};
···
248
<p className="text-xs text-red-500 dark:text-red-400">
249
Warning: Less secure. Use an App Password.
250
</p>
251
+
{/* <input
252
type="text"
253
placeholder="handle.bsky.social"
254
value={user}
···
270
value={serviceURL}
271
onChange={(e) => setServiceURL(e.target.value)}
272
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"
273
+
/> */}
274
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
275
+
<input
276
+
type="text"
277
+
placeholder=" "
278
+
value={user}
279
+
onChange={(e) => setUser(e.target.value)}
280
+
/>
281
+
<label>AT Handle</label>
282
+
</div>
283
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
284
+
<input
285
+
type="text"
286
+
placeholder=" "
287
+
value={password}
288
+
onChange={(e) => setPassword(e.target.value)}
289
+
/>
290
+
<label>App Password</label>
291
+
</div>
292
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
293
+
<input
294
+
type="text"
295
+
placeholder=" "
296
+
value={serviceURL}
297
+
onChange={(e) => setServiceURL(e.target.value)}
298
+
/>
299
+
<label>PDS</label>
300
+
</div>
301
{error && <p className="text-xs text-red-500">{error}</p>}
302
<button
303
type="submit"
···
317
agent: Agent | null;
318
large?: boolean;
319
}) => {
320
+
const did = ((agent as AtpAgent)?.session?.did ??
321
+
(agent as AtpAgent)?.assertDid ??
322
+
agent?.did) as string | undefined;
323
+
const { data: identity } = useQueryIdentity(did);
324
+
const { data: profiledata } = useQueryProfile(
325
+
`at://${did}/app.bsky.actor.profile/self`
326
+
);
327
+
const profile = profiledata?.value;
328
329
+
const [imgcdn] = useAtom(imgCDNAtom)
330
331
+
function getAvatarUrl(p: typeof profile) {
332
+
const link = p?.avatar?.ref?.["$link"];
333
+
if (!link || !did) return null;
334
+
return `https://${imgcdn}/img/avatar/plain/${did}/${link}@jpeg`;
335
+
}
336
+
337
+
if (!profiledata) {
338
return (
339
// Skeleton loader
340
<div
···
360
className={`flex flex-row items-center gap-2.5 ${large ? "mb-1" : ""}`}
361
>
362
<img
363
+
src={getAvatarUrl(profile) ?? undefined}
364
alt="avatar"
365
className={`object-cover rounded-full ${large ? "w-10 h-10" : "w-[30px] h-[30px]"}`}
366
/>
···
373
<div
374
className={` ${large ? "text-gray-500 dark:text-gray-400 text-sm" : "text-gray-500 dark:text-gray-400 text-xs"}`}
375
>
376
+
@{identity?.handle}
377
</div>
378
</div>
379
</div>
+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
+
}
+152
-68
src/components/UniversalPostRenderer.tsx
+152
-68
src/components/UniversalPostRenderer.tsx
···
2
import DOMPurify from "dompurify";
3
import { useAtom } from "jotai";
4
import { DropdownMenu } from "radix-ui";
5
import * as React from "react";
6
import { type SVGProps } from "react";
7
8
-
import { composerAtom, likedPostsAtom } from "~/utils/atoms";
9
import { useHydratedEmbed } from "~/utils/useHydrated";
10
import {
11
useQueryConstellation,
···
150
maxReplies,
151
isQuote,
152
}: UniversalPostRendererATURILoaderProps) {
153
// /*mass comment*/ console.log("atUri", atUri);
154
//const { get, set } = usePersistentStore();
155
//const [record, setRecord] = React.useState<any>(null);
···
401
// path: ".reply.parent.uri",
402
// });
403
404
const infinitequeryresults = useInfiniteQuery({
405
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
406
{
407
method: "/links",
408
target: atUri,
409
collection: "app.bsky.feed.post",
···
422
423
// auto-fetch all pages
424
useEffect(() => {
425
-
if (!maxReplies || isQuote) return;
426
if (
427
infinitequeryresults.hasNextPage &&
428
!infinitequeryresults.isFetchingNextPage
···
430
console.log("Fetching the next page...");
431
infinitequeryresults.fetchNextPage();
432
}
433
-
}, [infinitequeryresults]);
434
435
const replyAturis = repliesData
436
? repliesData.pages.flatMap((page) =>
···
507
? true
508
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
509
? false
510
-
: bottomReplyLine
511
}
512
topReplyLine={topReplyLine}
513
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
529
maxReplies={maxReplies}
530
isQuote={isQuote}
531
/>
532
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
533
<>
534
{/* <span>hello {maxReplies}</span> */}
···
553
maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
554
}
555
/>
556
-
{maxReplies && maxReplies - 1 === 0 && replies && replies > 0 && (
557
-
<MoreReplies atUri={oldestOpsReplyElseNewestNonOpsReply} />
558
-
)}
559
</>
560
)}
561
</>
···
596
);
597
}
598
599
-
function getAvatarUrl(opProfile: any, did: string) {
600
const link = opProfile?.value?.avatar?.ref?.["$link"];
601
if (!link) return null;
602
-
return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`;
603
}
604
605
export function UniversalPostRendererRawRecordShim({
···
720
error: embedError,
721
} = useHydratedEmbed(postRecord?.value?.embed, resolved?.did);
722
723
const parsedaturi = new AtUri(aturi); //parseAtUri(aturi);
724
725
const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(
726
() => ({
727
$type: "app.bsky.feed.defs#postView",
728
uri: aturi,
729
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
-
},
739
record: postRecord?.value || {},
740
embed: hydratedEmbed ?? undefined,
741
replyCount: repliesCount ?? 0,
···
752
postRecord?.cid,
753
postRecord?.value,
754
postRecord?.labels,
755
-
resolved?.did,
756
-
resolved?.handle,
757
-
profileRecord,
758
hydratedEmbed,
759
repliesCount,
760
repostsCount,
···
831
}
832
}}
833
post={fakepost}
834
salt={aturi}
835
bottomReplyLine={bottomReplyLine}
836
topReplyLine={topReplyLine}
···
883
{...props}
884
>
885
<path
886
-
fill="oklch(0.704 0.05 28)"
887
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
></path>
889
</svg>
···
900
{...props}
901
>
902
<path
903
-
fill="oklch(0.704 0.05 28)"
904
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
905
></path>
906
</svg>
···
951
{...props}
952
>
953
<path
954
-
fill="oklch(0.704 0.05 28)"
955
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
></path>
957
</svg>
···
968
{...props}
969
>
970
<path
971
-
fill="oklch(0.704 0.05 28)"
972
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
></path>
974
</svg>
···
985
{...props}
986
>
987
<path
988
-
fill="oklch(0.704 0.05 28)"
989
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
></path>
991
</svg>
···
1002
{...props}
1003
>
1004
<path
1005
-
fill="oklch(0.704 0.05 28)"
1006
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
></path>
1008
</svg>
···
1036
{...props}
1037
>
1038
<path
1039
-
fill="oklch(0.704 0.05 28)"
1040
d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11"
1041
></path>
1042
</svg>
···
1090
{...props}
1091
>
1092
<path
1093
-
fill="oklch(0.704 0.05 28)"
1094
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
1095
></path>
1096
</svg>
···
1107
{...props}
1108
>
1109
<path
1110
-
fill="oklch(0.704 0.05 28)"
1111
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
></path>
1113
</svg>
···
1135
//import Masonry from "@mui/lab/Masonry";
1136
import {
1137
type $Typed,
1138
AppBskyEmbedDefs,
1139
AppBskyEmbedExternal,
1140
AppBskyEmbedImages,
···
1164
1165
import defaultpfp from "~/../public/favicon.png";
1166
import { useAuth } from "~/providers/UnifiedAuthProvider";
1167
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1168
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1169
// import type {
···
1272
1273
function UniversalPostRenderer({
1274
post,
1275
//setMainItem,
1276
//isMainItem,
1277
onPostClick,
···
1296
maxReplies,
1297
}: {
1298
post: PostView;
1299
// optional for now because i havent ported every use to this yet
1300
// setMainItem?: React.Dispatch<
1301
// React.SetStateAction<AppBskyFeedDefs.FeedViewPost>
···
1479
className="bg-gray-500 dark:bg-gray-400"
1480
/>
1481
)}
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>
1519
<div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}>
1520
<div
1521
style={{
···
2571
return { start, end, feature: f.features[0] };
2572
});
2573
}
2574
-
function renderTextWithFacets({
2575
text,
2576
facets,
2577
navigate,
···
2
import DOMPurify from "dompurify";
3
import { useAtom } from "jotai";
4
import { DropdownMenu } from "radix-ui";
5
+
import { HoverCard } from "radix-ui";
6
import * as React from "react";
7
import { type SVGProps } from "react";
8
9
+
import {
10
+
composerAtom,
11
+
constellationURLAtom,
12
+
imgCDNAtom,
13
+
likedPostsAtom,
14
+
} from "~/utils/atoms";
15
import { useHydratedEmbed } from "~/utils/useHydrated";
16
import {
17
useQueryConstellation,
···
156
maxReplies,
157
isQuote,
158
}: UniversalPostRendererATURILoaderProps) {
159
+
// todo remove this once tree rendering is implemented, use a prop like isTree
160
+
const TEMPLINEAR = true;
161
// /*mass comment*/ console.log("atUri", atUri);
162
//const { get, set } = usePersistentStore();
163
//const [record, setRecord] = React.useState<any>(null);
···
409
// path: ".reply.parent.uri",
410
// });
411
412
+
const [constellationurl] = useAtom(constellationURLAtom);
413
+
414
const infinitequeryresults = useInfiniteQuery({
415
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
416
{
417
+
constellation: constellationurl,
418
method: "/links",
419
target: atUri,
420
collection: "app.bsky.feed.post",
···
433
434
// auto-fetch all pages
435
useEffect(() => {
436
+
if (!maxReplies || isQuote || TEMPLINEAR) return;
437
if (
438
infinitequeryresults.hasNextPage &&
439
!infinitequeryresults.isFetchingNextPage
···
441
console.log("Fetching the next page...");
442
infinitequeryresults.fetchNextPage();
443
}
444
+
}, [TEMPLINEAR, infinitequeryresults, isQuote, maxReplies]);
445
446
const replyAturis = repliesData
447
? repliesData.pages.flatMap((page) =>
···
518
? true
519
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
520
? false
521
+
: (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine
522
}
523
topReplyLine={topReplyLine}
524
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
540
maxReplies={maxReplies}
541
isQuote={isQuote}
542
/>
543
+
<>
544
+
{(maxReplies && maxReplies === 0 && replies && replies > 0) ? (
545
+
<>
546
+
{/* <div>hello</div> */}
547
+
<MoreReplies atUri={atUri} />
548
+
</>
549
+
) : (<></>)}
550
+
</>
551
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
552
<>
553
{/* <span>hello {maxReplies}</span> */}
···
572
maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
573
}
574
/>
575
</>
576
)}
577
</>
···
612
);
613
}
614
615
+
function getAvatarUrl(opProfile: any, did: string, cdn: string) {
616
const link = opProfile?.value?.avatar?.ref?.["$link"];
617
if (!link) return null;
618
+
return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`;
619
}
620
621
export function UniversalPostRendererRawRecordShim({
···
736
error: embedError,
737
} = useHydratedEmbed(postRecord?.value?.embed, resolved?.did);
738
739
+
const [imgcdn] = useAtom(imgCDNAtom);
740
+
741
const parsedaturi = new AtUri(aturi); //parseAtUri(aturi);
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
+
766
const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(
767
() => ({
768
$type: "app.bsky.feed.defs#postView",
769
uri: aturi,
770
cid: postRecord?.cid || "",
771
+
author: fakeprofileviewbasic,
772
record: postRecord?.value || {},
773
embed: hydratedEmbed ?? undefined,
774
replyCount: repliesCount ?? 0,
···
785
postRecord?.cid,
786
postRecord?.value,
787
postRecord?.labels,
788
+
fakeprofileviewbasic,
789
hydratedEmbed,
790
repliesCount,
791
repostsCount,
···
862
}
863
}}
864
post={fakepost}
865
+
uprrrsauthor={fakeprofileviewdetailed}
866
salt={aturi}
867
bottomReplyLine={bottomReplyLine}
868
topReplyLine={topReplyLine}
···
915
{...props}
916
>
917
<path
918
+
fill="var(--color-gray-400)"
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"
920
></path>
921
</svg>
···
932
{...props}
933
>
934
<path
935
+
fill="var(--color-gray-400)"
936
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
937
></path>
938
</svg>
···
983
{...props}
984
>
985
<path
986
+
fill="var(--color-gray-400)"
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"
988
></path>
989
</svg>
···
1000
{...props}
1001
>
1002
<path
1003
+
fill="var(--color-gray-400)"
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"
1005
></path>
1006
</svg>
···
1017
{...props}
1018
>
1019
<path
1020
+
fill="var(--color-gray-400)"
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"
1022
></path>
1023
</svg>
···
1034
{...props}
1035
>
1036
<path
1037
+
fill="var(--color-gray-400)"
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"
1039
></path>
1040
</svg>
···
1068
{...props}
1069
>
1070
<path
1071
+
fill="var(--color-gray-400)"
1072
d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11"
1073
></path>
1074
</svg>
···
1122
{...props}
1123
>
1124
<path
1125
+
fill="var(--color-gray-400)"
1126
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
1127
></path>
1128
</svg>
···
1139
{...props}
1140
>
1141
<path
1142
+
fill="var(--color-gray-400)"
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"
1144
></path>
1145
</svg>
···
1167
//import Masonry from "@mui/lab/Masonry";
1168
import {
1169
type $Typed,
1170
+
AppBskyActorDefs,
1171
AppBskyEmbedDefs,
1172
AppBskyEmbedExternal,
1173
AppBskyEmbedImages,
···
1197
1198
import defaultpfp from "~/../public/favicon.png";
1199
import { useAuth } from "~/providers/UnifiedAuthProvider";
1200
+
import { FollowButton, Mutual } from "~/routes/profile.$did";
1201
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1202
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1203
// import type {
···
1306
1307
function UniversalPostRenderer({
1308
post,
1309
+
uprrrsauthor,
1310
//setMainItem,
1311
//isMainItem,
1312
onPostClick,
···
1331
maxReplies,
1332
}: {
1333
post: PostView;
1334
+
uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed;
1335
// optional for now because i havent ported every use to this yet
1336
// setMainItem?: React.Dispatch<
1337
// React.SetStateAction<AppBskyFeedDefs.FeedViewPost>
···
1515
className="bg-gray-500 dark:bg-gray-400"
1516
/>
1517
)}
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
+
<FollowButton targetdidorhandle={post.author.did} />
1561
+
</div>
1562
+
</div>
1563
+
<div className="flex flex-col gap-3">
1564
+
<div>
1565
+
<div className="text-gray-900 dark:text-gray-100 font-medium text-md">
1566
+
{post.author.displayName || post.author.handle}{" "}
1567
+
</div>
1568
+
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1569
+
<Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "}
1570
+
</div>
1571
+
</div>
1572
+
{uprrrsauthor?.description && (
1573
+
<div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3">
1574
+
{uprrrsauthor.description}
1575
+
</div>
1576
+
)}
1577
+
{/* <div className="flex gap-4">
1578
+
<div className="flex gap-1">
1579
+
<div className="font-medium text-gray-900 dark:text-gray-100">
1580
+
0
1581
+
</div>
1582
+
<div className="text-gray-500 dark:text-gray-400">
1583
+
Following
1584
+
</div>
1585
+
</div>
1586
+
<div className="flex gap-1">
1587
+
<div className="font-medium text-gray-900 dark:text-gray-100">
1588
+
2,900
1589
+
</div>
1590
+
<div className="text-gray-500 dark:text-gray-400">
1591
+
Followers
1592
+
</div>
1593
+
</div>
1594
+
</div> */}
1595
+
</div>
1596
+
</div>
1597
+
1598
+
{/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */}
1599
+
</HoverCard.Content>
1600
+
</HoverCard.Portal>
1601
+
</HoverCard.Root>
1602
+
1603
<div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}>
1604
<div
1605
style={{
···
2655
return { start, end, feature: f.features[0] };
2656
});
2657
}
2658
+
export function renderTextWithFacets({
2659
text,
2660
facets,
2661
navigate,
+2
src/main.tsx
+2
src/main.tsx
+35
-33
src/routes/__root.tsx
+35
-33
src/routes/__root.tsx
···
18
19
import { Composer } from "~/components/Composer";
20
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
21
import Login from "~/components/Login";
22
import { NotFound } from "~/components/NotFound";
23
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
24
-
import { composerAtom } from "~/utils/atoms";
25
import { seo } from "~/utils/seo";
26
27
export const Route = createRootRouteWithContext<{
···
87
}
88
89
function RootDocument({ children }: { children: React.ReactNode }) {
90
const location = useLocation();
91
const navigate = useNavigate();
92
const { agent } = useAuth();
···
128
<div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950">
129
<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
<div className="flex items-center gap-3 mb-4">
131
-
<img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" />
132
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
133
Red Dwarf{" "}
134
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
152
/>
153
154
<MaterialNavItem
155
InactiveIcon={
156
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
157
}
···
178
})
179
}
180
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
/>
194
<MaterialNavItem
195
InactiveIcon={
···
367
368
<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
<div className="flex items-center gap-3 mb-4">
370
-
<img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" />
371
</div>
372
<MaterialNavItem
373
small
···
387
388
<MaterialNavItem
389
small
390
InactiveIcon={
391
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
392
}
···
417
/>
418
<MaterialNavItem
419
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
InactiveIcon={
434
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
435
}
···
496
</main>
497
498
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
499
<Login />
500
501
<div className="flex-1"></div>
502
<p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4">
503
-
Red Dwarf is a bluesky client that uses Constellation and direct PDS
504
-
queries. Skylite would be a self-hosted bluesky "instance". Stay
505
-
tuned for the release of Skylite.
506
</p>
507
</aside>
508
</div>
···
551
//params: { did: agent.assertDid },
552
})
553
}
554
-
text="Search"
555
/>
556
{/* <Link
557
to="/search"
···
678
) : (
679
<div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10">
680
<div className="flex items-center gap-2">
681
-
<img src="/redstar.png" alt="Red Dwarf Logo" className="w-6 h-6" />
682
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
683
Red Dwarf{" "}
684
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
18
19
import { Composer } from "~/components/Composer";
20
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
21
+
import { Import } from "~/components/Import";
22
import Login from "~/components/Login";
23
import { NotFound } from "~/components/NotFound";
24
+
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
25
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
26
+
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
27
import { seo } from "~/utils/seo";
28
29
export const Route = createRootRouteWithContext<{
···
89
}
90
91
function RootDocument({ children }: { children: React.ReactNode }) {
92
+
useAtomCssVar(hueAtom, "--tw-gray-hue");
93
const location = useLocation();
94
const navigate = useNavigate();
95
const { agent } = useAuth();
···
131
<div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950">
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">
133
<div className="flex items-center gap-3 mb-4">
134
+
<FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
135
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
136
Red Dwarf{" "}
137
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
155
/>
156
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
170
InactiveIcon={
171
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
172
}
···
193
})
194
}
195
text="Feeds"
196
/>
197
<MaterialNavItem
198
InactiveIcon={
···
370
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">
372
<div className="flex items-center gap-3 mb-4">
373
+
<FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
374
</div>
375
<MaterialNavItem
376
small
···
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
+
/>
404
+
<MaterialNavItem
405
+
small
406
InactiveIcon={
407
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
408
}
···
433
/>
434
<MaterialNavItem
435
small
436
InactiveIcon={
437
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
438
}
···
499
</main>
500
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>
503
<Login />
504
505
<div className="flex-1"></div>
506
<p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4">
507
+
Red Dwarf is a Bluesky client that does not rely on any Bluesky API App Servers. Instead, it uses Microcosm to fetch records directly from each users' PDS (via Slingshot) and connect them using backlinks (via Constellation)
508
</p>
509
</aside>
510
</div>
···
553
//params: { did: agent.assertDid },
554
})
555
}
556
+
text="Explore"
557
/>
558
{/* <Link
559
to="/search"
···
680
) : (
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">
682
<div className="flex items-center gap-2">
683
+
<FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
684
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
685
Red Dwarf{" "}
686
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
+1
src/routes/index.tsx
+1
src/routes/index.tsx
+7
-3
src/routes/notifications.tsx
+7
-3
src/routes/notifications.tsx
···
1
import { createFileRoute } from "@tanstack/react-router";
2
import React, { useEffect, useRef,useState } from "react";
3
4
import { useAuth } from "~/providers/UnifiedAuthProvider";
5
6
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
7
···
70
}
71
}
72
73
useEffect(() => {
74
if (!did) return;
75
setLoading(true);
76
setError(null);
77
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`,
81
];
82
let ignore = false;
83
Promise.all(
···
1
import { createFileRoute } from "@tanstack/react-router";
2
+
import { useAtom } from "jotai";
3
import React, { useEffect, useRef,useState } from "react";
4
5
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
+
import { constellationURLAtom } from "~/utils/atoms";
7
8
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
9
···
72
}
73
}
74
75
+
const [constellationURL] = useAtom(constellationURLAtom)
76
+
77
useEffect(() => {
78
if (!did) return;
79
setLoading(true);
80
setError(null);
81
const urls = [
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`,
85
];
86
let ignore = false;
87
Promise.all(
+194
-56
src/routes/profile.$did/index.tsx
+194
-56
src/routes/profile.$did/index.tsx
···
1
import { useQueryClient } from "@tanstack/react-query";
2
-
import { createFileRoute } from "@tanstack/react-router";
3
-
import React from "react";
4
5
import { Header } from "~/components/Header";
6
-
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
7
import { useAuth } from "~/providers/UnifiedAuthProvider";
8
-
import { toggleFollow, useGetFollowState } from "~/utils/followState";
9
import {
10
-
useInfiniteQueryAuthorFeed,
11
useQueryIdentity,
12
useQueryProfile,
13
} from "~/utils/useQuery";
···
19
function ProfileComponent() {
20
// booo bad this is not always the did it might be a handle, use identity.did instead
21
const { did } = Route.useParams();
22
const queryClient = useQueryClient();
23
-
const { agent } = useAuth();
24
const {
25
data: identity,
26
isLoading: isIdentityLoading,
27
error: identityError,
28
} = useQueryIdentity(did);
29
30
-
const followRecords = useGetFollowState({
31
-
target: identity?.did || did,
32
-
user: agent?.did,
33
-
});
34
-
35
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
36
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
37
-
const pdsUrl = identity?.pds;
38
39
const profileUri = resolvedDid
40
? `at://${resolvedDid}/app.bsky.actor.profile/self`
···
42
const { data: profileRecord } = useQueryProfile(profileUri);
43
const profile = profileRecord?.value;
44
45
const {
46
data: postsData,
47
fetchNextPage,
48
hasNextPage,
49
isFetchingNextPage,
50
isLoading: arePostsLoading,
51
-
} = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl);
52
53
React.useEffect(() => {
54
if (postsData) {
55
postsData.pages.forEach((page) => {
56
-
page.records.forEach((record) => {
57
if (!queryClient.getQueryData(["post", record.uri])) {
58
queryClient.setQueryData(["post", record.uri], record);
59
}
···
63
}, [postsData, queryClient]);
64
65
const posts = React.useMemo(
66
-
() => postsData?.pages.flatMap((page) => page.records) ?? [],
67
[postsData]
68
);
69
70
function getAvatarUrl(p: typeof profile) {
71
const link = p?.avatar?.ref?.["$link"];
72
if (!link || !resolvedDid) return null;
73
-
return `https://cdn.bsky.app/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
74
}
75
function getBannerUrl(p: typeof profile) {
76
const link = p?.banner?.ref?.["$link"];
77
if (!link || !resolvedDid) return null;
78
-
return `https://cdn.bsky.app/img/banner/plain/${resolvedDid}/${link}@jpeg`;
79
}
80
81
const displayName =
···
162
also delay the backfill to be on demand because it would be pretty intense
163
also save it persistently
164
*/}
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]">
203
... {/* todo: icon */}
204
</button>
···
207
{/* Info Card */}
208
<div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100">
209
<div className="font-bold text-2xl">{displayName}</div>
210
-
<div className="text-gray-500 dark:text-gray-400 text-base mb-3">
211
{handle}
212
</div>
213
{description && (
214
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
215
-
{description}
216
</div>
217
)}
218
</div>
···
255
</>
256
);
257
}
···
1
+
import { RichText } from "@atproto/api";
2
import { useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
5
+
import React, { type ReactNode, useEffect, useState } from "react";
6
7
import { Header } from "~/components/Header";
8
+
import {
9
+
renderTextWithFacets,
10
+
UniversalPostRendererATURILoader,
11
+
} from "~/components/UniversalPostRenderer";
12
import { useAuth } from "~/providers/UnifiedAuthProvider";
13
+
import { aturiListServiceAtom, imgCDNAtom } from "~/utils/atoms";
14
import {
15
+
toggleFollow,
16
+
useGetFollowState,
17
+
useGetOneToOneState,
18
+
} from "~/utils/followState";
19
+
import {
20
+
useInfiniteQueryAturiList,
21
useQueryIdentity,
22
useQueryProfile,
23
} from "~/utils/useQuery";
···
29
function ProfileComponent() {
30
// booo bad this is not always the did it might be a handle, use identity.did instead
31
const { did } = Route.useParams();
32
+
//const navigate = useNavigate();
33
const queryClient = useQueryClient();
34
const {
35
data: identity,
36
isLoading: isIdentityLoading,
37
error: identityError,
38
} = useQueryIdentity(did);
39
40
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
41
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
42
+
//const pdsUrl = identity?.pds;
43
44
const profileUri = resolvedDid
45
? `at://${resolvedDid}/app.bsky.actor.profile/self`
···
47
const { data: profileRecord } = useQueryProfile(profileUri);
48
const profile = profileRecord?.value;
49
50
+
const [aturilistservice] = useAtom(aturiListServiceAtom);
51
+
52
const {
53
data: postsData,
54
fetchNextPage,
55
hasNextPage,
56
isFetchingNextPage,
57
isLoading: arePostsLoading,
58
+
} = useInfiniteQueryAturiList({
59
+
aturilistservice: aturilistservice,
60
+
did: resolvedDid,
61
+
collection: "app.bsky.feed.post",
62
+
reverse: true
63
+
});
64
65
React.useEffect(() => {
66
if (postsData) {
67
postsData.pages.forEach((page) => {
68
+
page.forEach((record) => {
69
if (!queryClient.getQueryData(["post", record.uri])) {
70
queryClient.setQueryData(["post", record.uri], record);
71
}
···
75
}, [postsData, queryClient]);
76
77
const posts = React.useMemo(
78
+
() => postsData?.pages.flatMap((page) => page) ?? [],
79
[postsData]
80
);
81
+
82
+
const [imgcdn] = useAtom(imgCDNAtom);
83
84
function getAvatarUrl(p: typeof profile) {
85
const link = p?.avatar?.ref?.["$link"];
86
if (!link || !resolvedDid) return null;
87
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
88
}
89
function getBannerUrl(p: typeof profile) {
90
const link = p?.banner?.ref?.["$link"];
91
if (!link || !resolvedDid) return null;
92
+
return `https://${imgcdn}/img/banner/plain/${resolvedDid}/${link}@jpeg`;
93
}
94
95
const displayName =
···
176
also delay the backfill to be on demand because it would be pretty intense
177
also save it persistently
178
*/}
179
+
<FollowButton targetdidorhandle={did} />
180
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
181
... {/* todo: icon */}
182
</button>
···
185
{/* Info Card */}
186
<div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100">
187
<div className="font-bold text-2xl">{displayName}</div>
188
+
<div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1">
189
+
<Mutual targetdidorhandle={did} />
190
{handle}
191
</div>
192
{description && (
193
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
194
+
{/* {description} */}
195
+
<RichTextRenderer key={did} description={description} />
196
</div>
197
)}
198
</div>
···
235
</>
236
);
237
}
238
+
239
+
export function FollowButton({
240
+
targetdidorhandle,
241
+
}: {
242
+
targetdidorhandle: string;
243
+
}) {
244
+
const { agent } = useAuth();
245
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
246
+
const queryClient = useQueryClient();
247
+
248
+
const followRecords = useGetFollowState({
249
+
target: identity?.did ?? targetdidorhandle,
250
+
user: agent?.did,
251
+
});
252
+
253
+
return (
254
+
<>
255
+
{identity?.did !== agent?.did ? (
256
+
<>
257
+
{!(followRecords?.length && followRecords?.length > 0) ? (
258
+
<button
259
+
onClick={(e) => {
260
+
e.stopPropagation();
261
+
toggleFollow({
262
+
agent: agent || undefined,
263
+
targetDid: identity?.did,
264
+
followRecords: followRecords,
265
+
queryClient: queryClient,
266
+
});
267
+
}}
268
+
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
269
+
>
270
+
Follow
271
+
</button>
272
+
) : (
273
+
<button
274
+
onClick={(e) => {
275
+
e.stopPropagation();
276
+
toggleFollow({
277
+
agent: agent || undefined,
278
+
targetDid: identity?.did,
279
+
followRecords: followRecords,
280
+
queryClient: queryClient,
281
+
});
282
+
}}
283
+
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
284
+
>
285
+
Unfollow
286
+
</button>
287
+
)}
288
+
</>
289
+
) : (
290
+
<button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]">
291
+
Edit Profile
292
+
</button>
293
+
)}
294
+
</>
295
+
);
296
+
}
297
+
298
+
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
299
+
const { agent } = useAuth();
300
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
301
+
302
+
const theyFollowYouRes = useGetOneToOneState(
303
+
agent?.did
304
+
? {
305
+
target: agent?.did,
306
+
user: identity?.did ?? targetdidorhandle,
307
+
collection: "app.bsky.graph.follow",
308
+
path: ".subject",
309
+
}
310
+
: undefined
311
+
);
312
+
313
+
const youFollowThemRes = useGetFollowState({
314
+
target: identity?.did ?? targetdidorhandle,
315
+
user: agent?.did,
316
+
});
317
+
318
+
const theyFollowYou: boolean =
319
+
!!theyFollowYouRes?.length && theyFollowYouRes.length > 0;
320
+
const youFollowThem: boolean =
321
+
!!youFollowThemRes?.length && youFollowThemRes.length > 0;
322
+
323
+
return (
324
+
<>
325
+
{/* if not self */}
326
+
{identity?.did !== agent?.did ? (
327
+
<>
328
+
{theyFollowYou ? (
329
+
<>
330
+
{youFollowThem ? (
331
+
<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">
332
+
mutuals
333
+
</div>
334
+
) : (
335
+
<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">
336
+
follows you
337
+
</div>
338
+
)}
339
+
</>
340
+
) : (
341
+
<></>
342
+
)}
343
+
</>
344
+
) : (
345
+
// lmao can someone be mutuals with themselves ??
346
+
<></>
347
+
)}
348
+
</>
349
+
);
350
+
}
351
+
352
+
export function RichTextRenderer({ description }: { description: string }) {
353
+
const [richDescription, setRichDescription] = useState<string | ReactNode[]>(
354
+
description
355
+
);
356
+
const { agent } = useAuth();
357
+
const navigate = useNavigate();
358
+
359
+
useEffect(() => {
360
+
let mounted = true;
361
+
362
+
// setRichDescription(description);
363
+
364
+
async function processRichText() {
365
+
try {
366
+
if (!agent?.did) return;
367
+
const rt = new RichText({ text: description });
368
+
await rt.detectFacets(agent);
369
+
370
+
if (!mounted) return;
371
+
372
+
if (rt.facets) {
373
+
setRichDescription(
374
+
renderTextWithFacets({ text: rt.text, facets: rt.facets, navigate })
375
+
);
376
+
} else {
377
+
setRichDescription(rt.text);
378
+
}
379
+
} catch (error) {
380
+
console.error("Failed to detect facets:", error);
381
+
if (mounted) {
382
+
setRichDescription(description);
383
+
}
384
+
}
385
+
}
386
+
387
+
processRichText();
388
+
389
+
return () => {
390
+
mounted = false;
391
+
};
392
+
}, [description, agent, navigate]);
393
+
394
+
return <>{richDescription}</>;
395
+
}
+9
-1
src/routes/profile.$did/post.$rkey.tsx
+9
-1
src/routes/profile.$did/post.$rkey.tsx
···
1
import { AtUri } from "@atproto/api";
2
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
import { createFileRoute, Outlet } from "@tanstack/react-router";
4
import React, { useLayoutEffect } from "react";
5
6
import { Header } from "~/components/Header";
7
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
8
//import { usePersistentStore } from '~/providers/PersistentStoreProvider';
9
import {
10
constructPostQuery,
···
275
// path: ".reply.parent.uri",
276
// });
277
// const replies = repliesData?.linking_records.slice(0, 50) ?? [];
278
const infinitequeryresults = useInfiniteQuery({
279
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
280
{
281
method: "/links",
282
target: atUri,
283
collection: "app.bsky.feed.post",
···
386
}
387
}, [parents, layoutReady]);
388
389
React.useEffect(() => {
390
if (parentsLoading) {
391
setLayoutReady(false);
···
414
while (currentParentUri && safetyCounter < MAX_PARENTS) {
415
try {
416
const parentPost = await queryClient.fetchQuery(
417
-
constructPostQuery(currentParentUri)
418
);
419
if (!parentPost) break;
420
parentChain.push(parentPost);
···
1
import { AtUri } from "@atproto/api";
2
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
import { createFileRoute, Outlet } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
5
import React, { useLayoutEffect } from "react";
6
7
import { Header } from "~/components/Header";
8
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
9
+
import { constellationURLAtom, slingshotURLAtom } from "~/utils/atoms";
10
//import { usePersistentStore } from '~/providers/PersistentStoreProvider';
11
import {
12
constructPostQuery,
···
277
// path: ".reply.parent.uri",
278
// });
279
// const replies = repliesData?.linking_records.slice(0, 50) ?? [];
280
+
const [constellationurl] = useAtom(constellationURLAtom)
281
+
282
const infinitequeryresults = useInfiniteQuery({
283
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
284
{
285
+
constellation: constellationurl,
286
method: "/links",
287
target: atUri,
288
collection: "app.bsky.feed.post",
···
391
}
392
}, [parents, layoutReady]);
393
394
+
395
+
const [slingshoturl] = useAtom(slingshotURLAtom)
396
+
397
React.useEffect(() => {
398
if (parentsLoading) {
399
setLayoutReady(false);
···
422
while (currentParentUri && safetyCounter < MAX_PARENTS) {
423
try {
424
const parentPost = await queryClient.fetchQuery(
425
+
constructPostQuery(currentParentUri, slingshoturl)
426
);
427
if (!parentPost) break;
428
parentChain.push(parentPost);
+50
-1
src/routes/search.tsx
+50
-1
src/routes/search.tsx
···
1
import { createFileRoute } from "@tanstack/react-router";
2
3
+
import { Header } from "~/components/Header";
4
+
import { Import } from "~/components/Import";
5
+
6
export const Route = createFileRoute("/search")({
7
component: Search,
8
});
9
10
export function Search() {
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
+
);
58
}
+172
-1
src/routes/settings.tsx
+172
-1
src/routes/settings.tsx
···
1
import { createFileRoute } from "@tanstack/react-router";
2
+
import { useAtom } from "jotai";
3
+
import { Slider } from "radix-ui";
4
5
import { Header } from "~/components/Header";
6
import Login from "~/components/Login";
7
+
import {
8
+
aturiListServiceAtom,
9
+
constellationURLAtom,
10
+
defaultaturilistservice,
11
+
defaultconstellationURL,
12
+
defaulthue,
13
+
defaultImgCDN,
14
+
defaultslingshotURL,
15
+
defaultVideoCDN,
16
+
hueAtom,
17
+
imgCDNAtom,
18
+
slingshotURLAtom,
19
+
videoCDNAtom,
20
+
} from "~/utils/atoms";
21
22
export const Route = createFileRoute("/settings")({
23
component: Settings,
···
36
}
37
}}
38
/>
39
+
<div className="lg:hidden">
40
+
<Login />
41
+
</div>
42
+
<div className="h-4" />
43
+
<TextInputSetting
44
+
atom={constellationURLAtom}
45
+
title={"Constellation"}
46
+
description={
47
+
"Customize the Constellation instance to be used by Red Dwarf"
48
+
}
49
+
init={defaultconstellationURL}
50
+
/>
51
+
<TextInputSetting
52
+
atom={slingshotURLAtom}
53
+
title={"Slingshot"}
54
+
description={"Customize the Slingshot instance to be used by Red Dwarf"}
55
+
init={defaultslingshotURL}
56
+
/>
57
+
<TextInputSetting
58
+
atom={aturiListServiceAtom}
59
+
title={"AtUriListService"}
60
+
description={"Customize the AtUriListService instance to be used by Red Dwarf"}
61
+
init={defaultaturilistservice}
62
+
/>
63
+
<TextInputSetting
64
+
atom={imgCDNAtom}
65
+
title={"Image CDN"}
66
+
description={
67
+
"Customize the Constellation instance to be used by Red Dwarf"
68
+
}
69
+
init={defaultImgCDN}
70
+
/>
71
+
<TextInputSetting
72
+
atom={videoCDNAtom}
73
+
title={"Video CDN"}
74
+
description={"Customize the Slingshot instance to be used by Red Dwarf"}
75
+
init={defaultVideoCDN}
76
+
/>
77
+
78
+
<Hue />
79
+
<p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm">
80
+
please restart/refresh the app if changes arent applying correctly
81
+
</p>
82
</>
83
);
84
}
85
+
function Hue() {
86
+
const [hue, setHue] = useAtom(hueAtom);
87
+
return (
88
+
<div className="flex flex-col px-4 mt-4 ">
89
+
<span className="z-10">Hue</span>
90
+
<div className="flex flex-row items-center gap-4">
91
+
<SliderComponent
92
+
atom={hueAtom}
93
+
max={360}
94
+
/>
95
+
<button
96
+
onClick={() => setHue(defaulthue ?? 28)}
97
+
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
98
+
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
99
+
>
100
+
Reset
101
+
</button>
102
+
</div>
103
+
</div>
104
+
);
105
+
}
106
+
107
+
export function TextInputSetting({
108
+
atom,
109
+
title,
110
+
description,
111
+
init,
112
+
}: {
113
+
atom: typeof constellationURLAtom;
114
+
title?: string;
115
+
description?: string;
116
+
init?: string;
117
+
}) {
118
+
const [value, setValue] = useAtom(atom);
119
+
return (
120
+
<div className="flex flex-col gap-2 px-4 py-2">
121
+
{/* <div>
122
+
{title && (
123
+
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
124
+
{title}
125
+
</h3>
126
+
)}
127
+
{description && (
128
+
<p className="text-sm text-gray-500 dark:text-gray-400">
129
+
{description}
130
+
</p>
131
+
)}
132
+
</div> */}
133
+
134
+
<div className="flex flex-row gap-2 items-center">
135
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
136
+
<input
137
+
type="text"
138
+
placeholder=" "
139
+
value={value}
140
+
onChange={(e) => setValue(e.target.value)}
141
+
/>
142
+
<label>{title}</label>
143
+
</div>
144
+
{/* <input
145
+
type="text"
146
+
value={value}
147
+
onChange={(e) => setValue(e.target.value)}
148
+
className="flex-1 px-3 py-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700
149
+
text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400
150
+
focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600"
151
+
placeholder="Enter value..."
152
+
/> */}
153
+
<button
154
+
onClick={() => setValue(init ?? "")}
155
+
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
156
+
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
157
+
>
158
+
Reset
159
+
</button>
160
+
</div>
161
+
</div>
162
+
);
163
+
}
164
+
165
+
166
+
interface SliderProps {
167
+
atom: typeof hueAtom;
168
+
min?: number;
169
+
max?: number;
170
+
step?: number;
171
+
}
172
+
173
+
export const SliderComponent: React.FC<SliderProps> = ({
174
+
atom,
175
+
min = 0,
176
+
max = 100,
177
+
step = 1,
178
+
}) => {
179
+
180
+
const [value, setValue] = useAtom(atom)
181
+
182
+
return (
183
+
<Slider.Root
184
+
className="relative flex items-center w-full h-4"
185
+
value={[value]}
186
+
min={min}
187
+
max={max}
188
+
step={step}
189
+
onValueChange={(v: number[]) => setValue(v[0])}
190
+
>
191
+
<Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full">
192
+
<Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" />
193
+
</Slider.Track>
194
+
<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" />
195
+
</Slider.Root>
196
+
);
197
+
};
+133
-11
src/styles/app.css
+133
-11
src/styles/app.css
···
15
--color-gray-950: oklch(0.129 0.050 222.000);
16
} */
17
18
@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);
30
}
31
32
@layer base {
···
105
:root {
106
--shadow-opacity: calc(1 - var(--is-top));
107
--tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15));
108
}
···
15
--color-gray-950: oklch(0.129 0.050 222.000);
16
} */
17
18
+
:root {
19
+
--safe-hue: var(--tw-gray-hue, 28)
20
+
}
21
+
22
@theme {
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));
34
}
35
36
@layer base {
···
109
:root {
110
--shadow-opacity: calc(1 - var(--is-top));
111
--tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15));
112
+
}
113
+
114
+
115
+
/* m3 input */
116
+
:root {
117
+
--m3input-radius: 6px;
118
+
--m3input-border-width: .0625rem;
119
+
--m3input-font-size: 16px;
120
+
--m3input-transition: 150ms cubic-bezier(.2, .8, .2, 1);
121
+
/* light theme */
122
+
--m3input-bg: var(--color-gray-50);
123
+
--m3input-border-color: var(--color-gray-400);
124
+
--m3input-label-color: var(--color-gray-500);
125
+
--m3input-text-color: var(--color-gray-900);
126
+
--m3input-focus-color: var(--color-gray-600);
127
+
}
128
+
129
+
@media (prefers-color-scheme: dark) {
130
+
:root {
131
+
--m3input-bg: var(--color-gray-950);
132
+
--m3input-border-color: var(--color-gray-700);
133
+
--m3input-label-color: var(--color-gray-400);
134
+
--m3input-text-color: var(--color-gray-50);
135
+
--m3input-focus-color: var(--color-gray-400);
136
+
}
137
+
}
138
+
139
+
/* reset page *//*
140
+
html,
141
+
body {
142
+
background: var(--m3input-bg);
143
+
margin: 0;
144
+
padding: 1rem;
145
+
color: var(--m3input-text-color);
146
+
font-family: system-ui, sans-serif;
147
+
font-size: var(--m3input-font-size);
148
+
}*/
149
+
150
+
/* base wrapper */
151
+
.m3input-field.m3input-label.m3input-border {
152
+
position: relative;
153
+
display: inline-block;
154
+
width: 100%;
155
+
/*max-width: 400px;*/
156
+
}
157
+
158
+
/* size variants */
159
+
.m3input-field.size-sm {
160
+
--m3input-h: 40px;
161
+
}
162
+
163
+
.m3input-field.size-md {
164
+
--m3input-h: 48px;
165
+
}
166
+
167
+
.m3input-field.size-lg {
168
+
--m3input-h: 56px;
169
+
}
170
+
171
+
.m3input-field.size-xl {
172
+
--m3input-h: 64px;
173
+
}
174
+
175
+
.m3input-field.m3input-label.m3input-border:not(.size-sm):not(.size-md):not(.size-lg):not(.size-xl) {
176
+
--m3input-h: 48px;
177
+
}
178
+
179
+
/* outlined input */
180
+
.m3input-field.m3input-label.m3input-border input {
181
+
width: 100%;
182
+
height: var(--m3input-h);
183
+
border: var(--m3input-border-width) solid var(--m3input-border-color);
184
+
border-radius: var(--m3input-radius);
185
+
background: var(--m3input-bg);
186
+
color: var(--m3input-text-color);
187
+
font-size: var(--m3input-font-size);
188
+
padding: 0 12px;
189
+
box-sizing: border-box;
190
+
outline: none;
191
+
transition: border-color var(--m3input-transition), box-shadow var(--m3input-transition);
192
+
}
193
+
194
+
/* focus ring */
195
+
.m3input-field.m3input-label.m3input-border input:focus {
196
+
border-color: var(--m3input-focus-color);
197
+
/*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/
198
+
}
199
+
200
+
/* label */
201
+
.m3input-field.m3input-label.m3input-border label {
202
+
position: absolute;
203
+
left: 12px;
204
+
top: 50%;
205
+
transform: translateY(-50%);
206
+
background: var(--m3input-bg);
207
+
padding: 0 .25em;
208
+
color: var(--m3input-label-color);
209
+
pointer-events: none;
210
+
transition: all var(--m3input-transition);
211
+
}
212
+
213
+
/* float on focus or when filled */
214
+
.m3input-field.m3input-label.m3input-border input:focus+label,
215
+
.m3input-field.m3input-label.m3input-border input:not(:placeholder-shown)+label {
216
+
top: 0;
217
+
transform: translateY(-50%) scale(.78);
218
+
left: 0;
219
+
color: var(--m3input-focus-color);
220
+
}
221
+
222
+
/* placeholder trick */
223
+
.m3input-field.m3input-label.m3input-border input::placeholder {
224
+
color: transparent;
225
+
}
226
+
227
+
/* radix i love you but like cmon man */
228
+
body[data-scroll-locked]{
229
+
margin-left: var(--removed-body-scroll-bar-size) !important;
230
}
+60
-11
src/utils/atoms.ts
+60
-11
src/utils/atoms.ts
···
1
import type Agent from "@atproto/api";
2
-
import { atom, createStore } from "jotai";
3
-
import { atomWithStorage } from 'jotai/utils';
4
5
export const store = createStore();
6
7
export const selectedFeedUriAtom = atomWithStorage<string | null>(
8
-
'selectedFeedUri',
9
null
10
);
11
12
//export const feedScrollPositionsAtom = atom<Record<string, number>>({});
13
14
export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>(
15
-
'feedscrollpositions',
16
{}
17
);
18
19
export const likedPostsAtom = atomWithStorage<Record<string, string>>(
20
-
'likedPosts',
21
{}
22
);
23
24
export const isAtTopAtom = atom<boolean>(true);
25
26
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' });
32
33
-
export const agentAtom = atom<Agent|null>(null);
34
export const authedAtom = atom<boolean>(false);
···
1
import type Agent from "@atproto/api";
2
+
import { atom, createStore, useAtomValue } from "jotai";
3
+
import { atomWithStorage } from "jotai/utils";
4
+
import { useEffect } from "react";
5
6
export const store = createStore();
7
8
export const selectedFeedUriAtom = atomWithStorage<string | null>(
9
+
"selectedFeedUri",
10
null
11
);
12
13
//export const feedScrollPositionsAtom = atom<Record<string, number>>({});
14
15
export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>(
16
+
"feedscrollpositions",
17
{}
18
);
19
20
export const likedPostsAtom = atomWithStorage<Record<string, string>>(
21
+
"likedPosts",
22
{}
23
);
24
25
+
export const defaultconstellationURL = "constellation.microcosm.blue";
26
+
export const constellationURLAtom = atomWithStorage<string>(
27
+
"constellationURL",
28
+
defaultconstellationURL
29
+
);
30
+
export const defaultslingshotURL = "slingshot.microcosm.blue";
31
+
export const slingshotURLAtom = atomWithStorage<string>(
32
+
"slingshotURL",
33
+
defaultslingshotURL
34
+
);
35
+
export const defaultaturilistservice = "aturilistservice.reddwarf.app";
36
+
export const aturiListServiceAtom = atomWithStorage<string>(
37
+
"aturilistservice",
38
+
defaultaturilistservice
39
+
);
40
+
export const defaultImgCDN = "cdn.bsky.app";
41
+
export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN);
42
+
export const defaultVideoCDN = "video.bsky.app";
43
+
export const videoCDNAtom = atomWithStorage<string>(
44
+
"videocdnurl",
45
+
defaultVideoCDN
46
+
);
47
+
48
+
export const defaulthue = 28;
49
+
export const hueAtom = atomWithStorage<number>("hue", defaulthue);
50
+
51
export const isAtTopAtom = atom<boolean>(true);
52
53
type ComposerState =
54
+
| { kind: "closed" }
55
+
| { kind: "root" }
56
+
| { kind: "reply"; parent: string }
57
+
| { kind: "quote"; subject: string };
58
+
export const composerAtom = atom<ComposerState>({ kind: "closed" });
59
60
+
export const agentAtom = atom<Agent | null>(null);
61
export const authedAtom = atom<boolean>(false);
62
+
63
+
export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) {
64
+
const value = useAtomValue(atom);
65
+
66
+
useEffect(() => {
67
+
document.documentElement.style.setProperty(cssVar, value.toString());
68
+
}, [value, cssVar]);
69
+
70
+
useEffect(() => {
71
+
document.documentElement.style.setProperty(cssVar, value.toString());
72
+
}, []);
73
+
}
74
+
75
+
hueAtom.onMount = (setAtom) => {
76
+
const stored = localStorage.getItem("hue");
77
+
if (stored != null) setAtom(Number(stored));
78
+
};
79
+
// export function initAtomToCssVar(atom: typeof hueAtom, cssVar: string) {
80
+
// const initial = store.get(atom);
81
+
// console.log("atom get ", initial);
82
+
// document.documentElement.style.setProperty(cssVar, initial.toString());
83
+
// }
+33
src/utils/followState.ts
+33
src/utils/followState.ts
···
128
};
129
});
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
AppBskyFeedPost,
10
AtUri,
11
} from "@atproto/api";
12
import { useMemo } from "react";
13
14
-
import { useQueryIdentity,useQueryPost, useQueryProfile } from "./useQuery";
15
16
-
type QueryResultData<T extends (...args: any) => any> = ReturnType<T> extends
17
-
| { data: infer D }
18
-
| undefined
19
-
? D
20
-
: never;
21
22
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
23
return obj as $Typed<T>;
···
26
export function hydrateEmbedImages(
27
embed: AppBskyEmbedImages.Main,
28
did: string,
29
): $Typed<AppBskyEmbedImages.View> {
30
return asTyped({
31
$type: "app.bsky.embed.images#view" as const,
···
34
const link = img.image.ref?.["$link"];
35
if (!link) return null;
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`,
39
alt: img.alt || "",
40
aspectRatio: img.aspectRatio,
41
};
···
47
export function hydrateEmbedExternal(
48
embed: AppBskyEmbedExternal.Main,
49
did: string,
50
): $Typed<AppBskyEmbedExternal.View> {
51
return asTyped({
52
$type: "app.bsky.embed.external#view" as const,
···
55
title: embed.external.title,
56
description: embed.external.description,
57
thumb: embed.external.thumb?.ref?.$link
58
-
? `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg`
59
: undefined,
60
},
61
});
···
64
export function hydrateEmbedVideo(
65
embed: AppBskyEmbedVideo.Main,
66
did: string,
67
): $Typed<AppBskyEmbedVideo.View> {
68
const videoLink = embed.video.ref.$link;
69
return asTyped({
70
$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
aspectRatio: embed.aspectRatio,
74
cid: videoLink,
75
});
···
80
quotedPost: QueryResultData<typeof useQueryPost>,
81
quotedProfile: QueryResultData<typeof useQueryProfile>,
82
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
83
): $Typed<AppBskyEmbedRecord.View> | undefined {
84
if (!quotedPost || !quotedProfile || !quotedIdentity) {
85
return undefined;
···
91
handle: quotedIdentity.handle,
92
displayName: quotedProfile.value.displayName ?? quotedIdentity.handle,
93
avatar: quotedProfile.value.avatar?.ref?.$link
94
-
? `https://cdn.bsky.app/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg`
95
: undefined,
96
viewer: {},
97
labels: [],
···
122
quotedPost: QueryResultData<typeof useQueryPost>,
123
quotedProfile: QueryResultData<typeof useQueryProfile>,
124
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
125
): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined {
126
const hydratedRecord = hydrateEmbedRecord(
127
embed.record,
128
quotedPost,
129
quotedProfile,
130
quotedIdentity,
131
);
132
133
if (!hydratedRecord) return undefined;
···
148
149
export function useHydratedEmbed(
150
embed: AppBskyFeedPost.Record["embed"],
151
-
postAuthorDid: string | undefined,
152
) {
153
const recordInfo = useMemo(() => {
154
if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
···
181
error: profileError,
182
} = useQueryProfile(profileUri);
183
184
const queryidentityresult = useQueryIdentity(quotedAuthorDid);
185
186
const hydratedEmbed: HydratedEmbedView | undefined = (() => {
187
if (!embed || !postAuthorDid) return undefined;
188
189
-
if (isRecordType && (!usequerypostresults?.data || !quotedProfile || !queryidentityresult?.data)) {
190
return undefined;
191
}
192
193
try {
194
if (AppBskyEmbedImages.isMain(embed)) {
195
-
return hydrateEmbedImages(embed, postAuthorDid);
196
} else if (AppBskyEmbedExternal.isMain(embed)) {
197
-
return hydrateEmbedExternal(embed, postAuthorDid);
198
} else if (AppBskyEmbedVideo.isMain(embed)) {
199
-
return hydrateEmbedVideo(embed, postAuthorDid);
200
} else if (AppBskyEmbedRecord.isMain(embed)) {
201
return hydrateEmbedRecord(
202
embed,
203
usequerypostresults?.data,
204
quotedProfile,
205
queryidentityresult?.data,
206
);
207
} else if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
208
let hydratedMedia:
···
212
| undefined;
213
214
if (AppBskyEmbedImages.isMain(embed.media)) {
215
-
hydratedMedia = hydrateEmbedImages(embed.media, postAuthorDid);
216
} else if (AppBskyEmbedExternal.isMain(embed.media)) {
217
-
hydratedMedia = hydrateEmbedExternal(embed.media, postAuthorDid);
218
} else if (AppBskyEmbedVideo.isMain(embed.media)) {
219
-
hydratedMedia = hydrateEmbedVideo(embed.media, postAuthorDid);
220
}
221
222
if (hydratedMedia) {
···
226
usequerypostresults?.data,
227
quotedProfile,
228
queryidentityresult?.data,
229
);
230
}
231
}
···
236
})();
237
238
const isLoading = isRecordType
239
-
? usequerypostresults?.isLoading || isLoadingProfile || queryidentityresult?.isLoading
240
: false;
241
242
-
const error = usequerypostresults?.error || profileError || queryidentityresult?.error;
243
244
return { data: hydratedEmbed, isLoading, error };
245
-
}
···
9
AppBskyFeedPost,
10
AtUri,
11
} from "@atproto/api";
12
+
import { useAtom } from "jotai";
13
import { useMemo } from "react";
14
15
+
import { imgCDNAtom, videoCDNAtom } from "./atoms";
16
+
import { useQueryIdentity, useQueryPost, useQueryProfile } from "./useQuery";
17
18
+
type QueryResultData<T extends (...args: any) => any> =
19
+
ReturnType<T> extends { data: infer D } | undefined ? D : never;
20
21
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
22
return obj as $Typed<T>;
···
25
export function hydrateEmbedImages(
26
embed: AppBskyEmbedImages.Main,
27
did: string,
28
+
cdn: string
29
): $Typed<AppBskyEmbedImages.View> {
30
return asTyped({
31
$type: "app.bsky.embed.images#view" as const,
···
34
const link = img.image.ref?.["$link"];
35
if (!link) return null;
36
return {
37
+
thumb: `https://${cdn}/img/feed_thumbnail/plain/${did}/${link}@jpeg`,
38
+
fullsize: `https://${cdn}/img/feed_fullsize/plain/${did}/${link}@jpeg`,
39
alt: img.alt || "",
40
aspectRatio: img.aspectRatio,
41
};
···
47
export function hydrateEmbedExternal(
48
embed: AppBskyEmbedExternal.Main,
49
did: string,
50
+
cdn: string
51
): $Typed<AppBskyEmbedExternal.View> {
52
return asTyped({
53
$type: "app.bsky.embed.external#view" as const,
···
56
title: embed.external.title,
57
description: embed.external.description,
58
thumb: embed.external.thumb?.ref?.$link
59
+
? `https://${cdn}/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg`
60
: undefined,
61
},
62
});
···
65
export function hydrateEmbedVideo(
66
embed: AppBskyEmbedVideo.Main,
67
did: string,
68
+
videocdn: string
69
): $Typed<AppBskyEmbedVideo.View> {
70
const videoLink = embed.video.ref.$link;
71
return asTyped({
72
$type: "app.bsky.embed.video#view" as const,
73
+
playlist: `https://${videocdn}/watch/${did}/${videoLink}/playlist.m3u8`,
74
+
thumbnail: `https://${videocdn}/watch/${did}/${videoLink}/thumbnail.jpg`,
75
aspectRatio: embed.aspectRatio,
76
cid: videoLink,
77
});
···
82
quotedPost: QueryResultData<typeof useQueryPost>,
83
quotedProfile: QueryResultData<typeof useQueryProfile>,
84
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
85
+
cdn: string
86
): $Typed<AppBskyEmbedRecord.View> | undefined {
87
if (!quotedPost || !quotedProfile || !quotedIdentity) {
88
return undefined;
···
94
handle: quotedIdentity.handle,
95
displayName: quotedProfile.value.displayName ?? quotedIdentity.handle,
96
avatar: quotedProfile.value.avatar?.ref?.$link
97
+
? `https://${cdn}/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg`
98
: undefined,
99
viewer: {},
100
labels: [],
···
125
quotedPost: QueryResultData<typeof useQueryPost>,
126
quotedProfile: QueryResultData<typeof useQueryProfile>,
127
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
128
+
cdn: string
129
): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined {
130
const hydratedRecord = hydrateEmbedRecord(
131
embed.record,
132
quotedPost,
133
quotedProfile,
134
quotedIdentity,
135
+
cdn
136
);
137
138
if (!hydratedRecord) return undefined;
···
153
154
export function useHydratedEmbed(
155
embed: AppBskyFeedPost.Record["embed"],
156
+
postAuthorDid: string | undefined
157
) {
158
const recordInfo = useMemo(() => {
159
if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
···
186
error: profileError,
187
} = useQueryProfile(profileUri);
188
189
+
const [imgcdn] = useAtom(imgCDNAtom);
190
+
const [videocdn] = useAtom(videoCDNAtom);
191
+
192
const queryidentityresult = useQueryIdentity(quotedAuthorDid);
193
194
const hydratedEmbed: HydratedEmbedView | undefined = (() => {
195
if (!embed || !postAuthorDid) return undefined;
196
197
+
if (
198
+
isRecordType &&
199
+
(!usequerypostresults?.data ||
200
+
!quotedProfile ||
201
+
!queryidentityresult?.data)
202
+
) {
203
return undefined;
204
}
205
206
try {
207
if (AppBskyEmbedImages.isMain(embed)) {
208
+
return hydrateEmbedImages(embed, postAuthorDid, imgcdn);
209
} else if (AppBskyEmbedExternal.isMain(embed)) {
210
+
return hydrateEmbedExternal(embed, postAuthorDid, imgcdn);
211
} else if (AppBskyEmbedVideo.isMain(embed)) {
212
+
return hydrateEmbedVideo(embed, postAuthorDid, videocdn);
213
} else if (AppBskyEmbedRecord.isMain(embed)) {
214
return hydrateEmbedRecord(
215
embed,
216
usequerypostresults?.data,
217
quotedProfile,
218
queryidentityresult?.data,
219
+
imgcdn
220
);
221
} else if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
222
let hydratedMedia:
···
226
| undefined;
227
228
if (AppBskyEmbedImages.isMain(embed.media)) {
229
+
hydratedMedia = hydrateEmbedImages(
230
+
embed.media,
231
+
postAuthorDid,
232
+
imgcdn
233
+
);
234
} else if (AppBskyEmbedExternal.isMain(embed.media)) {
235
+
hydratedMedia = hydrateEmbedExternal(
236
+
embed.media,
237
+
postAuthorDid,
238
+
imgcdn
239
+
);
240
} else if (AppBskyEmbedVideo.isMain(embed.media)) {
241
+
hydratedMedia = hydrateEmbedVideo(
242
+
embed.media,
243
+
postAuthorDid,
244
+
videocdn
245
+
);
246
}
247
248
if (hydratedMedia) {
···
252
usequerypostresults?.data,
253
quotedProfile,
254
queryidentityresult?.data,
255
+
imgcdn
256
);
257
}
258
}
···
263
})();
264
265
const isLoading = isRecordType
266
+
? usequerypostresults?.isLoading ||
267
+
isLoadingProfile ||
268
+
queryidentityresult?.isLoading
269
: false;
270
271
+
const error =
272
+
usequerypostresults?.error || profileError || queryidentityresult?.error;
273
274
return { data: hydratedEmbed, isLoading, error };
275
+
}
+107
-18
src/utils/useQuery.ts
+107
-18
src/utils/useQuery.ts
···
6
useInfiniteQuery,
7
useQuery,
8
type UseQueryResult} from "@tanstack/react-query";
9
10
-
export function constructIdentityQuery(didorhandle?: string) {
11
return queryOptions({
12
queryKey: ["identity", didorhandle],
13
queryFn: async () => {
14
if (!didorhandle) return undefined as undefined
15
const res = await fetch(
16
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
17
);
18
if (!res.ok) throw new Error("Failed to fetch post");
19
try {
···
55
Error
56
>
57
export function useQueryIdentity(didorhandle?: string) {
58
-
return useQuery(constructIdentityQuery(didorhandle));
59
}
60
61
-
export function constructPostQuery(uri?: string) {
62
return queryOptions({
63
queryKey: ["post", uri],
64
queryFn: async () => {
65
if (!uri) return undefined as undefined
66
const res = await fetch(
67
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
68
);
69
let data: any;
70
try {
···
118
Error
119
>
120
export function useQueryPost(uri?: string) {
121
-
return useQuery(constructPostQuery(uri));
122
}
123
124
-
export function constructProfileQuery(uri?: string) {
125
return queryOptions({
126
queryKey: ["profile", uri],
127
queryFn: async () => {
128
if (!uri) return undefined as undefined
129
const res = await fetch(
130
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
131
);
132
let data: any;
133
try {
···
181
Error
182
>
183
export function useQueryProfile(uri?: string) {
184
-
return useQuery(constructProfileQuery(uri));
185
}
186
187
// export function constructConstellationQuery(
···
217
// target: string
218
// ): QueryOptions<linksAllResponse, Error>;
219
export function constructConstellationQuery(query?:{
220
method:
221
| "/links"
222
| "/links/distinct-dids"
···
250
const cursor = query.cursor
251
const dids = query?.dids
252
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("") : ""}`
254
);
255
if (!res.ok) throw new Error("Failed to fetch post");
256
try {
···
339
>
340
| undefined {
341
//if (!query) return;
342
return useQuery(
343
-
constructConstellationQuery(query)
344
);
345
}
346
···
445
446
447
448
-
export function constructArbitraryQuery(uri?: string) {
449
return queryOptions({
450
queryKey: ["arbitrary", uri],
451
queryFn: async () => {
452
if (!uri) return undefined as undefined
453
const res = await fetch(
454
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
455
);
456
let data: any;
457
try {
···
504
Error
505
>;
506
export function useQueryArbitrary(uri?: string) {
507
-
return useQuery(constructArbitraryQuery(uri));
508
}
509
510
export function constructFallbackNothingQuery(){
···
556
});
557
}
558
559
type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
560
561
export function constructInfiniteFeedSkeletonQuery(options: {
···
606
}) {
607
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
608
609
-
return useInfiniteQuery({
610
queryKey,
611
queryFn,
612
initialPageParam: undefined as never,
···
614
staleTime: Infinity,
615
refetchOnWindowFocus: false,
616
enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
617
-
});
618
}
619
620
621
export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
622
method: '/links'
623
target?: string
624
collection: string
625
path: string
626
}) {
627
-
const constellationHost = 'constellation.microcosm.blue'
628
console.log(
629
'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
630
query,
···
650
const cursor = pageParam
651
652
const res = await fetch(
653
-
`https://${constellationHost}${method}?target=${encodeURIComponent(target)}${
654
collection ? `&collection=${encodeURIComponent(collection)}` : ''
655
}${path ? `&path=${encodeURIComponent(path)}` : ''}${
656
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
···
6
useInfiniteQuery,
7
useQuery,
8
type UseQueryResult} from "@tanstack/react-query";
9
+
import { useAtom } from "jotai";
10
11
+
import { constellationURLAtom, slingshotURLAtom } from "./atoms";
12
+
13
+
export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) {
14
return queryOptions({
15
queryKey: ["identity", didorhandle],
16
queryFn: async () => {
17
if (!didorhandle) return undefined as undefined
18
const res = await fetch(
19
+
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
20
);
21
if (!res.ok) throw new Error("Failed to fetch post");
22
try {
···
58
Error
59
>
60
export function useQueryIdentity(didorhandle?: string) {
61
+
const [slingshoturl] = useAtom(slingshotURLAtom)
62
+
return useQuery(constructIdentityQuery(didorhandle, slingshoturl));
63
}
64
65
+
export function constructPostQuery(uri?: string, slingshoturl?: string) {
66
return queryOptions({
67
queryKey: ["post", uri],
68
queryFn: async () => {
69
if (!uri) return undefined as undefined
70
const res = await fetch(
71
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
72
);
73
let data: any;
74
try {
···
122
Error
123
>
124
export function useQueryPost(uri?: string) {
125
+
const [slingshoturl] = useAtom(slingshotURLAtom)
126
+
return useQuery(constructPostQuery(uri, slingshoturl));
127
}
128
129
+
export function constructProfileQuery(uri?: string, slingshoturl?: string) {
130
return queryOptions({
131
queryKey: ["profile", uri],
132
queryFn: async () => {
133
if (!uri) return undefined as undefined
134
const res = await fetch(
135
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
136
);
137
let data: any;
138
try {
···
186
Error
187
>
188
export function useQueryProfile(uri?: string) {
189
+
const [slingshoturl] = useAtom(slingshotURLAtom)
190
+
return useQuery(constructProfileQuery(uri, slingshoturl));
191
}
192
193
// export function constructConstellationQuery(
···
223
// target: string
224
// ): QueryOptions<linksAllResponse, Error>;
225
export function constructConstellationQuery(query?:{
226
+
constellation: string,
227
method:
228
| "/links"
229
| "/links/distinct-dids"
···
257
const cursor = query.cursor
258
const dids = query?.dids
259
const res = await fetch(
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("") : ""}`
261
);
262
if (!res.ok) throw new Error("Failed to fetch post");
263
try {
···
346
>
347
| undefined {
348
//if (!query) return;
349
+
const [constellationurl] = useAtom(constellationURLAtom)
350
return useQuery(
351
+
constructConstellationQuery(query && {constellation: constellationurl, ...query})
352
);
353
}
354
···
453
454
455
456
+
export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
457
return queryOptions({
458
queryKey: ["arbitrary", uri],
459
queryFn: async () => {
460
if (!uri) return undefined as undefined
461
const res = await fetch(
462
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
463
);
464
let data: any;
465
try {
···
512
Error
513
>;
514
export function useQueryArbitrary(uri?: string) {
515
+
const [slingshoturl] = useAtom(slingshotURLAtom)
516
+
return useQuery(constructArbitraryQuery(uri, slingshoturl));
517
}
518
519
export function constructFallbackNothingQuery(){
···
565
});
566
}
567
568
+
export const ATURI_PAGE_LIMIT = 100;
569
+
570
+
export interface AturiDirectoryAturisItem {
571
+
uri: string;
572
+
cid: string;
573
+
rkey: string;
574
+
}
575
+
576
+
export type AturiDirectoryAturis = AturiDirectoryAturisItem[];
577
+
578
+
export function constructAturiListQuery(aturilistservice: string, did: string, collection: string, reverse?: boolean) {
579
+
return queryOptions({
580
+
// A unique key for this query, including all parameters that affect the data.
581
+
queryKey: ["aturiList", did, collection, { reverse }],
582
+
583
+
// The function that fetches the data.
584
+
queryFn: async ({ pageParam }: QueryFunctionContext) => {
585
+
const cursor = pageParam as string | undefined;
586
+
587
+
// Use URLSearchParams for safe and clean URL construction.
588
+
const params = new URLSearchParams({
589
+
did,
590
+
collection,
591
+
});
592
+
593
+
if (cursor) {
594
+
params.set("cursor", cursor);
595
+
}
596
+
597
+
// Add the reverse parameter if it's true
598
+
if (reverse) {
599
+
params.set("reverse", "true");
600
+
}
601
+
602
+
const url = `https://${aturilistservice}/aturis?${params.toString()}`;
603
+
604
+
const res = await fetch(url);
605
+
if (!res.ok) {
606
+
// You can add more specific error handling here
607
+
throw new Error(`Failed to fetch AT-URI list for ${did}`);
608
+
}
609
+
610
+
return res.json() as Promise<AturiDirectoryAturis>;
611
+
},
612
+
});
613
+
}
614
+
615
+
export function useInfiniteQueryAturiList({aturilistservice, did, collection, reverse}:{aturilistservice: string, did: string | undefined, collection: string | undefined, reverse?: boolean}) {
616
+
// We only enable the query if both `did` and `collection` are provided.
617
+
const isEnabled = !!did && !!collection;
618
+
619
+
const { queryKey, queryFn } = constructAturiListQuery(aturilistservice, did!, collection!, reverse);
620
+
621
+
return useInfiniteQuery({
622
+
queryKey,
623
+
queryFn,
624
+
initialPageParam: undefined as never, // ???? what is this shit
625
+
626
+
// @ts-expect-error i wouldve used as null | undefined, anyways
627
+
getNextPageParam: (lastPage: AturiDirectoryAturis) => {
628
+
// If the last page returned no records, we're at the end.
629
+
if (!lastPage || lastPage.length === 0) {
630
+
return undefined;
631
+
}
632
+
633
+
// If the number of records is less than our page limit, it must be the last page.
634
+
if (lastPage.length < ATURI_PAGE_LIMIT) {
635
+
return undefined;
636
+
}
637
+
638
+
// The cursor for the next page is the `rkey` of the last item we received.
639
+
const lastItem = lastPage[lastPage.length - 1];
640
+
return lastItem.rkey;
641
+
},
642
+
643
+
enabled: isEnabled,
644
+
});
645
+
}
646
+
647
+
648
type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
649
650
export function constructInfiniteFeedSkeletonQuery(options: {
···
695
}) {
696
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
697
698
+
return {...useInfiniteQuery({
699
queryKey,
700
queryFn,
701
initialPageParam: undefined as never,
···
703
staleTime: Infinity,
704
refetchOnWindowFocus: false,
705
enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
706
+
}), queryKey: queryKey};
707
}
708
709
710
export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
711
+
constellation: string,
712
method: '/links'
713
target?: string
714
collection: string
715
path: string
716
}) {
717
console.log(
718
'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
719
query,
···
739
const cursor = pageParam
740
741
const res = await fetch(
742
+
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
743
collection ? `&collection=${encodeURIComponent(collection)}` : ''
744
}${path ? `&path=${encodeURIComponent(path)}` : ''}${
745
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
+1
-1
vite.config.ts
+1
-1
vite.config.ts