+2
-1
.gitignore
+2
-1
.gitignore
+9
README.md
+9
README.md
···
15
15
16
16
run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder)
17
17
18
+
19
+
20
+
you probably dont need to change these
21
+
```ts
22
+
const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party"
23
+
const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social"
24
+
```
25
+
if you do want to change these, i recommend changing both of these to your own PDS url. i separate the prod and dev urls so that you can change it as needed. here i separated it because if the prod resolver and prod url shares the same domain itll error and prevent logins
26
+
18
27
## useQuery
19
28
Red Dwarf has been upgraded from its original bespoke caching system to Tanstack Query (react query). this migration was done to achieve a more robust and maintainable approach to data fetching and caching and state synchronization. ive seen serious performance gains from this switch!
20
29
+62
-30
oauthdev.mts
+62
-30
oauthdev.mts
···
1
-
import fs from 'fs';
2
-
import path from 'path';
1
+
import fs from "fs";
2
+
import path from "path";
3
3
//import { generateClientMetadata } from './src/helpers/oauthClient'
4
4
export const generateClientMetadata = (appOrigin: string) => {
5
-
const callbackPath = '/callback';
5
+
const callbackPath = "/callback";
6
6
7
7
return {
8
-
"client_id": `${appOrigin}/client-metadata.json`,
9
-
"client_name": "ForumTest",
10
-
"client_uri": appOrigin,
11
-
"logo_uri": `${appOrigin}/logo192.png`,
12
-
"tos_uri": `${appOrigin}/terms-of-service`,
13
-
"policy_uri": `${appOrigin}/privacy-policy`,
14
-
"redirect_uris": [`${appOrigin}${callbackPath}`] as [string, ...string[]],
15
-
"scope": "atproto transition:generic",
16
-
"grant_types": ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"],
17
-
"response_types": ["code"] as ["code"],
18
-
"token_endpoint_auth_method": "none" as "none",
19
-
"application_type": "web" as "web",
20
-
"dpop_bound_access_tokens": true
21
-
};
22
-
}
23
-
8
+
client_id: `${appOrigin}/client-metadata.json`,
9
+
client_name: "ForumTest",
10
+
client_uri: appOrigin,
11
+
logo_uri: `${appOrigin}/logo192.png`,
12
+
tos_uri: `${appOrigin}/terms-of-service`,
13
+
policy_uri: `${appOrigin}/privacy-policy`,
14
+
redirect_uris: [`${appOrigin}${callbackPath}`] as [string, ...string[]],
15
+
scope: "atproto transition:generic",
16
+
grant_types: ["authorization_code", "refresh_token"] as [
17
+
"authorization_code",
18
+
"refresh_token",
19
+
],
20
+
response_types: ["code"] as ["code"],
21
+
token_endpoint_auth_method: "none" as "none",
22
+
application_type: "web" as "web",
23
+
dpop_bound_access_tokens: true,
24
+
};
25
+
};
24
26
25
-
export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) {
27
+
export function generateMetadataPlugin({
28
+
prod,
29
+
dev,
30
+
prodResolver = "https://bsky.social",
31
+
devResolver = prodResolver,
32
+
}: {
33
+
prod: string;
34
+
dev: string;
35
+
prodResolver?: string;
36
+
devResolver?: string;
37
+
}) {
26
38
return {
27
-
name: 'vite-plugin-generate-metadata',
39
+
name: "vite-plugin-generate-metadata",
28
40
config(_config: any, { mode }: any) {
29
-
let appOrigin;
30
-
if (mode === 'production') {
31
-
appOrigin = prod
32
-
if (!appOrigin || !appOrigin.startsWith('https://')) {
33
-
throw new Error('VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build.');
41
+
console.log('๐ก vite mode =', mode)
42
+
let appOrigin, resolver;
43
+
if (mode === "production") {
44
+
appOrigin = prod;
45
+
resolver = prodResolver;
46
+
if (!appOrigin || !appOrigin.startsWith("https://")) {
47
+
throw new Error(
48
+
"VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build."
49
+
);
34
50
}
35
51
} else {
36
52
appOrigin = dev;
53
+
resolver = devResolver;
37
54
}
38
-
39
-
55
+
40
56
const metadata = generateClientMetadata(appOrigin);
41
-
const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json');
57
+
const outputPath = path.resolve(
58
+
process.cwd(),
59
+
"public",
60
+
"client-metadata.json"
61
+
);
42
62
43
63
fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2));
44
64
65
+
const resolvers = {
66
+
resolver: resolver,
67
+
};
68
+
const resolverOutPath = path.resolve(
69
+
process.cwd(),
70
+
"public",
71
+
"resolvers.json"
72
+
);
73
+
74
+
fs.writeFileSync(resolverOutPath, JSON.stringify(resolvers, null, 2));
75
+
76
+
45
77
// /*mass comment*/ console.log(`โ
Generated client-metadata.json for ${appOrigin}`);
46
78
},
47
79
};
48
-
}
80
+
}
+10
package-lock.json
+10
package-lock.json
···
29
29
"react": "^19.0.0",
30
30
"react-dom": "^19.0.0",
31
31
"react-player": "^3.3.2",
32
+
"sonner": "^2.0.7",
32
33
"tailwindcss": "^4.0.6",
33
34
"tanstack-router-keepalive": "^1.0.0"
34
35
},
···
12543
12544
"csstype": "^3.1.0",
12544
12545
"seroval": "~1.3.0",
12545
12546
"seroval-plugins": "~1.3.0"
12547
+
}
12548
+
},
12549
+
"node_modules/sonner": {
12550
+
"version": "2.0.7",
12551
+
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
12552
+
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
12553
+
"peerDependencies": {
12554
+
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
12555
+
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
12546
12556
}
12547
12557
},
12548
12558
"node_modules/source-map": {
+1
package.json
+1
package.json
+6
src/auto-imports.d.ts
+6
src/auto-imports.d.ts
···
18
18
const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default
19
19
const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default
20
20
const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default
21
+
const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default
22
+
const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default
23
+
const IconMdiClose: typeof import('~icons/mdi/close.jsx').default
24
+
const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default
21
25
const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default
26
+
const IconMdiShield: typeof import('~icons/mdi/shield.jsx').default
27
+
const IconMdiShieldOutline: typeof import('~icons/mdi/shield-outline.jsx').default
22
28
}
+117
-164
src/components/Composer.tsx
+117
-164
src/components/Composer.tsx
···
8
8
import { useQueryPost } from "~/utils/useQuery";
9
9
10
10
import { ProfileThing } from "./Login";
11
-
import { Button } from "./radix-m3-rd/Button";
12
11
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
13
12
14
13
const MAX_POST_LENGTH = 300;
15
14
16
15
export function Composer() {
17
16
const [composerState, setComposerState] = useAtom(composerAtom);
18
-
const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false);
19
17
const { agent } = useAuth();
20
18
21
19
const [postText, setPostText] = useState("");
···
114
112
setPosting(false);
115
113
}
116
114
}
115
+
// if (composerState.kind === "closed") {
116
+
// return null;
117
+
// }
117
118
118
119
const getPlaceholder = () => {
119
120
switch (composerState.kind) {
···
131
132
const isPostButtonDisabled =
132
133
posting || !postText.trim() || isParentLoading || charsLeft < 0;
133
134
134
-
function handleAttemptClose() {
135
-
if (postText.trim() && !posting) {
136
-
setCloseConfirmState(true);
137
-
} else {
138
-
setComposerState({ kind: "closed" });
139
-
}
140
-
}
141
-
142
-
function handleConfirmClose() {
143
-
setComposerState({ kind: "closed" });
144
-
setCloseConfirmState(false);
145
-
setPostText("");
146
-
}
147
-
148
135
return (
149
-
<>
150
-
<Dialog.Root
151
-
open={composerState.kind !== "closed"}
152
-
onOpenChange={(open) => {
153
-
if (!open) handleAttemptClose();
154
-
}}
155
-
>
156
-
<Dialog.Portal>
157
-
<Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
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 disablegutter inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
158
144
159
-
<Dialog.Content className="fixed overflow-y-auto gutter inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]">
160
-
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4">
161
-
<div className="flex flex-row justify-between p-2">
162
-
<Dialog.Close asChild>
163
-
<button
164
-
className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
165
-
disabled={posting}
166
-
aria-label="Close"
167
-
onClick={handleAttemptClose}
145
+
<Dialog.Content className="fixed gutter overflow-y-scroll inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]">
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"
168
164
>
169
-
<svg
170
-
xmlns="http://www.w3.org/2000/svg"
171
-
width="20"
172
-
height="20"
173
-
viewBox="0 0 24 24"
174
-
fill="none"
175
-
stroke="currentColor"
176
-
strokeWidth="2.5"
177
-
strokeLinecap="round"
178
-
strokeLinejoin="round"
179
-
>
180
-
<line x1="18" y1="6" x2="6" y2="18"></line>
181
-
<line x1="6" y1="6" x2="18" y2="18"></line>
182
-
</svg>
183
-
</button>
184
-
</Dialog.Close>
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>
185
170
186
-
<div className="flex-1" />
187
-
<div className="flex items-center gap-4">
188
-
<span
189
-
className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`}
190
-
>
191
-
{charsLeft}
192
-
</span>
193
-
<Button
194
-
onClick={handlePost}
195
-
disabled={isPostButtonDisabled}
196
-
>
197
-
{posting ? "Posting..." : "Post"}
198
-
</Button>
199
-
</div>
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>
200
185
</div>
186
+
</div>
201
187
202
-
{postSuccess ? (
203
-
<div className="flex flex-col items-center justify-center py-16">
204
-
<span className="text-gray-500 text-6xl mb-4">โ</span>
205
-
<span className="text-xl font-bold text-black dark:text-white">
206
-
Posted!
207
-
</span>
208
-
</div>
209
-
) : (
210
-
<div className="px-4">
211
-
{composerState.kind === "reply" && (
212
-
<div className="mb-1 -mx-4">
213
-
{isParentLoading ? (
214
-
<div className="text-sm text-gray-500 animate-pulse">
215
-
Loading parent post...
216
-
</div>
217
-
) : parentUri ? (
218
-
<UniversalPostRendererATURILoader
219
-
atUri={parentUri}
220
-
bottomReplyLine
221
-
bottomBorder={false}
222
-
/>
223
-
) : (
224
-
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
225
-
Could not load parent post.
226
-
</div>
227
-
)}
228
-
</div>
229
-
)}
230
-
231
-
<div className="flex w-full gap-1 flex-col">
232
-
<ProfileThing agent={agent} large />
233
-
<div className="flex pl-[50px]">
234
-
<AutoGrowTextarea
235
-
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2"
236
-
rows={5}
237
-
placeholder={getPlaceholder()}
238
-
value={postText}
239
-
onChange={(e) => setPostText(e.target.value)}
240
-
disabled={posting}
241
-
autoFocus
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}
242
208
/>
243
-
</div>
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
+
)}
244
214
</div>
215
+
)}
245
216
246
-
{composerState.kind === "quote" && (
247
-
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
248
-
{isParentLoading ? (
249
-
<div className="text-sm text-gray-500 animate-pulse">
250
-
Loading parent post...
251
-
</div>
252
-
) : parentUri ? (
253
-
<UniversalPostRendererATURILoader
254
-
atUri={parentUri}
255
-
isQuote
256
-
/>
257
-
) : (
258
-
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
259
-
Could not load parent post.
260
-
</div>
261
-
)}
262
-
</div>
263
-
)}
264
-
265
-
{postError && (
266
-
<div className="text-red-500 text-sm my-2 text-center">
267
-
{postError}
268
-
</div>
269
-
)}
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>
270
230
</div>
271
-
)}
272
-
</div>
273
-
</Dialog.Content>
274
-
</Dialog.Portal>
275
-
</Dialog.Root>
276
231
277
-
{/* Close confirmation dialog */}
278
-
<Dialog.Root open={closeConfirmState} onOpenChange={setCloseConfirmState}>
279
-
<Dialog.Portal>
280
-
281
-
<Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
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
+
)}
282
250
283
-
<Dialog.Content className="fixed gutter inset-0 z-50 flex items-start justify-center pt-30 sm:pt-40">
284
-
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-md relative mx-4 py-6">
285
-
<div className="text-xl mb-4 text-center">
286
-
Discard your post?
287
-
</div>
288
-
<div className="text-md mb-4 text-center">
289
-
You will lose your draft
251
+
{postError && (
252
+
<div className="text-red-500 text-sm my-2 text-center">
253
+
{postError}
254
+
</div>
255
+
)}
290
256
</div>
291
-
<div className="flex justify-end gap-2 px-6">
292
-
<Button
293
-
onClick={handleConfirmClose}
294
-
>
295
-
Discard
296
-
</Button>
297
-
<Button
298
-
variant={"outlined"}
299
-
onClick={() => setCloseConfirmState(false)}
300
-
>
301
-
Cancel
302
-
</Button>
303
-
</div>
304
-
</div>
305
-
</Dialog.Content>
306
-
</Dialog.Portal>
307
-
</Dialog.Root>
308
-
</>
257
+
)}
258
+
</div>
259
+
</Dialog.Content>
260
+
</Dialog.Portal>
261
+
</Dialog.Root>
309
262
);
310
263
}
311
264
+4
-2
src/components/Header.tsx
+4
-2
src/components/Header.tsx
···
5
5
6
6
export function Header({
7
7
backButtonCallback,
8
-
title
8
+
title,
9
+
bottomBorderDisabled,
9
10
}: {
10
11
backButtonCallback?: () => void;
11
12
title?: string;
13
+
bottomBorderDisabled?: boolean;
12
14
}) {
13
15
const router = useRouter();
14
16
const [isAtTop] = useAtom(isAtTopAtom);
15
17
//const what = router.history.
16
18
return (
17
-
<div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}>
19
+
<div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 ${!bottomBorderDisabled && "sm:border-b"} ${!isAtTop && !bottomBorderDisabled && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}>
18
20
{backButtonCallback ? (<Link
19
21
to=".."
20
22
//className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
+37
-3
src/components/Import.tsx
+37
-3
src/components/Import.tsx
···
1
1
import { AtUri } from "@atproto/api";
2
2
import { useNavigate, type UseNavigateResult } from "@tanstack/react-router";
3
+
import { useAtom } from "jotai";
3
4
import { useState } from "react";
5
+
6
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
+
import { lycanURLAtom } from "~/utils/atoms";
8
+
import { useQueryLycanStatus } from "~/utils/useQuery";
4
9
5
10
/**
6
11
* Basically the best equivalent to Search that i can do
7
12
*/
8
-
export function Import() {
9
-
const [textInput, setTextInput] = useState<string | undefined>();
13
+
export function Import({
14
+
optionaltextstring,
15
+
}: {
16
+
optionaltextstring?: string;
17
+
}) {
18
+
const [textInput, setTextInput] = useState<string | undefined>(
19
+
optionaltextstring
20
+
);
10
21
const navigate = useNavigate();
11
22
23
+
const { status } = useAuth();
24
+
const [lycandomain] = useAtom(lycanURLAtom);
25
+
const lycanExists = lycandomain !== "";
26
+
const { data: lycanstatusdata } = useQueryLycanStatus();
27
+
const lycanIndexed = lycanstatusdata?.status === "finished" || false;
28
+
const lycanIndexing = lycanstatusdata?.status === "in_progress" || false;
29
+
const lycanIndexingProgress = lycanIndexing
30
+
? lycanstatusdata?.progress
31
+
: undefined;
32
+
const authed = status === "signedIn";
33
+
34
+
const lycanReady = lycanExists && lycanIndexed && authed;
35
+
12
36
const handleEnter = () => {
13
37
if (!textInput) return;
14
38
handleImport({
15
39
text: textInput,
16
40
navigate,
41
+
lycanReady:
42
+
lycanReady || (!!lycanIndexingProgress && lycanIndexingProgress > 0),
17
43
});
18
44
};
45
+
46
+
const placeholder = lycanReady ? "Search..." : "Import...";
19
47
20
48
return (
21
49
<div className="w-full relative">
···
23
51
24
52
<input
25
53
type="text"
26
-
placeholder="Import..."
54
+
placeholder={placeholder}
27
55
value={textInput}
28
56
onChange={(e) => setTextInput(e.target.value)}
29
57
onKeyDown={(e) => {
···
38
66
function handleImport({
39
67
text,
40
68
navigate,
69
+
lycanReady,
41
70
}: {
42
71
text: string;
43
72
navigate: UseNavigateResult<string>;
73
+
lycanReady?: boolean;
44
74
}) {
45
75
const trimmed = text.trim();
46
76
// parse text
···
147
177
// } catch {
148
178
// // continue
149
179
// }
180
+
181
+
if (lycanReady) {
182
+
navigate({ to: "/search", search: { q: text } });
183
+
}
150
184
}
+6
-1
src/components/InfiniteCustomFeed.tsx
+6
-1
src/components/InfiniteCustomFeed.tsx
···
14
14
feedUri: string;
15
15
pdsUrl?: string;
16
16
feedServiceDid?: string;
17
+
authedOverride?: boolean;
18
+
unauthedfeedurl?: string;
17
19
}
18
20
19
21
export function InfiniteCustomFeed({
20
22
feedUri,
21
23
pdsUrl,
22
24
feedServiceDid,
25
+
authedOverride,
26
+
unauthedfeedurl,
23
27
}: InfiniteCustomFeedProps) {
24
28
const { agent } = useAuth();
25
-
const authed = !!agent?.did;
29
+
const authed = authedOverride || !!agent?.did;
26
30
27
31
// const identityresultmaybe = useQueryIdentity(agent?.did);
28
32
// const identity = identityresultmaybe?.data;
···
45
49
isAuthed: authed ?? false,
46
50
pdsUrl: pdsUrl,
47
51
feedServiceDid: feedServiceDid,
52
+
unauthedfeedurl: unauthedfeedurl,
48
53
});
49
54
const queryClient = useQueryClient();
50
55
+3
-5
src/components/Login.tsx
+3
-5
src/components/Login.tsx
···
7
7
import { imgCDNAtom } from "~/utils/atoms";
8
8
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
9
9
10
-
import { Button } from "./radix-m3-rd/Button";
11
-
12
10
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
13
11
export default function Login({
14
12
compact = false,
···
51
49
You are logged in!
52
50
</p>
53
51
<ProfileThing agent={agent} large />
54
-
<Button
52
+
<button
55
53
onClick={logout}
56
-
className="mt-4"
54
+
className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded-full px-6 py-2 font-semibold text-base transition-colors"
57
55
>
58
56
Log out
59
-
</Button>
57
+
</button>
60
58
</div>
61
59
</div>
62
60
);
+124
src/components/ReusableTabRoute.tsx
+124
src/components/ReusableTabRoute.tsx
···
1
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
2
+
import { useAtom } from "jotai";
3
+
import { useEffect, useLayoutEffect } from "react";
4
+
5
+
import { isAtTopAtom, reusableTabRouteScrollAtom } from "~/utils/atoms";
6
+
7
+
/**
8
+
* Please wrap your Route in a div, do not return a top-level fragment,
9
+
* it will break navigation scroll restoration
10
+
*/
11
+
export function ReusableTabRoute({
12
+
route,
13
+
tabs,
14
+
}: {
15
+
route: string;
16
+
tabs: Record<string, React.ReactNode>;
17
+
}) {
18
+
const [reusableTabState, setReusableTabState] = useAtom(
19
+
reusableTabRouteScrollAtom
20
+
);
21
+
const [isAtTop] = useAtom(isAtTopAtom);
22
+
23
+
const routeState = reusableTabState?.[route] ?? {
24
+
activeTab: Object.keys(tabs)[0],
25
+
scrollPositions: {},
26
+
};
27
+
const activeTab = routeState.activeTab;
28
+
29
+
const handleValueChange = (newTab: string) => {
30
+
setReusableTabState((prev) => {
31
+
const current = prev?.[route] ?? routeState;
32
+
return {
33
+
...prev,
34
+
[route]: {
35
+
...current,
36
+
scrollPositions: {
37
+
...current.scrollPositions,
38
+
[current.activeTab]: window.scrollY,
39
+
},
40
+
activeTab: newTab,
41
+
},
42
+
};
43
+
});
44
+
};
45
+
46
+
// // todo, warning experimental, usually this doesnt work,
47
+
// // like at all, and i usually do this for each tab
48
+
// useLayoutEffect(() => {
49
+
// const savedScroll = routeState.scrollPositions[activeTab] ?? 0;
50
+
// window.scrollTo({ top: savedScroll });
51
+
// // eslint-disable-next-line react-hooks/exhaustive-deps
52
+
// }, [activeTab, route]);
53
+
54
+
useLayoutEffect(() => {
55
+
return () => {
56
+
setReusableTabState((prev) => {
57
+
const current = prev?.[route] ?? routeState;
58
+
return {
59
+
...prev,
60
+
[route]: {
61
+
...current,
62
+
scrollPositions: {
63
+
...current.scrollPositions,
64
+
[current.activeTab]: window.scrollY,
65
+
},
66
+
},
67
+
};
68
+
});
69
+
};
70
+
// eslint-disable-next-line react-hooks/exhaustive-deps
71
+
}, []);
72
+
73
+
return (
74
+
<TabsPrimitive.Root
75
+
value={activeTab}
76
+
onValueChange={handleValueChange}
77
+
className={`w-full`}
78
+
>
79
+
<TabsPrimitive.List
80
+
className={`flex sticky top-[52px] bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-[9] border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}
81
+
>
82
+
{Object.entries(tabs).map(([key]) => (
83
+
<TabsPrimitive.Trigger key={key} value={key} className="m3tab">
84
+
{key}
85
+
</TabsPrimitive.Trigger>
86
+
))}
87
+
</TabsPrimitive.List>
88
+
89
+
{Object.entries(tabs).map(([key, node]) => (
90
+
<TabsPrimitive.Content key={key} value={key} className="flex-1 min-h-[80dvh]">
91
+
{activeTab === key && node}
92
+
</TabsPrimitive.Content>
93
+
))}
94
+
</TabsPrimitive.Root>
95
+
);
96
+
}
97
+
98
+
export function useReusableTabScrollRestore(route: string) {
99
+
const [reusableTabState] = useAtom(
100
+
reusableTabRouteScrollAtom
101
+
);
102
+
103
+
const routeState = reusableTabState?.[route];
104
+
const activeTab = routeState?.activeTab;
105
+
106
+
useEffect(() => {
107
+
const savedScroll = activeTab ? routeState?.scrollPositions[activeTab] ?? 0 : 0;
108
+
//window.scrollTo(0, savedScroll);
109
+
window.scrollTo({ top: savedScroll });
110
+
// eslint-disable-next-line react-hooks/exhaustive-deps
111
+
}, []);
112
+
}
113
+
114
+
115
+
/*
116
+
117
+
const [notifState] = useAtom(notificationsScrollAtom);
118
+
const activeTab = notifState.activeTab;
119
+
useEffect(() => {
120
+
const savedY = notifState.scrollPositions[activeTab] ?? 0;
121
+
window.scrollTo(0, savedY);
122
+
}, [activeTab, notifState.scrollPositions]);
123
+
124
+
*/
+204
-60
src/components/UniversalPostRenderer.tsx
+204
-60
src/components/UniversalPostRenderer.tsx
···
1
+
import * as ATPAPI from "@atproto/api";
1
2
import { useNavigate } from "@tanstack/react-router";
2
3
import DOMPurify from "dompurify";
3
4
import { useAtom } from "jotai";
···
9
10
import {
10
11
composerAtom,
11
12
constellationURLAtom,
13
+
enableBridgyTextAtom,
14
+
enableWafrnTextAtom,
12
15
imgCDNAtom,
13
-
likedPostsAtom,
14
16
} from "~/utils/atoms";
15
17
import { useHydratedEmbed } from "~/utils/useHydrated";
16
18
import {
···
38
40
feedviewpost?: boolean;
39
41
repostedby?: string;
40
42
style?: React.CSSProperties;
41
-
ref?: React.Ref<HTMLDivElement>;
43
+
ref?: React.RefObject<HTMLDivElement>;
42
44
dataIndexPropPass?: number;
43
45
nopics?: boolean;
46
+
concise?: boolean;
44
47
lightboxCallback?: (d: LightboxProps) => void;
45
48
maxReplies?: number;
46
49
isQuote?: boolean;
50
+
filterNoReplies?: boolean;
51
+
filterMustHaveMedia?: boolean;
52
+
filterMustBeReply?: boolean;
47
53
}
48
54
49
55
// export async function cachedGetRecord({
···
152
158
ref,
153
159
dataIndexPropPass,
154
160
nopics,
161
+
concise,
155
162
lightboxCallback,
156
163
maxReplies,
157
164
isQuote,
165
+
filterNoReplies,
166
+
filterMustHaveMedia,
167
+
filterMustBeReply,
158
168
}: UniversalPostRendererATURILoaderProps) {
159
169
// todo remove this once tree rendering is implemented, use a prop like isTree
160
170
const TEMPLINEAR = true;
···
518
528
? true
519
529
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
520
530
? false
521
-
: (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine
531
+
: maxReplies === 0 && (!replies || (!!replies && replies === 0))
532
+
? false
533
+
: bottomReplyLine
522
534
}
523
535
topReplyLine={topReplyLine}
524
536
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
536
548
ref={ref}
537
549
dataIndexPropPass={dataIndexPropPass}
538
550
nopics={nopics}
551
+
concise={concise}
539
552
lightboxCallback={lightboxCallback}
540
553
maxReplies={maxReplies}
541
554
isQuote={isQuote}
555
+
filterNoReplies={filterNoReplies}
556
+
filterMustHaveMedia={filterMustHaveMedia}
557
+
filterMustBeReply={filterMustBeReply}
542
558
/>
543
559
<>
544
-
{(maxReplies && maxReplies === 0 && replies && replies > 0) ? (
560
+
{maxReplies && maxReplies === 0 && replies && replies > 0 ? (
545
561
<>
546
-
{/* <div>hello</div> */}
547
-
<MoreReplies atUri={atUri} />
562
+
{/* <div>hello</div> */}
563
+
<MoreReplies atUri={atUri} />
548
564
</>
549
-
) : (<></>)}
565
+
) : (
566
+
<></>
567
+
)}
550
568
</>
551
569
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
552
570
<>
···
567
585
ref={ref}
568
586
dataIndexPropPass={dataIndexPropPass}
569
587
nopics={nopics}
588
+
concise={concise}
570
589
lightboxCallback={lightboxCallback}
571
590
maxReplies={
572
591
maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
···
636
655
ref,
637
656
dataIndexPropPass,
638
657
nopics,
658
+
concise,
639
659
lightboxCallback,
640
660
maxReplies,
641
661
isQuote,
662
+
filterNoReplies,
663
+
filterMustHaveMedia,
664
+
filterMustBeReply,
642
665
}: {
643
666
postRecord: any;
644
667
profileRecord: any;
···
654
677
feedviewpost?: boolean;
655
678
repostedby?: string;
656
679
style?: React.CSSProperties;
657
-
ref?: React.Ref<HTMLDivElement>;
680
+
ref?: React.RefObject<HTMLDivElement>;
658
681
dataIndexPropPass?: number;
659
682
nopics?: boolean;
683
+
concise?: boolean;
660
684
lightboxCallback?: (d: LightboxProps) => void;
661
685
maxReplies?: number;
662
686
isQuote?: boolean;
687
+
filterNoReplies?: boolean;
688
+
filterMustHaveMedia?: boolean;
689
+
filterMustBeReply?: boolean;
663
690
}) {
664
691
// /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`);
665
692
const navigate = useNavigate();
···
730
757
// run();
731
758
// }, [postRecord, resolved?.did]);
732
759
760
+
const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed;
761
+
const hasImages = hasEmbed?.$type === "app.bsky.embed.images";
762
+
const hasVideo = hasEmbed?.$type === "app.bsky.embed.video";
763
+
const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia";
764
+
const isQuotewithImages =
765
+
isquotewithmedia &&
766
+
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
767
+
"app.bsky.embed.images";
768
+
const isQuotewithVideo =
769
+
isquotewithmedia &&
770
+
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
771
+
"app.bsky.embed.video";
772
+
773
+
const hasMedia =
774
+
hasEmbed &&
775
+
(hasImages || hasVideo || isQuotewithImages || isQuotewithVideo);
776
+
733
777
const {
734
778
data: hydratedEmbed,
735
779
isLoading: isEmbedLoading,
···
824
868
// }, [fakepost, get, set]);
825
869
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
826
870
?.uri;
827
-
const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
871
+
const feedviewpostreplydid =
872
+
thereply && !filterNoReplies ? new AtUri(thereply).host : undefined;
828
873
const replyhookvalue = useQueryIdentity(
829
874
feedviewpost ? feedviewpostreplydid : undefined
830
875
);
···
835
880
repostedby ? aturirepostbydid : undefined
836
881
);
837
882
const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle;
883
+
884
+
if (filterNoReplies && thereply) return null;
885
+
886
+
if (filterMustHaveMedia && !hasMedia) return null;
887
+
888
+
if (filterMustBeReply && !thereply) return null;
889
+
838
890
return (
839
891
<>
840
892
{/* <p>
841
893
{postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)}
842
894
</p> */}
895
+
{/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span>
896
+
<span>thereply is {thereply ? "true" : "false"}</span> */}
843
897
<UniversalPostRenderer
844
898
expanded={detailed}
845
899
onPostClick={() =>
···
874
928
ref={ref}
875
929
dataIndexPropPass={dataIndexPropPass}
876
930
nopics={nopics}
931
+
concise={concise}
877
932
lightboxCallback={lightboxCallback}
878
933
maxReplies={maxReplies}
879
934
isQuote={isQuote}
···
1197
1252
1198
1253
import defaultpfp from "~/../public/favicon.png";
1199
1254
import { useAuth } from "~/providers/UnifiedAuthProvider";
1200
-
import { FollowButton, Mutual } from "~/routes/profile.$did";
1255
+
import { renderSnack } from "~/routes/__root";
1256
+
import {
1257
+
FeedItemRenderAturiLoader,
1258
+
FollowButton,
1259
+
Mutual,
1260
+
} from "~/routes/profile.$did";
1201
1261
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1262
+
import { useFastLike } from "~/utils/likeMutationQueue";
1202
1263
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1203
1264
// import type {
1204
1265
// ViewRecord,
···
1327
1388
ref,
1328
1389
dataIndexPropPass,
1329
1390
nopics,
1391
+
concise,
1330
1392
lightboxCallback,
1331
1393
maxReplies,
1332
1394
}: {
···
1350
1412
depth?: number;
1351
1413
repostedby?: string;
1352
1414
style?: React.CSSProperties;
1353
-
ref?: React.Ref<HTMLDivElement>;
1415
+
ref?: React.RefObject<HTMLDivElement>;
1354
1416
dataIndexPropPass?: number;
1355
1417
nopics?: boolean;
1418
+
concise?: boolean;
1356
1419
lightboxCallback?: (d: LightboxProps) => void;
1357
1420
maxReplies?: number;
1358
1421
}) {
1359
1422
const parsed = new AtUri(post.uri);
1360
1423
const navigate = useNavigate();
1361
-
const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom);
1362
1424
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
1363
1425
post.viewer?.repost ? true : false
1364
1426
);
1365
-
const [hasLiked, setHasLiked] = useState<boolean>(
1366
-
post.uri in likedPosts || post.viewer?.like ? true : false
1367
-
);
1368
1427
const [, setComposerPost] = useAtom(composerAtom);
1369
1428
const { agent } = useAuth();
1370
-
const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like);
1371
1429
const [retweetUri, setRetweetUri] = useState<string | undefined>(
1372
1430
post.viewer?.repost
1373
1431
);
1374
-
1375
-
const likeOrUnlikePost = async () => {
1376
-
const newLikedPosts = { ...likedPosts };
1377
-
if (!agent) {
1378
-
console.error("Agent is null or undefined");
1379
-
return;
1380
-
}
1381
-
if (hasLiked) {
1382
-
if (post.uri in likedPosts) {
1383
-
const likeUri = likedPosts[post.uri];
1384
-
setLikeUri(likeUri);
1385
-
}
1386
-
if (likeUri) {
1387
-
await agent.deleteLike(likeUri);
1388
-
setHasLiked(false);
1389
-
delete newLikedPosts[post.uri];
1390
-
}
1391
-
} else {
1392
-
const { uri } = await agent.like(post.uri, post.cid);
1393
-
setLikeUri(uri);
1394
-
setHasLiked(true);
1395
-
newLikedPosts[post.uri] = uri;
1396
-
}
1397
-
setLikedPosts(newLikedPosts);
1398
-
};
1432
+
const { liked, toggle, backfill } = useFastLike(post.uri, post.cid);
1433
+
// const bovref = useBackfillOnView(post.uri, post.cid);
1434
+
// React.useLayoutEffect(()=>{
1435
+
// if (expanded && !isQuote) {
1436
+
// backfill();
1437
+
// }
1438
+
// },[backfill, expanded, isQuote])
1399
1439
1400
1440
const repostOrUnrepostPost = async () => {
1401
1441
if (!agent) {
···
1426
1466
: undefined;
1427
1467
1428
1468
const emergencySalt = randomString();
1429
-
const fedi = (post.record as { bridgyOriginalText?: string })
1469
+
1470
+
const [showBridgyText] = useAtom(enableBridgyTextAtom);
1471
+
const [showWafrnText] = useAtom(enableWafrnTextAtom);
1472
+
1473
+
const unfedibridgy = (post.record as { bridgyOriginalText?: string })
1430
1474
.bridgyOriginalText;
1475
+
const unfediwafrnPartial = (post.record as { fullText?: string }).fullText;
1476
+
const unfediwafrnTags = (post.record as { fullTags?: string }).fullTags;
1477
+
const unfediwafrnUnHost = (post.record as { fediverseId?: string })
1478
+
.fediverseId;
1479
+
1480
+
const undfediwafrnHost = unfediwafrnUnHost
1481
+
? new URL(unfediwafrnUnHost).hostname
1482
+
: undefined;
1483
+
1484
+
const tags = unfediwafrnTags
1485
+
? unfediwafrnTags
1486
+
.split("\n")
1487
+
.map((t) => t.trim())
1488
+
.filter(Boolean)
1489
+
: undefined;
1490
+
1491
+
const links = tags
1492
+
? tags
1493
+
.map((tag) => {
1494
+
const encoded = encodeURIComponent(tag);
1495
+
return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`;
1496
+
})
1497
+
.join("<br>")
1498
+
: "";
1499
+
1500
+
const unfediwafrn = unfediwafrnPartial
1501
+
? unfediwafrnPartial + (links ? `<br>${links}` : "")
1502
+
: undefined;
1503
+
1504
+
const fedi =
1505
+
(showBridgyText ? unfedibridgy : undefined) ??
1506
+
(showWafrnText ? unfediwafrn : undefined);
1431
1507
1432
1508
/* fuck you */
1433
1509
const isMainItem = false;
1434
1510
const setMainItem = (any: any) => {};
1435
1511
// eslint-disable-next-line react-hooks/refs
1436
-
console.log("Received ref in UniversalPostRenderer:", ref);
1512
+
//console.log("Received ref in UniversalPostRenderer:", usedref);
1437
1513
return (
1438
1514
<div ref={ref} style={style} data-index={dataIndexPropPass}>
1439
1515
<div
···
1557
1633
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
1634
/>
1559
1635
<div className=" flex-1 flex flex-row align-middle justify-end">
1560
-
<div className=" flex flex-col justify-start">
1561
1636
<FollowButton targetdidorhandle={post.author.did} />
1562
-
</div>
1563
1637
</div>
1564
1638
</div>
1565
1639
<div className="flex flex-col gap-3">
···
1568
1642
{post.author.displayName || post.author.handle}{" "}
1569
1643
</div>
1570
1644
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1571
-
<Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "}
1645
+
<Mutual targetdidorhandle={post.author.did} />@
1646
+
{post.author.handle}{" "}
1572
1647
</div>
1573
1648
</div>
1574
1649
{uprrrsauthor?.description && (
···
1761
1836
<div
1762
1837
style={{
1763
1838
fontSize: 16,
1764
-
marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8,
1839
+
marginBottom: !post.embed || concise ? 0 : 8,
1765
1840
whiteSpace: "pre-wrap",
1766
1841
textAlign: "left",
1767
1842
overflowWrap: "anywhere",
1768
1843
wordBreak: "break-word",
1769
-
//color: theme.text,
1844
+
...(concise && {
1845
+
display: "-webkit-box",
1846
+
WebkitBoxOrient: "vertical",
1847
+
WebkitLineClamp: 2,
1848
+
overflow: "hidden",
1849
+
}),
1770
1850
}}
1771
1851
className="text-gray-900 dark:text-gray-100"
1772
1852
>
···
1789
1869
</>
1790
1870
)}
1791
1871
</div>
1792
-
{post.embed && depth < 1 ? (
1872
+
{post.embed && depth < 1 && !concise ? (
1793
1873
<PostEmbeds
1794
1874
embed={post.embed}
1795
1875
//moderation={moderation}
···
1811
1891
</div>
1812
1892
</>
1813
1893
)}
1814
-
<div style={{ paddingTop: post.embed && depth < 1 ? 4 : 0 }}>
1894
+
<div
1895
+
style={{
1896
+
paddingTop: post.embed && !concise && depth < 1 ? 4 : 0,
1897
+
}}
1898
+
>
1815
1899
<>
1816
1900
{expanded && (
1817
1901
<div
···
1907
1991
</DropdownMenu.Root>
1908
1992
<HitSlopButton
1909
1993
onClick={() => {
1910
-
likeOrUnlikePost();
1994
+
toggle();
1911
1995
}}
1912
1996
style={{
1913
1997
...btnstyle,
1914
-
...(hasLiked ? { color: "#EC4899" } : {}),
1998
+
...(liked ? { color: "#EC4899" } : {}),
1915
1999
}}
1916
2000
>
1917
-
{hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
1918
-
{(post.likeCount || 0) + (hasLiked ? 1 : 0)}
2001
+
{liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
2002
+
{(post.likeCount || 0) + (liked ? 1 : 0)}
1919
2003
</HitSlopButton>
1920
2004
<div style={{ display: "flex", gap: 8 }}>
1921
2005
<HitSlopButton
···
1929
2013
"/post/" +
1930
2014
post.uri.split("/").pop()
1931
2015
);
2016
+
renderSnack({
2017
+
title: "Copied to clipboard!",
2018
+
});
1932
2019
} catch (_e) {
1933
2020
// idk
2021
+
renderSnack({
2022
+
title: "Failed to copy link",
2023
+
});
1934
2024
}
1935
2025
}}
1936
2026
style={{
···
1939
2029
>
1940
2030
<MdiShareVariant />
1941
2031
</HitSlopButton>
1942
-
<span style={btnstyle}>
1943
-
<MdiMoreHoriz />
1944
-
</span>
2032
+
<HitSlopButton
2033
+
onClick={() => {
2034
+
renderSnack({
2035
+
title: "Not implemented yet...",
2036
+
});
2037
+
}}
2038
+
>
2039
+
<span style={btnstyle}>
2040
+
<MdiMoreHoriz />
2041
+
</span>
2042
+
</HitSlopButton>
1945
2043
</div>
1946
2044
</div>
1947
2045
)}
···
2167
2265
}
2168
2266
2169
2267
if (AppBskyEmbedRecord.isView(embed)) {
2268
+
// hey im really lazy and im gonna do it the bad way
2269
+
const reallybaduri = (embed?.record as any)?.uri as string | undefined;
2270
+
const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined;
2271
+
2170
2272
// custom feed embed (i.e. generator view)
2171
2273
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
2172
2274
// stopgap sorry
···
2176
2278
// <MaybeFeedCard view={embed.record} />
2177
2279
// </div>
2178
2280
// )
2281
+
} else if (
2282
+
!!reallybaduri &&
2283
+
!!reallybadaturi &&
2284
+
reallybadaturi.collection === "app.bsky.feed.generator"
2285
+
) {
2286
+
return (
2287
+
<div className="rounded-xl border">
2288
+
<FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder />
2289
+
</div>
2290
+
);
2179
2291
}
2180
2292
2181
2293
// list embed
···
2187
2299
// <MaybeListCard view={embed.record} />
2188
2300
// </div>
2189
2301
// )
2302
+
} else if (
2303
+
!!reallybaduri &&
2304
+
!!reallybadaturi &&
2305
+
reallybadaturi.collection === "app.bsky.graph.list"
2306
+
) {
2307
+
return (
2308
+
<div className="rounded-xl border">
2309
+
<FeedItemRenderAturiLoader
2310
+
aturi={reallybaduri}
2311
+
disableBottomBorder
2312
+
listmode
2313
+
disablePropagation
2314
+
/>
2315
+
</div>
2316
+
);
2190
2317
}
2191
2318
2192
2319
// starter pack embed
···
2198
2325
// <StarterPackCard starterPack={embed.record} />
2199
2326
// </div>
2200
2327
// )
2328
+
} else if (
2329
+
!!reallybaduri &&
2330
+
!!reallybadaturi &&
2331
+
reallybadaturi.collection === "app.bsky.graph.starterpack"
2332
+
) {
2333
+
return (
2334
+
<div className="rounded-xl border">
2335
+
<FeedItemRenderAturiLoader
2336
+
aturi={reallybaduri}
2337
+
disableBottomBorder
2338
+
listmode
2339
+
disablePropagation
2340
+
/>
2341
+
</div>
2342
+
);
2201
2343
}
2202
2344
2203
2345
// quote post
···
2257
2399
</div>
2258
2400
);
2259
2401
} else {
2402
+
console.log("what the hell is a ", embed);
2260
2403
return <>sorry</>;
2261
2404
}
2262
2405
//return <QuotePostRenderer record={embed.record} moderation={moderation} />;
···
2566
2709
// =
2567
2710
if (AppBskyEmbedVideo.isView(embed)) {
2568
2711
// hls playlist
2712
+
if (nopics) return;
2569
2713
const playlist = embed.playlist;
2570
2714
return (
2571
2715
<SmartHLSPlayer
···
2689
2833
className="link"
2690
2834
style={{
2691
2835
textDecoration: "none",
2692
-
color: "rgb(29, 122, 242)",
2836
+
color: "var(--link-text-color)",
2693
2837
wordBreak: "break-all",
2694
2838
}}
2695
2839
target="_blank"
···
2709
2853
result.push(
2710
2854
<span
2711
2855
key={start}
2712
-
style={{ color: "rgb(29, 122, 242)" }}
2856
+
style={{ color: "var(--link-text-color)" }}
2713
2857
className=" cursor-pointer"
2714
2858
onClick={(e) => {
2715
2859
e.stopPropagation();
···
2727
2871
result.push(
2728
2872
<span
2729
2873
key={start}
2730
-
style={{ color: "rgb(29, 122, 242)" }}
2874
+
style={{ color: "var(--link-text-color)" }}
2731
2875
onClick={(e) => {
2732
2876
e.stopPropagation();
2733
2877
}}
-59
src/components/radix-m3-rd/Button.tsx
-59
src/components/radix-m3-rd/Button.tsx
···
1
-
import { Slot } from "@radix-ui/react-slot";
2
-
import clsx from "clsx";
3
-
import * as React from "react";
4
-
5
-
export type ButtonVariant = "filled" | "outlined" | "text" | "secondary";
6
-
export type ButtonSize = "sm" | "md" | "lg";
7
-
8
-
const variantClasses: Record<ButtonVariant, string> = {
9
-
filled:
10
-
"bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500",
11
-
secondary:
12
-
"bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500",
13
-
outlined:
14
-
"border border-gray-800 text-gray-800 hover:bg-gray-100 dark:border-gray-200 dark:text-gray-200 dark:hover:bg-gray-800/10",
15
-
text: "bg-transparent text-gray-800 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800/10",
16
-
};
17
-
18
-
const sizeClasses: Record<ButtonSize, string> = {
19
-
sm: "px-3 py-1.5 text-sm",
20
-
md: "px-4 py-2 text-base",
21
-
lg: "px-6 py-3 text-lg",
22
-
};
23
-
24
-
export function Button({
25
-
variant = "filled",
26
-
size = "md",
27
-
asChild = false,
28
-
ref,
29
-
className,
30
-
children,
31
-
...props
32
-
}: {
33
-
variant?: ButtonVariant;
34
-
size?: ButtonSize;
35
-
asChild?: boolean;
36
-
className?: string;
37
-
children?: React.ReactNode;
38
-
ref?: React.Ref<HTMLButtonElement>;
39
-
} & React.ComponentPropsWithoutRef<"button">) {
40
-
const Comp = asChild ? Slot : "button";
41
-
42
-
return (
43
-
<Comp
44
-
ref={ref}
45
-
className={clsx(
46
-
//focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-gray-500 dark:focus:ring-gray-300
47
-
"inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
48
-
variantClasses[variant],
49
-
sizeClasses[size],
50
-
className
51
-
)}
52
-
{...props}
53
-
>
54
-
{children}
55
-
</Comp>
56
-
);
57
-
}
58
-
59
-
Button.displayName = "Button";
+163
src/providers/LikeMutationQueueProvider.tsx
+163
src/providers/LikeMutationQueueProvider.tsx
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { TID } from "@atproto/common-web";
3
+
import { useQueryClient } from "@tanstack/react-query";
4
+
import { useAtom } from "jotai";
5
+
import React, { createContext, use, useCallback, useEffect, useRef } from "react";
6
+
7
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
8
+
import { renderSnack } from "~/routes/__root";
9
+
import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms";
10
+
import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery";
11
+
12
+
export type LikeRecord = { uri: string; target: string; cid: string };
13
+
export type LikeMutation = { type: 'like'; target: string; cid: string };
14
+
export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord };
15
+
export type Mutation = LikeMutation | UnlikeMutation;
16
+
17
+
interface LikeMutationQueueContextType {
18
+
fastState: (target: string) => LikeRecord | null | undefined;
19
+
fastToggle: (target:string, cid:string) => void;
20
+
backfillState: (target: string, user: string) => Promise<void>;
21
+
}
22
+
23
+
const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined);
24
+
25
+
export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) {
26
+
const { agent } = useAuth();
27
+
const queryClient = useQueryClient();
28
+
const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom);
29
+
const [constellationurl] = useAtom(constellationURLAtom);
30
+
31
+
const likedPostsRef = useRef(likedPosts);
32
+
useEffect(() => {
33
+
likedPostsRef.current = likedPosts;
34
+
}, [likedPosts]);
35
+
36
+
const queueRef = useRef<Mutation[]>([]);
37
+
const runningRef = useRef(false);
38
+
39
+
const fastState = (target: string) => likedPosts[target];
40
+
41
+
const setFastState = useCallback(
42
+
(target: string, record: LikeRecord | null) =>
43
+
setLikedPosts((prev) => ({ ...prev, [target]: record })),
44
+
[setLikedPosts]
45
+
);
46
+
47
+
const enqueue = (mutation: Mutation) => queueRef.current.push(mutation);
48
+
49
+
const fastToggle = useCallback((target: string, cid: string) => {
50
+
const likedRecord = likedPostsRef.current[target];
51
+
52
+
if (likedRecord) {
53
+
setFastState(target, null);
54
+
if (likedRecord.uri !== 'pending') {
55
+
enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord });
56
+
}
57
+
} else {
58
+
setFastState(target, { uri: "pending", target, cid });
59
+
enqueue({ type: "like", target, cid });
60
+
}
61
+
}, [setFastState]);
62
+
63
+
/**
64
+
*
65
+
* @deprecated dont use it yet, will cause infinite rerenders
66
+
*/
67
+
const backfillState = async (target: string, user: string) => {
68
+
const query = constructConstellationQuery({
69
+
constellation: constellationurl,
70
+
method: "/links",
71
+
target,
72
+
collection: "app.bsky.feed.like",
73
+
path: ".subject.uri",
74
+
dids: [user],
75
+
});
76
+
const data = await queryClient.fetchQuery(query);
77
+
const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? [];
78
+
const found = likes.find((r) => r.did === user);
79
+
if (found) {
80
+
const uri = `at://${found.did}/${found.collection}/${found.rkey}`;
81
+
const ciddata = await queryClient.fetchQuery(
82
+
constructArbitraryQuery(uri)
83
+
);
84
+
if (ciddata?.cid)
85
+
setFastState(target, { uri, target, cid: ciddata?.cid });
86
+
} else {
87
+
setFastState(target, null);
88
+
}
89
+
};
90
+
91
+
92
+
useEffect(() => {
93
+
if (!agent?.did) return;
94
+
95
+
const processQueue = async () => {
96
+
if (runningRef.current || queueRef.current.length === 0) return;
97
+
runningRef.current = true;
98
+
99
+
while (queueRef.current.length > 0) {
100
+
const mutation = queueRef.current.shift()!;
101
+
try {
102
+
if (mutation.type === "like") {
103
+
const newRecord = {
104
+
repo: agent.did!,
105
+
collection: "app.bsky.feed.like",
106
+
rkey: TID.next().toString(),
107
+
record: {
108
+
$type: "app.bsky.feed.like",
109
+
subject: { uri: mutation.target, cid: mutation.cid },
110
+
createdAt: new Date().toISOString(),
111
+
},
112
+
};
113
+
const response = await agent.com.atproto.repo.createRecord(newRecord);
114
+
if (!response.success) throw new Error("createRecord failed");
115
+
116
+
const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`;
117
+
setFastState(mutation.target, {
118
+
uri,
119
+
target: mutation.target,
120
+
cid: mutation.cid,
121
+
});
122
+
} else if (mutation.type === "unlike") {
123
+
const aturi = new AtUri(mutation.likeRecordUri);
124
+
await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey });
125
+
setFastState(mutation.target, null);
126
+
}
127
+
} catch (err) {
128
+
console.error("Like mutation failed, reverting:", err);
129
+
renderSnack({
130
+
title: 'Like Mutation Failed',
131
+
description: 'Please try again.',
132
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
133
+
})
134
+
if (mutation.type === 'like') {
135
+
setFastState(mutation.target, null);
136
+
} else if (mutation.type === 'unlike') {
137
+
setFastState(mutation.target, mutation.originalRecord);
138
+
}
139
+
}
140
+
}
141
+
runningRef.current = false;
142
+
};
143
+
144
+
const interval = setInterval(processQueue, 1000);
145
+
return () => clearInterval(interval);
146
+
}, [agent, setFastState]);
147
+
148
+
const value = { fastState, fastToggle, backfillState };
149
+
150
+
return (
151
+
<LikeMutationQueueContext value={value}>
152
+
{children}
153
+
</LikeMutationQueueContext>
154
+
);
155
+
}
156
+
157
+
export function useLikeMutationQueue() {
158
+
const context = use(LikeMutationQueueContext);
159
+
if (context === undefined) {
160
+
throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider');
161
+
}
162
+
return context;
163
+
}
+150
src/routeTree.gen.ts
+150
src/routeTree.gen.ts
···
12
12
import { Route as SettingsRouteImport } from './routes/settings'
13
13
import { Route as SearchRouteImport } from './routes/search'
14
14
import { Route as NotificationsRouteImport } from './routes/notifications'
15
+
import { Route as ModerationRouteImport } from './routes/moderation'
15
16
import { Route as FeedsRouteImport } from './routes/feeds'
16
17
import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout'
17
18
import { Route as IndexRouteImport } from './routes/index'
18
19
import { Route as CallbackIndexRouteImport } from './routes/callback/index'
19
20
import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout'
20
21
import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index'
22
+
import { Route as ProfileDidFollowsRouteImport } from './routes/profile.$did/follows'
23
+
import { Route as ProfileDidFollowersRouteImport } from './routes/profile.$did/followers'
21
24
import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b'
22
25
import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a'
23
26
import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey'
27
+
import { Route as ProfileDidFeedRkeyRouteImport } from './routes/profile.$did/feed.$rkey'
28
+
import { Route as ProfileDidPostRkeyRepostedByRouteImport } from './routes/profile.$did/post.$rkey.reposted-by'
29
+
import { Route as ProfileDidPostRkeyQuotesRouteImport } from './routes/profile.$did/post.$rkey.quotes'
30
+
import { Route as ProfileDidPostRkeyLikedByRouteImport } from './routes/profile.$did/post.$rkey.liked-by'
24
31
import { Route as ProfileDidPostRkeyImageIRouteImport } from './routes/profile.$did/post.$rkey.image.$i'
25
32
26
33
const SettingsRoute = SettingsRouteImport.update({
···
38
45
path: '/notifications',
39
46
getParentRoute: () => rootRouteImport,
40
47
} as any)
48
+
const ModerationRoute = ModerationRouteImport.update({
49
+
id: '/moderation',
50
+
path: '/moderation',
51
+
getParentRoute: () => rootRouteImport,
52
+
} as any)
41
53
const FeedsRoute = FeedsRouteImport.update({
42
54
id: '/feeds',
43
55
path: '/feeds',
···
67
79
path: '/profile/$did/',
68
80
getParentRoute: () => rootRouteImport,
69
81
} as any)
82
+
const ProfileDidFollowsRoute = ProfileDidFollowsRouteImport.update({
83
+
id: '/profile/$did/follows',
84
+
path: '/profile/$did/follows',
85
+
getParentRoute: () => rootRouteImport,
86
+
} as any)
87
+
const ProfileDidFollowersRoute = ProfileDidFollowersRouteImport.update({
88
+
id: '/profile/$did/followers',
89
+
path: '/profile/$did/followers',
90
+
getParentRoute: () => rootRouteImport,
91
+
} as any)
70
92
const PathlessLayoutNestedLayoutRouteBRoute =
71
93
PathlessLayoutNestedLayoutRouteBRouteImport.update({
72
94
id: '/route-b',
···
84
106
path: '/profile/$did/post/$rkey',
85
107
getParentRoute: () => rootRouteImport,
86
108
} as any)
109
+
const ProfileDidFeedRkeyRoute = ProfileDidFeedRkeyRouteImport.update({
110
+
id: '/profile/$did/feed/$rkey',
111
+
path: '/profile/$did/feed/$rkey',
112
+
getParentRoute: () => rootRouteImport,
113
+
} as any)
114
+
const ProfileDidPostRkeyRepostedByRoute =
115
+
ProfileDidPostRkeyRepostedByRouteImport.update({
116
+
id: '/reposted-by',
117
+
path: '/reposted-by',
118
+
getParentRoute: () => ProfileDidPostRkeyRoute,
119
+
} as any)
120
+
const ProfileDidPostRkeyQuotesRoute =
121
+
ProfileDidPostRkeyQuotesRouteImport.update({
122
+
id: '/quotes',
123
+
path: '/quotes',
124
+
getParentRoute: () => ProfileDidPostRkeyRoute,
125
+
} as any)
126
+
const ProfileDidPostRkeyLikedByRoute =
127
+
ProfileDidPostRkeyLikedByRouteImport.update({
128
+
id: '/liked-by',
129
+
path: '/liked-by',
130
+
getParentRoute: () => ProfileDidPostRkeyRoute,
131
+
} as any)
87
132
const ProfileDidPostRkeyImageIRoute =
88
133
ProfileDidPostRkeyImageIRouteImport.update({
89
134
id: '/image/$i',
···
94
139
export interface FileRoutesByFullPath {
95
140
'/': typeof IndexRoute
96
141
'/feeds': typeof FeedsRoute
142
+
'/moderation': typeof ModerationRoute
97
143
'/notifications': typeof NotificationsRoute
98
144
'/search': typeof SearchRoute
99
145
'/settings': typeof SettingsRoute
100
146
'/callback': typeof CallbackIndexRoute
101
147
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
102
148
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
149
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
150
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
103
151
'/profile/$did': typeof ProfileDidIndexRoute
152
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
104
153
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
154
+
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
155
+
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
156
+
'/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute
105
157
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
106
158
}
107
159
export interface FileRoutesByTo {
108
160
'/': typeof IndexRoute
109
161
'/feeds': typeof FeedsRoute
162
+
'/moderation': typeof ModerationRoute
110
163
'/notifications': typeof NotificationsRoute
111
164
'/search': typeof SearchRoute
112
165
'/settings': typeof SettingsRoute
113
166
'/callback': typeof CallbackIndexRoute
114
167
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
115
168
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
169
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
170
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
116
171
'/profile/$did': typeof ProfileDidIndexRoute
172
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
117
173
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
174
+
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
175
+
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
176
+
'/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute
118
177
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
119
178
}
120
179
export interface FileRoutesById {
···
122
181
'/': typeof IndexRoute
123
182
'/_pathlessLayout': typeof PathlessLayoutRouteWithChildren
124
183
'/feeds': typeof FeedsRoute
184
+
'/moderation': typeof ModerationRoute
125
185
'/notifications': typeof NotificationsRoute
126
186
'/search': typeof SearchRoute
127
187
'/settings': typeof SettingsRoute
···
129
189
'/callback/': typeof CallbackIndexRoute
130
190
'/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
131
191
'/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
192
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
193
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
132
194
'/profile/$did/': typeof ProfileDidIndexRoute
195
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
133
196
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
197
+
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
198
+
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
199
+
'/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute
134
200
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
135
201
}
136
202
export interface FileRouteTypes {
···
138
204
fullPaths:
139
205
| '/'
140
206
| '/feeds'
207
+
| '/moderation'
141
208
| '/notifications'
142
209
| '/search'
143
210
| '/settings'
144
211
| '/callback'
145
212
| '/route-a'
146
213
| '/route-b'
214
+
| '/profile/$did/followers'
215
+
| '/profile/$did/follows'
147
216
| '/profile/$did'
217
+
| '/profile/$did/feed/$rkey'
148
218
| '/profile/$did/post/$rkey'
219
+
| '/profile/$did/post/$rkey/liked-by'
220
+
| '/profile/$did/post/$rkey/quotes'
221
+
| '/profile/$did/post/$rkey/reposted-by'
149
222
| '/profile/$did/post/$rkey/image/$i'
150
223
fileRoutesByTo: FileRoutesByTo
151
224
to:
152
225
| '/'
153
226
| '/feeds'
227
+
| '/moderation'
154
228
| '/notifications'
155
229
| '/search'
156
230
| '/settings'
157
231
| '/callback'
158
232
| '/route-a'
159
233
| '/route-b'
234
+
| '/profile/$did/followers'
235
+
| '/profile/$did/follows'
160
236
| '/profile/$did'
237
+
| '/profile/$did/feed/$rkey'
161
238
| '/profile/$did/post/$rkey'
239
+
| '/profile/$did/post/$rkey/liked-by'
240
+
| '/profile/$did/post/$rkey/quotes'
241
+
| '/profile/$did/post/$rkey/reposted-by'
162
242
| '/profile/$did/post/$rkey/image/$i'
163
243
id:
164
244
| '__root__'
165
245
| '/'
166
246
| '/_pathlessLayout'
167
247
| '/feeds'
248
+
| '/moderation'
168
249
| '/notifications'
169
250
| '/search'
170
251
| '/settings'
···
172
253
| '/callback/'
173
254
| '/_pathlessLayout/_nested-layout/route-a'
174
255
| '/_pathlessLayout/_nested-layout/route-b'
256
+
| '/profile/$did/followers'
257
+
| '/profile/$did/follows'
175
258
| '/profile/$did/'
259
+
| '/profile/$did/feed/$rkey'
176
260
| '/profile/$did/post/$rkey'
261
+
| '/profile/$did/post/$rkey/liked-by'
262
+
| '/profile/$did/post/$rkey/quotes'
263
+
| '/profile/$did/post/$rkey/reposted-by'
177
264
| '/profile/$did/post/$rkey/image/$i'
178
265
fileRoutesById: FileRoutesById
179
266
}
···
181
268
IndexRoute: typeof IndexRoute
182
269
PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren
183
270
FeedsRoute: typeof FeedsRoute
271
+
ModerationRoute: typeof ModerationRoute
184
272
NotificationsRoute: typeof NotificationsRoute
185
273
SearchRoute: typeof SearchRoute
186
274
SettingsRoute: typeof SettingsRoute
187
275
CallbackIndexRoute: typeof CallbackIndexRoute
276
+
ProfileDidFollowersRoute: typeof ProfileDidFollowersRoute
277
+
ProfileDidFollowsRoute: typeof ProfileDidFollowsRoute
188
278
ProfileDidIndexRoute: typeof ProfileDidIndexRoute
279
+
ProfileDidFeedRkeyRoute: typeof ProfileDidFeedRkeyRoute
189
280
ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren
190
281
}
191
282
···
212
303
preLoaderRoute: typeof NotificationsRouteImport
213
304
parentRoute: typeof rootRouteImport
214
305
}
306
+
'/moderation': {
307
+
id: '/moderation'
308
+
path: '/moderation'
309
+
fullPath: '/moderation'
310
+
preLoaderRoute: typeof ModerationRouteImport
311
+
parentRoute: typeof rootRouteImport
312
+
}
215
313
'/feeds': {
216
314
id: '/feeds'
217
315
path: '/feeds'
···
254
352
preLoaderRoute: typeof ProfileDidIndexRouteImport
255
353
parentRoute: typeof rootRouteImport
256
354
}
355
+
'/profile/$did/follows': {
356
+
id: '/profile/$did/follows'
357
+
path: '/profile/$did/follows'
358
+
fullPath: '/profile/$did/follows'
359
+
preLoaderRoute: typeof ProfileDidFollowsRouteImport
360
+
parentRoute: typeof rootRouteImport
361
+
}
362
+
'/profile/$did/followers': {
363
+
id: '/profile/$did/followers'
364
+
path: '/profile/$did/followers'
365
+
fullPath: '/profile/$did/followers'
366
+
preLoaderRoute: typeof ProfileDidFollowersRouteImport
367
+
parentRoute: typeof rootRouteImport
368
+
}
257
369
'/_pathlessLayout/_nested-layout/route-b': {
258
370
id: '/_pathlessLayout/_nested-layout/route-b'
259
371
path: '/route-b'
···
275
387
preLoaderRoute: typeof ProfileDidPostRkeyRouteImport
276
388
parentRoute: typeof rootRouteImport
277
389
}
390
+
'/profile/$did/feed/$rkey': {
391
+
id: '/profile/$did/feed/$rkey'
392
+
path: '/profile/$did/feed/$rkey'
393
+
fullPath: '/profile/$did/feed/$rkey'
394
+
preLoaderRoute: typeof ProfileDidFeedRkeyRouteImport
395
+
parentRoute: typeof rootRouteImport
396
+
}
397
+
'/profile/$did/post/$rkey/reposted-by': {
398
+
id: '/profile/$did/post/$rkey/reposted-by'
399
+
path: '/reposted-by'
400
+
fullPath: '/profile/$did/post/$rkey/reposted-by'
401
+
preLoaderRoute: typeof ProfileDidPostRkeyRepostedByRouteImport
402
+
parentRoute: typeof ProfileDidPostRkeyRoute
403
+
}
404
+
'/profile/$did/post/$rkey/quotes': {
405
+
id: '/profile/$did/post/$rkey/quotes'
406
+
path: '/quotes'
407
+
fullPath: '/profile/$did/post/$rkey/quotes'
408
+
preLoaderRoute: typeof ProfileDidPostRkeyQuotesRouteImport
409
+
parentRoute: typeof ProfileDidPostRkeyRoute
410
+
}
411
+
'/profile/$did/post/$rkey/liked-by': {
412
+
id: '/profile/$did/post/$rkey/liked-by'
413
+
path: '/liked-by'
414
+
fullPath: '/profile/$did/post/$rkey/liked-by'
415
+
preLoaderRoute: typeof ProfileDidPostRkeyLikedByRouteImport
416
+
parentRoute: typeof ProfileDidPostRkeyRoute
417
+
}
278
418
'/profile/$did/post/$rkey/image/$i': {
279
419
id: '/profile/$did/post/$rkey/image/$i'
280
420
path: '/image/$i'
···
316
456
)
317
457
318
458
interface ProfileDidPostRkeyRouteChildren {
459
+
ProfileDidPostRkeyLikedByRoute: typeof ProfileDidPostRkeyLikedByRoute
460
+
ProfileDidPostRkeyQuotesRoute: typeof ProfileDidPostRkeyQuotesRoute
461
+
ProfileDidPostRkeyRepostedByRoute: typeof ProfileDidPostRkeyRepostedByRoute
319
462
ProfileDidPostRkeyImageIRoute: typeof ProfileDidPostRkeyImageIRoute
320
463
}
321
464
322
465
const ProfileDidPostRkeyRouteChildren: ProfileDidPostRkeyRouteChildren = {
466
+
ProfileDidPostRkeyLikedByRoute: ProfileDidPostRkeyLikedByRoute,
467
+
ProfileDidPostRkeyQuotesRoute: ProfileDidPostRkeyQuotesRoute,
468
+
ProfileDidPostRkeyRepostedByRoute: ProfileDidPostRkeyRepostedByRoute,
323
469
ProfileDidPostRkeyImageIRoute: ProfileDidPostRkeyImageIRoute,
324
470
}
325
471
···
330
476
IndexRoute: IndexRoute,
331
477
PathlessLayoutRoute: PathlessLayoutRouteWithChildren,
332
478
FeedsRoute: FeedsRoute,
479
+
ModerationRoute: ModerationRoute,
333
480
NotificationsRoute: NotificationsRoute,
334
481
SearchRoute: SearchRoute,
335
482
SettingsRoute: SettingsRoute,
336
483
CallbackIndexRoute: CallbackIndexRoute,
484
+
ProfileDidFollowersRoute: ProfileDidFollowersRoute,
485
+
ProfileDidFollowsRoute: ProfileDidFollowsRoute,
337
486
ProfileDidIndexRoute: ProfileDidIndexRoute,
487
+
ProfileDidFeedRkeyRoute: ProfileDidFeedRkeyRoute,
338
488
ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren,
339
489
}
340
490
export const routeTree = rootRouteImport
+178
-16
src/routes/__root.tsx
+178
-16
src/routes/__root.tsx
···
14
14
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
15
15
import { useAtom } from "jotai";
16
16
import * as React from "react";
17
+
import { toast as sonnerToast } from "sonner";
18
+
import { Toaster } from "sonner";
17
19
import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive";
18
20
19
21
import { Composer } from "~/components/Composer";
···
22
24
import Login from "~/components/Login";
23
25
import { NotFound } from "~/components/NotFound";
24
26
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
27
+
import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider";
25
28
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
26
29
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
27
30
import { seo } from "~/utils/seo";
···
79
82
function RootComponent() {
80
83
return (
81
84
<UnifiedAuthProvider>
82
-
<RootDocument>
83
-
<KeepAliveProvider>
84
-
<KeepAliveOutlet />
85
-
</KeepAliveProvider>
86
-
</RootDocument>
85
+
<LikeMutationQueueProvider>
86
+
<RootDocument>
87
+
<KeepAliveProvider>
88
+
<AppToaster />
89
+
<KeepAliveOutlet />
90
+
</KeepAliveProvider>
91
+
</RootDocument>
92
+
</LikeMutationQueueProvider>
87
93
</UnifiedAuthProvider>
88
94
);
89
95
}
90
96
97
+
export function AppToaster() {
98
+
return (
99
+
<Toaster
100
+
position="bottom-center"
101
+
toastOptions={{
102
+
duration: 4000,
103
+
}}
104
+
/>
105
+
);
106
+
}
107
+
108
+
export function renderSnack({
109
+
title,
110
+
description,
111
+
button,
112
+
}: Omit<ToastProps, "id">) {
113
+
return sonnerToast.custom((id) => (
114
+
<Snack
115
+
id={id}
116
+
title={title}
117
+
description={description}
118
+
button={
119
+
button?.label
120
+
? {
121
+
label: button?.label,
122
+
onClick: () => {
123
+
button?.onClick?.();
124
+
},
125
+
}
126
+
: undefined
127
+
}
128
+
/>
129
+
));
130
+
}
131
+
132
+
function Snack(props: ToastProps) {
133
+
const { title, description, button, id } = props;
134
+
135
+
return (
136
+
<div
137
+
role="status"
138
+
aria-live="polite"
139
+
className="
140
+
w-full md:max-w-[520px]
141
+
flex items-center justify-between
142
+
rounded-md
143
+
px-4 py-3
144
+
shadow-sm
145
+
dark:bg-gray-300 dark:text-gray-900
146
+
bg-gray-700 text-gray-100
147
+
ring-1 dark:ring-gray-200 ring-gray-800
148
+
"
149
+
>
150
+
<div className="flex-1 min-w-0">
151
+
<p className="text-sm font-medium truncate">{title}</p>
152
+
{description ? (
153
+
<p className="mt-1 text-sm dark:text-gray-600 text-gray-300 truncate">
154
+
{description}
155
+
</p>
156
+
) : null}
157
+
</div>
158
+
159
+
{button ? (
160
+
<div className="ml-4 flex-shrink-0">
161
+
<button
162
+
className="
163
+
text-sm font-medium
164
+
px-3 py-1 rounded-md
165
+
bg-gray-200 text-gray-900
166
+
hover:bg-gray-300
167
+
dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700
168
+
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-300 dark:focus:ring-gray-700
169
+
"
170
+
onClick={() => {
171
+
button.onClick();
172
+
sonnerToast.dismiss(id);
173
+
}}
174
+
>
175
+
{button.label}
176
+
</button>
177
+
</div>
178
+
) : null}
179
+
<button className=" ml-4"
180
+
onClick={() => {
181
+
sonnerToast.dismiss(id);
182
+
}}
183
+
>
184
+
<IconMdiClose />
185
+
</button>
186
+
</div>
187
+
);
188
+
}
189
+
190
+
/* Types */
191
+
interface ToastProps {
192
+
id: string | number;
193
+
title: string;
194
+
description?: string;
195
+
button?: {
196
+
label: string;
197
+
onClick: () => void;
198
+
};
199
+
}
200
+
91
201
function RootDocument({ children }: { children: React.ReactNode }) {
92
202
useAtomCssVar(hueAtom, "--tw-gray-hue");
93
203
const location = useLocation();
···
103
213
const isSettings = location.pathname.startsWith("/settings");
104
214
const isSearch = location.pathname.startsWith("/search");
105
215
const isFeeds = location.pathname.startsWith("/feeds");
216
+
const isModeration = location.pathname.startsWith("/moderation");
106
217
107
218
const locationEnum:
108
219
| "feeds"
···
110
221
| "settings"
111
222
| "notifications"
112
223
| "profile"
224
+
| "moderation"
113
225
| "home" = isFeeds
114
226
? "feeds"
115
227
: isSearch
···
120
232
? "notifications"
121
233
: isProfile
122
234
? "profile"
123
-
: "home";
235
+
: isModeration
236
+
? "moderation"
237
+
: "home";
124
238
125
239
const [, setComposerPost] = useAtom(composerAtom);
126
240
···
131
245
<div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950">
132
246
<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
247
<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))"}} />
248
+
<FluentEmojiHighContrastGlowingStar
249
+
className="h-8 w-8"
250
+
style={{
251
+
color:
252
+
"oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))",
253
+
}}
254
+
/>
135
255
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
136
256
Red Dwarf{" "}
137
257
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
193
313
})
194
314
}
195
315
text="Feeds"
316
+
/>
317
+
<MaterialNavItem
318
+
InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />}
319
+
ActiveIcon={<IconMdiShield className="w-6 h-6" />}
320
+
active={locationEnum === "moderation"}
321
+
onClickCallbback={() =>
322
+
navigate({
323
+
to: "/moderation",
324
+
//params: { did: agent.assertDid },
325
+
})
326
+
}
327
+
text="Moderation"
196
328
/>
197
329
<MaterialNavItem
198
330
InactiveIcon={
···
232
364
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
233
365
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
234
366
//active={true}
235
-
onClickCallbback={() => setComposerPost({ kind: 'root' })}
367
+
onClickCallbback={() => setComposerPost({ kind: "root" })}
236
368
text="Post"
237
369
/>
238
370
</div>
···
370
502
371
503
<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
504
<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))"}} />
505
+
<FluentEmojiHighContrastGlowingStar
506
+
className="h-8 w-8"
507
+
style={{
508
+
color:
509
+
"oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))",
510
+
}}
511
+
/>
374
512
</div>
375
513
<MaterialNavItem
376
514
small
···
433
571
/>
434
572
<MaterialNavItem
435
573
small
574
+
InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />}
575
+
ActiveIcon={<IconMdiShield className="w-6 h-6" />}
576
+
active={locationEnum === "moderation"}
577
+
onClickCallbback={() =>
578
+
navigate({
579
+
to: "/moderation",
580
+
//params: { did: agent.assertDid },
581
+
})
582
+
}
583
+
text="Moderation"
584
+
/>
585
+
<MaterialNavItem
586
+
small
436
587
InactiveIcon={
437
588
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
438
589
}
···
472
623
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
473
624
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
474
625
//active={true}
475
-
onClickCallbback={() => setComposerPost({ kind: 'root' })}
626
+
onClickCallbback={() => setComposerPost({ kind: "root" })}
476
627
text="Post"
477
628
/>
478
629
</div>
···
482
633
<button
483
634
className="lg:hidden fixed bottom-22 right-4 z-50 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-2xl w-14 h-14 flex items-center justify-center transition-all"
484
635
style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }}
485
-
onClick={() => setComposerPost({ kind: 'root' })}
636
+
onClick={() => setComposerPost({ kind: "root" })}
486
637
type="button"
487
638
aria-label="Create Post"
488
639
>
···
499
650
</main>
500
651
501
652
<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>
653
+
<div className="px-4 pt-4">
654
+
<Import />
655
+
</div>
503
656
<Login />
504
657
505
658
<div className="flex-1"></div>
506
659
<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)
660
+
Red Dwarf is a Bluesky client that does not rely on any Bluesky API
661
+
App Servers. Instead, it uses Microcosm to fetch records directly
662
+
from each users' PDS (via Slingshot) and connect them using
663
+
backlinks (via Constellation)
508
664
</p>
509
665
</aside>
510
666
</div>
···
651
807
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
652
808
}
653
809
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
654
-
active={locationEnum === "settings"}
810
+
active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"}
655
811
onClickCallbback={() =>
656
812
navigate({
657
813
to: "/settings",
···
680
836
) : (
681
837
<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
838
<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))"}} />
839
+
<FluentEmojiHighContrastGlowingStar
840
+
className="h-6 w-6"
841
+
style={{
842
+
color:
843
+
"oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))",
844
+
}}
845
+
/>
684
846
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
685
847
Red Dwarf{" "}
686
848
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
700
862
);
701
863
}
702
864
703
-
function MaterialNavItem({
865
+
export function MaterialNavItem({
704
866
InactiveIcon,
705
867
ActiveIcon,
706
868
text,
+18
-1
src/routes/feeds.tsx
+18
-1
src/routes/feeds.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
2
3
+
import { Header } from "~/components/Header";
4
+
3
5
export const Route = createFileRoute("/feeds")({
4
6
component: Feeds,
5
7
});
6
8
7
9
export function Feeds() {
8
-
return <div className="p-6">Feeds page (coming soon)</div>;
10
+
return (
11
+
<div className="">
12
+
<Header
13
+
title={`Feeds`}
14
+
backButtonCallback={() => {
15
+
if (window.history.length > 1) {
16
+
window.history.back();
17
+
} else {
18
+
window.location.assign("/");
19
+
}
20
+
}}
21
+
bottomBorderDisabled={true}
22
+
/>
23
+
Feeds page (coming soon)
24
+
</div>
25
+
);
9
26
}
+44
-33
src/routes/index.tsx
+44
-33
src/routes/index.tsx
···
359
359
>
360
360
{!isAuthRestoring && savedFeeds.length > 0 ? (
361
361
<div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}>
362
-
{savedFeeds.map((item: any, idx: number) => {
363
-
const label = item.value.split("/").pop() || item.value;
364
-
const isActive = selectedFeed === item.value;
365
-
return (
366
-
<button
367
-
key={item.value || idx}
368
-
className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${
369
-
isActive
370
-
? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600"
371
-
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800"
372
-
// ? "bg-gray-500 text-white"
373
-
// : item.pinned
374
-
// ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
375
-
// : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
376
-
}`}
377
-
onClick={() => setSelectedFeed(item.value)}
378
-
title={item.value}
379
-
>
380
-
{label}
381
-
{item.pinned && (
382
-
<span
383
-
className={`ml-1 text-xs ${
384
-
isActive
385
-
? "text-gray-900 dark:text-gray-100"
386
-
: "text-gray-600 dark:text-gray-400"
387
-
}`}
388
-
>
389
-
โ
390
-
</span>
391
-
)}
392
-
</button>
393
-
);
394
-
})}
362
+
{savedFeeds.map((item: any, idx: number) => {return <FeedTabOnTop key={item} item={item} idx={idx} />})}
395
363
</div>
396
364
) : (
397
365
// <span className="text-xl font-bold ml-2">Home</span>
···
435
403
</div>
436
404
);
437
405
}
406
+
407
+
408
+
// todo please use types this is dangerous very dangerous.
409
+
// todo fix this whenever proper preferences is handled
410
+
function FeedTabOnTop({item, idx}:{item: any, idx: number}) {
411
+
const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom);
412
+
const selectedFeed = persistentSelectedFeed
413
+
const setSelectedFeed = setPersistentSelectedFeed
414
+
const rkey = item.value.split("/").pop() || item.value;
415
+
const isActive = selectedFeed === item.value;
416
+
const { data: feedrecord } = useQueryArbitrary(item.value)
417
+
const label = feedrecord?.value?.displayName || rkey
418
+
return (
419
+
<button
420
+
key={item.value || idx}
421
+
className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${
422
+
isActive
423
+
? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600"
424
+
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800"
425
+
// ? "bg-gray-500 text-white"
426
+
// : item.pinned
427
+
// ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
428
+
// : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
429
+
}`}
430
+
onClick={() => setSelectedFeed(item.value)}
431
+
title={item.value}
432
+
>
433
+
{label}
434
+
{item.pinned && (
435
+
<span
436
+
className={`ml-1 text-xs ${
437
+
isActive
438
+
? "text-gray-900 dark:text-gray-100"
439
+
: "text-gray-600 dark:text-gray-400"
440
+
}`}
441
+
>
442
+
โ
443
+
</span>
444
+
)}
445
+
</button>
446
+
);
447
+
}
448
+
438
449
// not even used lmaooo
439
450
440
451
// export async function cachedResolveDIDWEBDOC({
+269
src/routes/moderation.tsx
+269
src/routes/moderation.tsx
···
1
+
import * as ATPAPI from "@atproto/api";
2
+
import {
3
+
isAdultContentPref,
4
+
isBskyAppStatePref,
5
+
isContentLabelPref,
6
+
isFeedViewPref,
7
+
isLabelersPref,
8
+
isMutedWordsPref,
9
+
isSavedFeedsPref,
10
+
} from "@atproto/api/dist/client/types/app/bsky/actor/defs";
11
+
import { createFileRoute } from "@tanstack/react-router";
12
+
import { useAtom } from "jotai";
13
+
import { Switch } from "radix-ui";
14
+
15
+
import { Header } from "~/components/Header";
16
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
17
+
import { quickAuthAtom } from "~/utils/atoms";
18
+
import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery";
19
+
20
+
import { renderSnack } from "./__root";
21
+
import { NotificationItem } from "./notifications";
22
+
import { SettingHeading } from "./settings";
23
+
24
+
export const Route = createFileRoute("/moderation")({
25
+
component: RouteComponent,
26
+
});
27
+
28
+
function RouteComponent() {
29
+
const { agent } = useAuth();
30
+
31
+
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
32
+
const isAuthRestoring = quickAuth ? status === "loading" : false;
33
+
34
+
const identityresultmaybe = useQueryIdentity(
35
+
!isAuthRestoring ? agent?.did : undefined
36
+
);
37
+
const identity = identityresultmaybe?.data;
38
+
39
+
const prefsresultmaybe = useQueryPreferences({
40
+
agent: !isAuthRestoring ? (agent ?? undefined) : undefined,
41
+
pdsUrl: !isAuthRestoring ? identity?.pds : undefined,
42
+
});
43
+
const rawprefs = prefsresultmaybe?.data?.preferences as
44
+
| ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"]
45
+
| undefined;
46
+
47
+
//console.log(JSON.stringify(prefs, null, 2))
48
+
49
+
const parsedPref = parsePreferences(rawprefs);
50
+
51
+
return (
52
+
<div>
53
+
<Header
54
+
title={`Moderation`}
55
+
backButtonCallback={() => {
56
+
if (window.history.length > 1) {
57
+
window.history.back();
58
+
} else {
59
+
window.location.assign("/");
60
+
}
61
+
}}
62
+
bottomBorderDisabled={true}
63
+
/>
64
+
{/* <SettingHeading title="Moderation Tools" />
65
+
<p>
66
+
todo: add all these:
67
+
<br />
68
+
- Interaction settings
69
+
<br />
70
+
- Muted words & tags
71
+
<br />
72
+
- Moderation lists
73
+
<br />
74
+
- Muted accounts
75
+
<br />
76
+
- Blocked accounts
77
+
<br />
78
+
- Verification settings
79
+
<br />
80
+
</p> */}
81
+
<SettingHeading title="Content Filters" />
82
+
<div>
83
+
<div className="flex items-center gap-4 px-4 py-2 border-b">
84
+
<label
85
+
htmlFor={`switch-${"hardcoded"}`}
86
+
className="flex flex-row flex-1"
87
+
>
88
+
<div className="flex flex-col">
89
+
<span className="text-md">{"Adult Content"}</span>
90
+
<span className="text-sm text-gray-500 dark:text-gray-400">
91
+
{"Enable adult content"}
92
+
</span>
93
+
</div>
94
+
</label>
95
+
96
+
<Switch.Root
97
+
id={`switch-${"hardcoded"}`}
98
+
checked={parsedPref?.adultContentEnabled}
99
+
onCheckedChange={(v) => {
100
+
renderSnack({
101
+
title: "Sorry... Modifying preferences is not implemented yet",
102
+
description: "You can use another app to change preferences",
103
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
104
+
});
105
+
}}
106
+
className="m3switch root"
107
+
>
108
+
<Switch.Thumb className="m3switch thumb " />
109
+
</Switch.Root>
110
+
</div>
111
+
<div className="">
112
+
{Object.entries(parsedPref?.contentLabelPrefs ?? {}).map(
113
+
([label, visibility]) => (
114
+
<div
115
+
key={label}
116
+
className="flex justify-between border-b py-2 px-4"
117
+
>
118
+
<label
119
+
htmlFor={`switch-${"hardcoded"}`}
120
+
className="flex flex-row flex-1"
121
+
>
122
+
<div className="flex flex-col">
123
+
<span className="text-md">{label}</span>
124
+
<span className="text-sm text-gray-500 dark:text-gray-400">
125
+
{"uknown labeler"}
126
+
</span>
127
+
</div>
128
+
</label>
129
+
{/* <span className="text-md text-gray-500 dark:text-gray-400">
130
+
{visibility}
131
+
</span> */}
132
+
<TripleToggle
133
+
value={visibility as "ignore" | "warn" | "hide"}
134
+
/>
135
+
</div>
136
+
)
137
+
)}
138
+
</div>
139
+
</div>
140
+
<SettingHeading title="Advanced" />
141
+
{parsedPref?.labelers.map((labeler) => {
142
+
return (
143
+
<NotificationItem
144
+
key={labeler}
145
+
notification={labeler}
146
+
labeler={true}
147
+
/>
148
+
);
149
+
})}
150
+
</div>
151
+
);
152
+
}
153
+
154
+
export function TripleToggle({
155
+
value,
156
+
onChange,
157
+
}: {
158
+
value: "ignore" | "warn" | "hide";
159
+
onChange?: (newValue: "ignore" | "warn" | "hide") => void;
160
+
}) {
161
+
const options: Array<"ignore" | "warn" | "hide"> = ["ignore", "warn", "hide"];
162
+
return (
163
+
<div className="flex rounded-full bg-gray-200 dark:bg-gray-800 p-1 text-sm">
164
+
{options.map((opt) => {
165
+
const isActive = opt === value;
166
+
return (
167
+
<button
168
+
key={opt}
169
+
onClick={() => {
170
+
renderSnack({
171
+
title: "Sorry... Modifying preferences is not implemented yet",
172
+
description: "You can use another app to change preferences",
173
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
174
+
});
175
+
onChange?.(opt);
176
+
}}
177
+
className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${
178
+
isActive
179
+
? "bg-gray-400 dark:bg-gray-600 text-white"
180
+
: "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700"
181
+
}`}
182
+
>
183
+
{" "}
184
+
{opt.charAt(0).toUpperCase() + opt.slice(1)}
185
+
</button>
186
+
);
187
+
})}
188
+
</div>
189
+
);
190
+
}
191
+
192
+
type PrefItem =
193
+
ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"][number];
194
+
195
+
export interface NormalizedPreferences {
196
+
contentLabelPrefs: Record<string, string>;
197
+
mutedWords: string[];
198
+
feedViewPrefs: Record<string, any>;
199
+
labelers: string[];
200
+
adultContentEnabled: boolean;
201
+
savedFeeds: {
202
+
pinned: string[];
203
+
saved: string[];
204
+
};
205
+
nuxs: string[];
206
+
}
207
+
208
+
export function parsePreferences(
209
+
prefs?: PrefItem[]
210
+
): NormalizedPreferences | undefined {
211
+
if (!prefs) return undefined;
212
+
const normalized: NormalizedPreferences = {
213
+
contentLabelPrefs: {},
214
+
mutedWords: [],
215
+
feedViewPrefs: {},
216
+
labelers: [],
217
+
adultContentEnabled: false,
218
+
savedFeeds: { pinned: [], saved: [] },
219
+
nuxs: [],
220
+
};
221
+
222
+
for (const pref of prefs) {
223
+
switch (pref.$type) {
224
+
case "app.bsky.actor.defs#contentLabelPref":
225
+
if (!isContentLabelPref(pref)) break;
226
+
normalized.contentLabelPrefs[pref.label] = pref.visibility;
227
+
break;
228
+
229
+
case "app.bsky.actor.defs#mutedWordsPref":
230
+
if (!isMutedWordsPref(pref)) break;
231
+
for (const item of pref.items ?? []) {
232
+
normalized.mutedWords.push(item.value);
233
+
}
234
+
break;
235
+
236
+
case "app.bsky.actor.defs#feedViewPref":
237
+
if (!isFeedViewPref(pref)) break;
238
+
normalized.feedViewPrefs[pref.feed] = pref;
239
+
break;
240
+
241
+
case "app.bsky.actor.defs#labelersPref":
242
+
if (!isLabelersPref(pref)) break;
243
+
normalized.labelers.push(...(pref.labelers?.map((l) => l.did) ?? []));
244
+
break;
245
+
246
+
case "app.bsky.actor.defs#adultContentPref":
247
+
if (!isAdultContentPref(pref)) break;
248
+
normalized.adultContentEnabled = !!pref.enabled;
249
+
break;
250
+
251
+
case "app.bsky.actor.defs#savedFeedsPref":
252
+
if (!isSavedFeedsPref(pref)) break;
253
+
normalized.savedFeeds.pinned.push(...(pref.pinned ?? []));
254
+
normalized.savedFeeds.saved.push(...(pref.saved ?? []));
255
+
break;
256
+
257
+
case "app.bsky.actor.defs#bskyAppStatePref":
258
+
if (!isBskyAppStatePref(pref)) break;
259
+
normalized.nuxs.push(...(pref.nuxs?.map((n) => n.id) ?? []));
260
+
break;
261
+
262
+
default:
263
+
// unknown pref type โ just ignore for now
264
+
break;
265
+
}
266
+
}
267
+
268
+
return normalized;
269
+
}
+642
-154
src/routes/notifications.tsx
+642
-154
src/routes/notifications.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
1
+
import { AtUri } from "@atproto/api";
2
+
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
2
4
import { useAtom } from "jotai";
3
-
import React, { useEffect, useRef,useState } from "react";
5
+
import * as React from "react";
4
6
7
+
import defaultpfp from "~/../public/favicon.png";
8
+
import { Header } from "~/components/Header";
9
+
import {
10
+
ReusableTabRoute,
11
+
useReusableTabScrollRestore,
12
+
} from "~/components/ReusableTabRoute";
13
+
import {
14
+
MdiCardsHeartOutline,
15
+
MdiCommentOutline,
16
+
MdiRepeat,
17
+
UniversalPostRendererATURILoader,
18
+
} from "~/components/UniversalPostRenderer";
5
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
-
import { constellationURLAtom } from "~/utils/atoms";
20
+
import {
21
+
constellationURLAtom,
22
+
enableBitesAtom,
23
+
imgCDNAtom,
24
+
postInteractionsFiltersAtom,
25
+
} from "~/utils/atoms";
26
+
import {
27
+
useInfiniteQueryAuthorFeed,
28
+
useQueryConstellation,
29
+
useQueryIdentity,
30
+
useQueryProfile,
31
+
yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks,
32
+
} from "~/utils/useQuery";
7
33
8
-
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
34
+
import { FollowButton, Mutual } from "./profile.$did";
35
+
36
+
export function NotificationsComponent() {
37
+
return (
38
+
<div className="">
39
+
<Header
40
+
title={`Notifications`}
41
+
backButtonCallback={() => {
42
+
if (window.history.length > 1) {
43
+
window.history.back();
44
+
} else {
45
+
window.location.assign("/");
46
+
}
47
+
}}
48
+
bottomBorderDisabled={true}
49
+
/>
50
+
<NotificationsTabs />
51
+
</div>
52
+
);
53
+
}
9
54
10
55
export const Route = createFileRoute("/notifications")({
11
56
component: NotificationsComponent,
12
57
});
13
58
14
-
function NotificationsComponent() {
15
-
// /*mass comment*/ console.log("NotificationsComponent render");
16
-
const { agent, status } = useAuth();
17
-
const authed = !!agent?.did;
18
-
const authLoading = status === "loading";
19
-
const [did, setDid] = useState<string | null>(null);
20
-
const [resolving, setResolving] = useState(false);
21
-
const [error, setError] = useState<string | null>(null);
22
-
const [responses, setResponses] = useState<any[]>([null, null, null]);
23
-
const [loading, setLoading] = useState(false);
24
-
const inputRef = useRef<HTMLInputElement>(null);
59
+
export default function NotificationsTabs() {
60
+
const [bitesEnabled] = useAtom(enableBitesAtom);
61
+
return (
62
+
<ReusableTabRoute
63
+
route={`Notifications`}
64
+
tabs={{
65
+
Mentions: <MentionsTab />,
66
+
Follows: <FollowsTab />,
67
+
"Post Interactions": <PostInteractionsTab />,
68
+
...bitesEnabled ? {
69
+
Bites: <BitesTab />,
70
+
} : {}
71
+
}}
72
+
/>
73
+
);
74
+
}
75
+
76
+
function MentionsTab() {
77
+
const { agent } = useAuth();
78
+
const [constellationurl] = useAtom(constellationURLAtom);
79
+
const infinitequeryresults = useInfiniteQuery({
80
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
81
+
{
82
+
constellation: constellationurl,
83
+
method: "/links",
84
+
target: agent?.did,
85
+
collection: "app.bsky.feed.post",
86
+
path: ".facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did",
87
+
}
88
+
),
89
+
enabled: !!agent?.did,
90
+
});
91
+
92
+
const {
93
+
data: infiniteMentionsData,
94
+
fetchNextPage,
95
+
hasNextPage,
96
+
isFetchingNextPage,
97
+
isLoading,
98
+
isError,
99
+
error,
100
+
} = infinitequeryresults;
101
+
102
+
const mentionsAturis = React.useMemo(() => {
103
+
// Get all replies from the standard infinite query
104
+
return (
105
+
infiniteMentionsData?.pages.flatMap(
106
+
(page) =>
107
+
page?.linking_records.map(
108
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
109
+
) ?? []
110
+
) ?? []
111
+
);
112
+
}, [infiniteMentionsData]);
25
113
26
-
useEffect(() => {
27
-
if (authLoading) return;
28
-
if (authed && agent && agent.assertDid) {
29
-
setDid(agent.assertDid);
30
-
}
31
-
}, [authed, agent, authLoading]);
114
+
useReusableTabScrollRestore("Notifications");
32
115
33
-
async function handleSubmit() {
34
-
// /*mass comment*/ console.log("handleSubmit called");
35
-
setError(null);
36
-
setResponses([null, null, null]);
37
-
const value = inputRef.current?.value?.trim() || "";
38
-
if (!value) return;
39
-
if (value.startsWith("did:")) {
40
-
setDid(value);
41
-
setError(null);
42
-
return;
43
-
}
44
-
setResolving(true);
45
-
const cacheKey = `handleDid:${value}`;
46
-
const now = Date.now();
47
-
const cached = undefined // await get(cacheKey);
48
-
// if (
49
-
// cached &&
50
-
// cached.value &&
51
-
// cached.time &&
52
-
// now - cached.time < HANDLE_DID_CACHE_TIMEOUT
53
-
// ) {
54
-
// try {
55
-
// const data = JSON.parse(cached.value);
56
-
// setDid(data.did);
57
-
// setResolving(false);
58
-
// return;
59
-
// } catch {}
60
-
// }
61
-
try {
62
-
const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(value)}`;
63
-
const res = await fetch(url);
64
-
if (!res.ok) throw new Error("Failed to resolve handle");
65
-
const data = await res.json();
66
-
//set(cacheKey, JSON.stringify(data));
67
-
setDid(data.did);
68
-
} catch (e: any) {
69
-
setError("Failed to resolve handle: " + (e?.message || e));
70
-
} finally {
71
-
setResolving(false);
72
-
}
73
-
}
116
+
if (isLoading) return <LoadingState text="Loading mentions..." />;
117
+
if (isError) return <ErrorState error={error} />;
118
+
119
+
if (!mentionsAturis?.length) return <EmptyState text="No mentions yet." />;
74
120
75
-
const [constellationURL] = useAtom(constellationURLAtom)
121
+
return (
122
+
<>
123
+
{mentionsAturis.map((m) => (
124
+
<UniversalPostRendererATURILoader key={m} atUri={m} />
125
+
))}
76
126
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(
88
-
urls.map(async (url) => {
89
-
try {
90
-
const r = await fetch(url);
91
-
if (!r.ok) throw new Error("Failed to fetch");
92
-
const text = await r.text();
93
-
if (!text) return null;
94
-
try {
95
-
return JSON.parse(text);
96
-
} catch {
97
-
return null;
127
+
{hasNextPage && (
128
+
<button
129
+
onClick={() => fetchNextPage()}
130
+
disabled={isFetchingNextPage}
131
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
132
+
>
133
+
{isFetchingNextPage ? "Loading..." : "Load More"}
134
+
</button>
135
+
)}
136
+
</>
137
+
);
138
+
}
139
+
140
+
export function FollowsTab({did}:{did?:string}) {
141
+
const { agent } = useAuth();
142
+
const userdidunsafe = did ?? agent?.did;
143
+
const { data: identity} = useQueryIdentity(userdidunsafe);
144
+
const userdid = identity?.did;
145
+
146
+
const [constellationurl] = useAtom(constellationURLAtom);
147
+
const infinitequeryresults = useInfiniteQuery({
148
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
149
+
{
150
+
constellation: constellationurl,
151
+
method: "/links",
152
+
target: userdid,
153
+
collection: "app.bsky.graph.follow",
154
+
path: ".subject",
155
+
}
156
+
),
157
+
enabled: !!userdid,
158
+
});
159
+
160
+
const {
161
+
data: infiniteFollowsData,
162
+
fetchNextPage,
163
+
hasNextPage,
164
+
isFetchingNextPage,
165
+
isLoading,
166
+
isError,
167
+
error,
168
+
} = infinitequeryresults;
169
+
170
+
const followsAturis = React.useMemo(() => {
171
+
// Get all replies from the standard infinite query
172
+
return (
173
+
infiniteFollowsData?.pages.flatMap(
174
+
(page) =>
175
+
page?.linking_records.map(
176
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
177
+
) ?? []
178
+
) ?? []
179
+
);
180
+
}, [infiniteFollowsData]);
181
+
182
+
useReusableTabScrollRestore("Notifications");
183
+
184
+
if (isLoading) return <LoadingState text="Loading follows..." />;
185
+
if (isError) return <ErrorState error={error} />;
186
+
187
+
if (!followsAturis?.length) return <EmptyState text="No follows yet." />;
188
+
189
+
return (
190
+
<>
191
+
{followsAturis.map((m) => (
192
+
<NotificationItem key={m} notification={m} />
193
+
))}
194
+
195
+
{hasNextPage && (
196
+
<button
197
+
onClick={() => fetchNextPage()}
198
+
disabled={isFetchingNextPage}
199
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
200
+
>
201
+
{isFetchingNextPage ? "Loading..." : "Load More"}
202
+
</button>
203
+
)}
204
+
</>
205
+
);
206
+
}
207
+
208
+
209
+
export function BitesTab({did}:{did?:string}) {
210
+
const { agent } = useAuth();
211
+
const userdidunsafe = did ?? agent?.did;
212
+
const { data: identity} = useQueryIdentity(userdidunsafe);
213
+
const userdid = identity?.did;
214
+
215
+
const [constellationurl] = useAtom(constellationURLAtom);
216
+
const infinitequeryresults = useInfiniteQuery({
217
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
218
+
{
219
+
constellation: constellationurl,
220
+
method: "/links",
221
+
target: "at://"+userdid,
222
+
collection: "net.wafrn.feed.bite",
223
+
path: ".subject",
224
+
staleMult: 0 // safe fun
225
+
}
226
+
),
227
+
enabled: !!userdid,
228
+
});
229
+
230
+
const {
231
+
data: infiniteFollowsData,
232
+
fetchNextPage,
233
+
hasNextPage,
234
+
isFetchingNextPage,
235
+
isLoading,
236
+
isError,
237
+
error,
238
+
} = infinitequeryresults;
239
+
240
+
const followsAturis = React.useMemo(() => {
241
+
// Get all replies from the standard infinite query
242
+
return (
243
+
infiniteFollowsData?.pages.flatMap(
244
+
(page) =>
245
+
page?.linking_records.map(
246
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
247
+
) ?? []
248
+
) ?? []
249
+
);
250
+
}, [infiniteFollowsData]);
251
+
252
+
useReusableTabScrollRestore("Notifications");
253
+
254
+
if (isLoading) return <LoadingState text="Loading bites..." />;
255
+
if (isError) return <ErrorState error={error} />;
256
+
257
+
if (!followsAturis?.length) return <EmptyState text="No bites yet." />;
258
+
259
+
return (
260
+
<>
261
+
{followsAturis.map((m) => (
262
+
<NotificationItem key={m} notification={m} />
263
+
))}
264
+
265
+
{hasNextPage && (
266
+
<button
267
+
onClick={() => fetchNextPage()}
268
+
disabled={isFetchingNextPage}
269
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
270
+
>
271
+
{isFetchingNextPage ? "Loading..." : "Load More"}
272
+
</button>
273
+
)}
274
+
</>
275
+
);
276
+
}
277
+
278
+
function PostInteractionsTab() {
279
+
const { agent } = useAuth();
280
+
const { data: identity } = useQueryIdentity(agent?.did);
281
+
const queryClient = useQueryClient();
282
+
const {
283
+
data: postsData,
284
+
fetchNextPage,
285
+
hasNextPage,
286
+
isFetchingNextPage,
287
+
isLoading: arePostsLoading,
288
+
} = useInfiniteQueryAuthorFeed(agent?.did, identity?.pds);
289
+
290
+
React.useEffect(() => {
291
+
if (postsData) {
292
+
postsData.pages.forEach((page) => {
293
+
page.records.forEach((record) => {
294
+
if (!queryClient.getQueryData(["post", record.uri])) {
295
+
queryClient.setQueryData(["post", record.uri], record);
98
296
}
99
-
} catch (e: any) {
100
-
return { error: e?.message || String(e) };
101
-
}
102
-
})
103
-
)
104
-
.then((results) => {
105
-
if (!ignore) setResponses(results);
106
-
})
107
-
.catch((e) => {
108
-
if (!ignore)
109
-
setError("Failed to fetch notifications: " + (e?.message || e));
110
-
})
111
-
.finally(() => {
112
-
if (!ignore) setLoading(false);
297
+
});
113
298
});
114
-
return () => {
115
-
ignore = true;
116
-
};
117
-
}, [did]);
299
+
}
300
+
}, [postsData, queryClient]);
301
+
302
+
const posts = React.useMemo(
303
+
() => postsData?.pages.flatMap((page) => page.records) ?? [],
304
+
[postsData]
305
+
);
306
+
307
+
useReusableTabScrollRestore("Notifications");
308
+
309
+
const [filters] = useAtom(postInteractionsFiltersAtom);
310
+
const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts);
118
311
119
312
return (
120
-
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
121
-
<div className="flex items-center gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-800">
122
-
<span className="text-xl font-bold ml-2">Notifications</span>
123
-
{!authed && (
124
-
<div className="flex items-center gap-2">
125
-
<input
126
-
type="text"
127
-
placeholder="Enter handle or DID"
128
-
ref={inputRef}
129
-
className="ml-4 px-2 py-1 rounded border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100"
130
-
style={{ minWidth: 220 }}
131
-
disabled={resolving}
132
-
/>
133
-
<button
134
-
type="button"
135
-
className="px-3 py-1 rounded bg-blue-600 text-white font-semibold disabled:opacity-50"
136
-
disabled={resolving}
137
-
onClick={handleSubmit}
138
-
>
139
-
{resolving ? "Resolving..." : "Submit"}
140
-
</button>
141
-
</div>
142
-
)}
313
+
<>
314
+
<PostInteractionsFilterChipBar />
315
+
{!empty && posts.map((m) => (
316
+
<PostInteractionsItem key={m.uri} uri={m.uri} />
317
+
))}
318
+
319
+
{hasNextPage && (
320
+
<button
321
+
onClick={() => fetchNextPage()}
322
+
disabled={isFetchingNextPage}
323
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
324
+
>
325
+
{isFetchingNextPage ? "Loading..." : "Load More"}
326
+
</button>
327
+
)}
328
+
</>
329
+
);
330
+
}
331
+
332
+
function PostInteractionsFilterChipBar() {
333
+
const [filters, setFilters] = useAtom(postInteractionsFiltersAtom);
334
+
// const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts);
335
+
336
+
// useEffect(() => {
337
+
// if (empty) {
338
+
// setFilters((prev) => ({
339
+
// ...prev,
340
+
// likes: true,
341
+
// }));
342
+
// }
343
+
// }, [
344
+
// empty,
345
+
// setFilters,
346
+
// ]);
347
+
348
+
const toggle = (key: keyof typeof filters) => {
349
+
setFilters((prev) => ({
350
+
...prev,
351
+
[key]: !prev[key],
352
+
}));
353
+
};
354
+
355
+
return (
356
+
<div className="flex flex-row flex-wrap gap-2 px-4 pt-4">
357
+
<Chip
358
+
state={filters.likes}
359
+
text="Likes"
360
+
onClick={() => toggle("likes")}
361
+
/>
362
+
<Chip
363
+
state={filters.reposts}
364
+
text="Reposts"
365
+
onClick={() => toggle("reposts")}
366
+
/>
367
+
<Chip
368
+
state={filters.replies}
369
+
text="Replies"
370
+
onClick={() => toggle("replies")}
371
+
/>
372
+
<Chip
373
+
state={filters.quotes}
374
+
text="Quotes"
375
+
onClick={() => toggle("quotes")}
376
+
/>
377
+
<Chip
378
+
state={filters.showAll}
379
+
text="Show All Metrics"
380
+
onClick={() => toggle("showAll")}
381
+
/>
382
+
</div>
383
+
);
384
+
}
385
+
386
+
export function Chip({
387
+
state,
388
+
text,
389
+
onClick,
390
+
}: {
391
+
state: boolean;
392
+
text: string;
393
+
onClick: React.MouseEventHandler<HTMLButtonElement>;
394
+
}) {
395
+
return (
396
+
<button
397
+
onClick={onClick}
398
+
className={`relative inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-all
399
+
${
400
+
state
401
+
? "bg-primary/20 text-primary bg-gray-200 dark:bg-gray-800 border border-transparent"
402
+
: "bg-surface-container-low text-on-surface-variant border border-outline"
403
+
}
404
+
hover:bg-primary/30 active:scale-[0.97]
405
+
dark:border-outline-variant
406
+
`}
407
+
>
408
+
{state && (
409
+
<IconMdiCheck
410
+
className="mr-1.5 inline-block w-4 h-4 rounded-full bg-primary"
411
+
aria-hidden
412
+
/>
413
+
)}
414
+
{text}
415
+
</button>
416
+
);
417
+
}
418
+
419
+
function PostInteractionsItem({ uri }: { uri: string }) {
420
+
const [filters] = useAtom(postInteractionsFiltersAtom);
421
+
const { data: links } = useQueryConstellation({
422
+
method: "/links/all",
423
+
target: uri,
424
+
});
425
+
426
+
const likes =
427
+
links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0;
428
+
const replies =
429
+
links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0;
430
+
const reposts =
431
+
links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0;
432
+
const quotes1 =
433
+
links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0;
434
+
const quotes2 =
435
+
links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"]
436
+
?.records || 0;
437
+
const quotes = quotes1 + quotes2;
438
+
439
+
const all = likes + replies + reposts + quotes;
440
+
441
+
//const failLikes = filters.likes && likes < 1;
442
+
//const failReposts = filters.reposts && reposts < 1;
443
+
//const failReplies = filters.replies && replies < 1;
444
+
//const failQuotes = filters.quotes && quotes < 1;
445
+
446
+
const showLikes = filters.showAll || filters.likes
447
+
const showReposts = filters.showAll || filters.reposts
448
+
const showReplies = filters.showAll || filters.replies
449
+
const showQuotes = filters.showAll || filters.quotes
450
+
451
+
//const showNone = !showLikes && !showReposts && !showReplies && !showQuotes;
452
+
453
+
//const fail = failLikes || failReposts || failReplies || failQuotes || showNone;
454
+
455
+
const matchesLikes = filters.likes && likes > 0;
456
+
const matchesReposts = filters.reposts && reposts > 0;
457
+
const matchesReplies = filters.replies && replies > 0;
458
+
const matchesQuotes = filters.quotes && quotes > 0;
459
+
460
+
const matchesAnything =
461
+
// filters.showAll ||
462
+
matchesLikes ||
463
+
matchesReposts ||
464
+
matchesReplies ||
465
+
matchesQuotes;
466
+
467
+
if (!matchesAnything) return null;
468
+
469
+
//if (fail) return;
470
+
471
+
return (
472
+
<div className="flex flex-col">
473
+
{/* <span>fail likes {failLikes ? "true" : "false"}</span>
474
+
<span>fail repost {failReposts ? "true" : "false"}</span>
475
+
<span>fail reply {failReplies ? "true" : "false"}</span>
476
+
<span>fail qupte {failQuotes ? "true" : "false"}</span> */}
477
+
<div className="border rounded-xl mx-4 mt-4 overflow-hidden">
478
+
<UniversalPostRendererATURILoader
479
+
isQuote
480
+
key={uri}
481
+
atUri={uri}
482
+
nopics={true}
483
+
concise={true}
484
+
/>
485
+
<div className="flex flex-col divide-x">
486
+
{showLikes &&(<InteractionsButton
487
+
type={"like"}
488
+
uri={uri}
489
+
count={likes}
490
+
/>)}
491
+
{showReposts && (<InteractionsButton
492
+
type={"repost"}
493
+
uri={uri}
494
+
count={reposts}
495
+
/>)}
496
+
{showReplies && (<InteractionsButton
497
+
type={"reply"}
498
+
uri={uri}
499
+
count={replies}
500
+
/>)}
501
+
{showQuotes && (<InteractionsButton
502
+
type={"quote"}
503
+
uri={uri}
504
+
count={quotes}
505
+
/>)}
506
+
{!all && (
507
+
<div className="text-center text-gray-500 dark:text-gray-400 pb-3 pt-2 border-t">
508
+
No interactions yet.
509
+
</div>
510
+
)}
511
+
</div>
143
512
</div>
144
-
{error && <div className="p-4 text-red-500">{error}</div>}
145
-
{loading && (
146
-
<div className="p-4 text-gray-500">Loading notifications...</div>
513
+
</div>
514
+
);
515
+
}
516
+
517
+
function InteractionsButton({
518
+
type,
519
+
uri,
520
+
count,
521
+
}: {
522
+
type: "reply" | "repost" | "like" | "quote";
523
+
uri: string;
524
+
count: number;
525
+
}) {
526
+
if (!count) return <></>;
527
+
const aturi = new AtUri(uri);
528
+
return (
529
+
<Link
530
+
to={
531
+
`/profile/$did/post/$rkey` +
532
+
(type === "like"
533
+
? "/liked-by"
534
+
: type === "repost"
535
+
? "/reposted-by"
536
+
: type === "quote"
537
+
? "/quotes"
538
+
: "")
539
+
}
540
+
params={{
541
+
did: aturi.host,
542
+
rkey: aturi.rkey,
543
+
}}
544
+
className="flex-1 border-t py-2 px-4 flex flex-row items-center gap-2 transition-colors hover:bg-gray-100 hover:dark:bg-gray-800"
545
+
>
546
+
{type === "like" ? (
547
+
<MdiCardsHeartOutline height={22} width={22} />
548
+
) : type === "repost" ? (
549
+
<MdiRepeat height={22} width={22} />
550
+
) : type === "reply" ? (
551
+
<MdiCommentOutline height={22} width={22} />
552
+
) : type === "quote" ? (
553
+
<IconMdiMessageReplyTextOutline
554
+
height={22}
555
+
width={22}
556
+
className=" text-gray-400"
557
+
/>
558
+
) : (
559
+
<></>
560
+
)}
561
+
{type === "like"
562
+
? "likes"
563
+
: type === "reply"
564
+
? "replies"
565
+
: type === "quote"
566
+
? "quotes"
567
+
: type === "repost"
568
+
? "reposts"
569
+
: ""}
570
+
<div className="flex-1" /> {count}
571
+
</Link>
572
+
);
573
+
}
574
+
575
+
export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) {
576
+
const aturi = new AtUri(notification);
577
+
const bite = aturi.collection === "net.wafrn.feed.bite";
578
+
const navigate = useNavigate();
579
+
const { data: identity } = useQueryIdentity(aturi.host);
580
+
const resolvedDid = identity?.did;
581
+
const profileUri = resolvedDid
582
+
? `at://${resolvedDid}/app.bsky.actor.profile/self`
583
+
: undefined;
584
+
const { data: profileRecord } = useQueryProfile(profileUri);
585
+
const profile = profileRecord?.value;
586
+
587
+
const [imgcdn] = useAtom(imgCDNAtom);
588
+
589
+
function getAvatarUrl(p: typeof profile) {
590
+
const link = p?.avatar?.ref?.["$link"];
591
+
if (!link || !resolvedDid) return null;
592
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
593
+
}
594
+
595
+
const avatar = getAvatarUrl(profile);
596
+
597
+
return (
598
+
<div
599
+
className="flex items-center p-4 cursor-pointer gap-3 justify-around border-b flex-row"
600
+
onClick={() =>
601
+
aturi &&
602
+
navigate({
603
+
to: "/profile/$did",
604
+
params: { did: aturi.host },
605
+
})
606
+
}
607
+
>
608
+
{/* <div>
609
+
{aturi.collection === "app.bsky.graph.follow" ? (
610
+
<IconMdiAccountPlus />
611
+
) : aturi.collection === "app.bsky.feed.like" ? (
612
+
<MdiCardsHeart />
613
+
) : (
614
+
<></>
615
+
)}
616
+
</div> */}
617
+
{profile ? (
618
+
<img
619
+
src={avatar || defaultpfp}
620
+
alt={identity?.handle}
621
+
className={`w-10 h-10 ${labeler ? "rounded-md" : "rounded-full"}`}
622
+
/>
623
+
) : (
624
+
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
147
625
)}
148
-
{!loading &&
149
-
!error &&
150
-
responses.map((resp, i) => (
151
-
<div key={i} className="p-4">
152
-
<div className="font-bold mb-2">Query {i + 1}</div>
153
-
{!resp ||
154
-
(typeof resp === "object" && Object.keys(resp).length === 0) ||
155
-
(Array.isArray(resp) && resp.length === 0) ? (
156
-
<div className="text-gray-500">No notifications found.</div>
157
-
) : (
158
-
<pre
159
-
style={{
160
-
background: "#222",
161
-
color: "#eee",
162
-
borderRadius: 8,
163
-
padding: 12,
164
-
fontSize: 13,
165
-
overflowX: "auto",
166
-
}}
167
-
>
168
-
{JSON.stringify(resp, null, 2)}
169
-
</pre>
170
-
)}
171
-
</div>
172
-
))}
173
-
{/* <div className="p-4"> yo this project sucks, ill remake it some other time, like cmon inputting anything into the textbox makes it break. ive warned you</div> */}
626
+
<div className="flex flex-col min-w-0">
627
+
<div className="flex flex-row gap-2 overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
628
+
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
629
+
{profile?.displayName || identity?.handle || "Someone"}
630
+
</span>
631
+
<span className="text-gray-700 dark:text-gray-400 truncate">
632
+
@{identity?.handle}
633
+
</span>
634
+
</div>
635
+
<div className="flex flex-row gap-2">
636
+
{identity?.did && <Mutual targetdidorhandle={identity?.did} />}
637
+
{/* <span className="text-sm text-gray-600 dark:text-gray-400">
638
+
followed you
639
+
</span> */}
640
+
</div>
641
+
</div>
642
+
<div className="flex-1" />
643
+
{identity?.did && <FollowButton targetdidorhandle={identity?.did} />}
174
644
</div>
175
645
);
176
646
}
647
+
648
+
export const EmptyState = ({ text }: { text: string }) => (
649
+
<div className="py-10 text-center text-gray-500 dark:text-gray-400">
650
+
{text}
651
+
</div>
652
+
);
653
+
654
+
export const LoadingState = ({ text }: { text: string }) => (
655
+
<div className="py-10 text-center text-gray-500 dark:text-gray-400 italic">
656
+
{text}
657
+
</div>
658
+
);
659
+
660
+
export const ErrorState = ({ error }: { error: unknown }) => (
661
+
<div className="py-10 text-center text-red-600 dark:text-red-400">
662
+
Error: {(error as Error)?.message || "Something went wrong."}
663
+
</div>
664
+
);
+91
src/routes/profile.$did/feed.$rkey.tsx
+91
src/routes/profile.$did/feed.$rkey.tsx
···
1
+
import * as ATPAPI from "@atproto/api";
2
+
import { AtUri } from "@atproto/api";
3
+
import { createFileRoute } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
5
+
6
+
import { Header } from "~/components/Header";
7
+
import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed";
8
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
9
+
import { quickAuthAtom } from "~/utils/atoms";
10
+
import { useQueryArbitrary, useQueryIdentity } from "~/utils/useQuery";
11
+
12
+
export const Route = createFileRoute("/profile/$did/feed/$rkey")({
13
+
component: FeedRoute,
14
+
});
15
+
16
+
// todo: scroll restoration
17
+
function FeedRoute() {
18
+
const { did, rkey } = Route.useParams();
19
+
const { agent, status } = useAuth();
20
+
const { data: identitydata } = useQueryIdentity(did);
21
+
const { data: identity } = useQueryIdentity(agent?.did);
22
+
const uri = `at://${identitydata?.did || did}/app.bsky.feed.generator/${rkey}`;
23
+
const aturi = new AtUri(uri);
24
+
const { data: feeddata } = useQueryArbitrary(uri);
25
+
26
+
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
27
+
const isAuthRestoring = quickAuth ? status === "loading" : false;
28
+
29
+
const authed = status === "signedIn";
30
+
31
+
const feedServiceDid = !isAuthRestoring
32
+
? ((feeddata?.value as any)?.did as string | undefined)
33
+
: undefined;
34
+
35
+
// const {
36
+
// data: feedData,
37
+
// isLoading: isFeedLoading,
38
+
// error: feedError,
39
+
// } = useQueryFeedSkeleton({
40
+
// feedUri: selectedFeed!,
41
+
// agent: agent ?? undefined,
42
+
// isAuthed: authed ?? false,
43
+
// pdsUrl: identity?.pds,
44
+
// feedServiceDid: feedServiceDid,
45
+
// });
46
+
47
+
// const feed = feedData?.feed || [];
48
+
49
+
const isReadyForAuthedFeed =
50
+
!isAuthRestoring && authed && agent && identity?.pds && feedServiceDid;
51
+
const isReadyForUnauthedFeed = !isAuthRestoring && !authed;
52
+
53
+
const feed: ATPAPI.AppBskyFeedGenerator.Record | undefined = feeddata?.value;
54
+
55
+
const web = feedServiceDid?.replace(/^did:web:/, "") || "";
56
+
57
+
return (
58
+
<>
59
+
<Header
60
+
title={feed?.displayName || aturi.rkey}
61
+
backButtonCallback={() => {
62
+
if (window.history.length > 1) {
63
+
window.history.back();
64
+
} else {
65
+
window.location.assign("/");
66
+
}
67
+
}}
68
+
/>
69
+
70
+
{isAuthRestoring ||
71
+
(authed && (!identity?.pds || !feedServiceDid) && (
72
+
<div className="p-4 text-center text-gray-500">
73
+
Preparing your feed...
74
+
</div>
75
+
))}
76
+
77
+
{!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? (
78
+
<InfiniteCustomFeed
79
+
key={uri}
80
+
feedUri={uri}
81
+
pdsUrl={identity?.pds}
82
+
feedServiceDid={feedServiceDid}
83
+
authedOverride={!authed && true || undefined}
84
+
unauthedfeedurl={!authed && web || undefined}
85
+
/>
86
+
) : (
87
+
<div className="p-4 text-center text-gray-500">Loading.......</div>
88
+
)}
89
+
</>
90
+
);
91
+
}
+30
src/routes/profile.$did/followers.tsx
+30
src/routes/profile.$did/followers.tsx
···
1
+
import { createFileRoute } from "@tanstack/react-router";
2
+
3
+
import { Header } from "~/components/Header";
4
+
5
+
import { FollowsTab } from "../notifications";
6
+
7
+
export const Route = createFileRoute("/profile/$did/followers")({
8
+
component: RouteComponent,
9
+
});
10
+
11
+
// todo: scroll restoration
12
+
function RouteComponent() {
13
+
const params = Route.useParams();
14
+
15
+
return (
16
+
<div>
17
+
<Header
18
+
title={"Followers"}
19
+
backButtonCallback={() => {
20
+
if (window.history.length > 1) {
21
+
window.history.back();
22
+
} else {
23
+
window.location.assign("/");
24
+
}
25
+
}}
26
+
/>
27
+
<FollowsTab did={params.did} />
28
+
</div>
29
+
);
30
+
}
+79
src/routes/profile.$did/follows.tsx
+79
src/routes/profile.$did/follows.tsx
···
1
+
import * as ATPAPI from "@atproto/api"
2
+
import { createFileRoute } from '@tanstack/react-router'
3
+
import React from 'react';
4
+
5
+
import { Header } from '~/components/Header';
6
+
import { useReusableTabScrollRestore } from '~/components/ReusableTabRoute';
7
+
import { useInfiniteQueryAuthorFeed, useQueryIdentity } from '~/utils/useQuery';
8
+
9
+
import { EmptyState, ErrorState, LoadingState, NotificationItem } from '../notifications';
10
+
11
+
export const Route = createFileRoute('/profile/$did/follows')({
12
+
component: RouteComponent,
13
+
})
14
+
15
+
// todo: scroll restoration
16
+
function RouteComponent() {
17
+
const params = Route.useParams();
18
+
return (
19
+
<div>
20
+
<Header
21
+
title={"Follows"}
22
+
backButtonCallback={() => {
23
+
if (window.history.length > 1) {
24
+
window.history.back();
25
+
} else {
26
+
window.location.assign("/");
27
+
}
28
+
}}
29
+
/>
30
+
<Follows did={params.did}/>
31
+
</div>
32
+
);
33
+
}
34
+
35
+
function Follows({did}:{did:string}) {
36
+
const {data: identity} = useQueryIdentity(did);
37
+
const infinitequeryresults = useInfiniteQueryAuthorFeed(identity?.did, identity?.pds, "app.bsky.graph.follow");
38
+
39
+
const {
40
+
data: infiniteFollowsData,
41
+
fetchNextPage,
42
+
hasNextPage,
43
+
isFetchingNextPage,
44
+
isLoading,
45
+
isError,
46
+
error,
47
+
} = infinitequeryresults;
48
+
49
+
const followsAturis = React.useMemo(
50
+
() => infiniteFollowsData?.pages.flatMap((page) => page.records) ?? [],
51
+
[infiniteFollowsData]
52
+
);
53
+
54
+
useReusableTabScrollRestore("Notifications");
55
+
56
+
if (isLoading) return <LoadingState text="Loading follows..." />;
57
+
if (isError) return <ErrorState error={error} />;
58
+
59
+
if (!followsAturis?.length) return <EmptyState text="No follows yet." />;
60
+
61
+
return (
62
+
<>
63
+
{followsAturis.map((m) => {
64
+
const record = m.value as unknown as ATPAPI.AppBskyGraphFollow.Record;
65
+
return <NotificationItem key={record.subject} notification={record.subject} />
66
+
})}
67
+
68
+
{hasNextPage && (
69
+
<button
70
+
onClick={() => fetchNextPage()}
71
+
disabled={isFetchingNextPage}
72
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
73
+
>
74
+
{isFetchingNextPage ? "Loading..." : "Load More"}
75
+
</button>
76
+
)}
77
+
</>
78
+
);
79
+
}
+838
-86
src/routes/profile.$did/index.tsx
+838
-86
src/routes/profile.$did/index.tsx
···
1
-
import { RichText } from "@atproto/api";
1
+
import { Agent, RichText } from "@atproto/api";
2
+
import * as ATPAPI from "@atproto/api";
3
+
import { TID } from "@atproto/common-web";
2
4
import { useQueryClient } from "@tanstack/react-query";
3
-
import { createFileRoute, useNavigate } from "@tanstack/react-router";
5
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
4
6
import { useAtom } from "jotai";
5
7
import React, { type ReactNode, useEffect, useState } from "react";
6
8
9
+
import defaultpfp from "~/../public/favicon.png";
7
10
import { Header } from "~/components/Header";
8
-
import { Button } from "~/components/radix-m3-rd/Button";
11
+
import {
12
+
ReusableTabRoute,
13
+
useReusableTabScrollRestore,
14
+
} from "~/components/ReusableTabRoute";
9
15
import {
10
16
renderTextWithFacets,
11
17
UniversalPostRendererATURILoader,
12
18
} from "~/components/UniversalPostRenderer";
13
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
14
-
import { imgCDNAtom } from "~/utils/atoms";
20
+
import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms";
15
21
import {
16
22
toggleFollow,
17
23
useGetFollowState,
18
24
useGetOneToOneState,
19
25
} from "~/utils/followState";
26
+
import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue";
20
27
import {
21
28
useInfiniteQueryAuthorFeed,
29
+
useQueryArbitrary,
30
+
useQueryConstellation,
31
+
useQueryConstellationLinksCountDistinctDids,
22
32
useQueryIdentity,
23
33
useQueryProfile,
24
34
} from "~/utils/useQuery";
35
+
import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx";
36
+
37
+
import { renderSnack } from "../__root";
38
+
import { Chip } from "../notifications";
25
39
26
40
export const Route = createFileRoute("/profile/$did/")({
27
41
component: ProfileComponent,
···
30
44
function ProfileComponent() {
31
45
// booo bad this is not always the did it might be a handle, use identity.did instead
32
46
const { did } = Route.useParams();
47
+
const { agent } = useAuth();
33
48
const navigate = useNavigate();
34
49
const queryClient = useQueryClient();
35
50
const {
···
38
53
error: identityError,
39
54
} = useQueryIdentity(did);
40
55
56
+
// i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc)
57
+
// so instead we should query the labeler profile
58
+
59
+
const { data: labelerProfile } = useQueryArbitrary(
60
+
identity?.did
61
+
? `at://${identity?.did}/app.bsky.labeler.service/self`
62
+
: undefined
63
+
);
64
+
65
+
const isLabeler = !!labelerProfile?.cid;
66
+
const labelerRecord = isLabeler
67
+
? (labelerProfile?.value as ATPAPI.AppBskyLabelerService.Record)
68
+
: undefined;
69
+
41
70
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
42
71
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
43
72
const pdsUrl = identity?.pds;
···
48
77
const { data: profileRecord } = useQueryProfile(profileUri);
49
78
const profile = profileRecord?.value;
50
79
51
-
const {
52
-
data: postsData,
53
-
fetchNextPage,
54
-
hasNextPage,
55
-
isFetchingNextPage,
56
-
isLoading: arePostsLoading,
57
-
} = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl);
58
-
59
-
React.useEffect(() => {
60
-
if (postsData) {
61
-
postsData.pages.forEach((page) => {
62
-
page.records.forEach((record) => {
63
-
if (!queryClient.getQueryData(["post", record.uri])) {
64
-
queryClient.setQueryData(["post", record.uri], record);
65
-
}
66
-
});
67
-
});
68
-
}
69
-
}, [postsData, queryClient]);
70
-
71
-
const posts = React.useMemo(
72
-
() => postsData?.pages.flatMap((page) => page.records) ?? [],
73
-
[postsData]
74
-
);
75
-
76
80
const [imgcdn] = useAtom(imgCDNAtom);
77
81
78
82
function getAvatarUrl(p: typeof profile) {
···
91
95
const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did;
92
96
const description = profile?.description || "";
93
97
94
-
if (isIdentityLoading) {
95
-
return (
96
-
<div className="p-4 text-center text-gray-500">Resolving profile...</div>
97
-
);
98
-
}
98
+
const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord;
99
99
100
-
if (identityError) {
101
-
return (
102
-
<div className="p-4 text-center text-red-500">
103
-
Error: {identityError.message}
104
-
</div>
105
-
);
106
-
}
100
+
const resultwhateversure = useQueryConstellationLinksCountDistinctDids(
101
+
resolvedDid
102
+
? {
103
+
method: "/links/count/distinct-dids",
104
+
collection: "app.bsky.graph.follow",
105
+
target: resolvedDid,
106
+
path: ".subject",
107
+
}
108
+
: undefined
109
+
);
107
110
108
-
if (!resolvedDid) {
109
-
return (
110
-
<div className="p-4 text-center text-gray-500">Profile not found.</div>
111
-
);
112
-
}
111
+
const followercount = resultwhateversure?.data?.total;
113
112
114
113
return (
115
-
<>
114
+
<div className="">
116
115
<Header
117
116
title={`Profile`}
118
117
backButtonCallback={() => {
···
122
121
window.location.assign("/");
123
122
}
124
123
}}
124
+
bottomBorderDisabled={true}
125
125
/>
126
126
{/* <div className="flex gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700">
127
127
<Link
···
156
156
157
157
{/* Avatar (PFP) */}
158
158
<div className="absolute left-[16px] top-[100px] ">
159
-
<img
160
-
src={getAvatarUrl(profile) || "/favicon.png"}
161
-
alt="avatar"
162
-
className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700"
163
-
/>
159
+
{!getAvatarUrl(profile) && isLabeler ? (
160
+
<div
161
+
className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} items-center justify-center flex object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`}
162
+
>
163
+
<IconMdiShieldOutline className="w-20 h-20" />
164
+
</div>
165
+
) : (
166
+
<img
167
+
src={getAvatarUrl(profile) || "/favicon.png"}
168
+
alt="avatar"
169
+
className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`}
170
+
/>
171
+
)}
164
172
</div>
165
173
166
174
<div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5">
175
+
<BiteButton targetdidorhandle={did} />
167
176
{/*
168
177
todo: full follow and unfollow backfill (along with partial likes backfill,
169
178
just enough for it to be useful)
···
171
180
also save it persistently
172
181
*/}
173
182
<FollowButton targetdidorhandle={did} />
174
-
<Button className="rounded-full" variant={"secondary"}>
183
+
<button
184
+
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
185
+
onClick={(e) => {
186
+
renderSnack({
187
+
title: "Not Implemented Yet",
188
+
description: "Sorry...",
189
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
190
+
});
191
+
}}
192
+
>
175
193
... {/* todo: icon */}
176
-
</Button>
194
+
</button>
177
195
</div>
178
196
179
197
{/* Info Card */}
···
183
201
<Mutual targetdidorhandle={did} />
184
202
{handle}
185
203
</div>
204
+
<div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2">
205
+
<Link to="/profile/$did/followers" params={{ did: did }}>
206
+
{followercount && (
207
+
<span className="mr-1 text-gray-900 dark:text-gray-200 font-medium">
208
+
{followercount}
209
+
</span>
210
+
)}
211
+
Followers
212
+
</Link>
213
+
-
214
+
<Link to="/profile/$did/follows" params={{ did: did }}>
215
+
Follows
216
+
</Link>
217
+
</div>
186
218
{description && (
187
219
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
188
220
{/* {description} */}
···
192
224
</div>
193
225
</div>
194
226
195
-
{/* Posts Section */}
196
-
<div className="max-w-2xl mx-auto">
197
-
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
198
-
Posts
227
+
{/* this should not be rendered until its ready (the top profile layout is stable) */}
228
+
{isReady ? (
229
+
<ReusableTabRoute
230
+
route={`Profile` + did}
231
+
tabs={{
232
+
...(isLabeler
233
+
? {
234
+
Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />,
235
+
}
236
+
: {}),
237
+
...{
238
+
Posts: <PostsTab did={did} />,
239
+
Reposts: <RepostsTab did={did} />,
240
+
Feeds: <FeedsTab did={did} />,
241
+
Lists: <ListsTab did={did} />,
242
+
},
243
+
...(identity?.did === agent?.did
244
+
? { Likes: <SelfLikesTab did={did} /> }
245
+
: {}),
246
+
}}
247
+
/>
248
+
) : isIdentityLoading ? (
249
+
<div className="p-4 text-center text-gray-500">
250
+
Resolving profile...
251
+
</div>
252
+
) : identityError ? (
253
+
<div className="p-4 text-center text-red-500">
254
+
Error: {identityError.message}
255
+
</div>
256
+
) : !resolvedDid ? (
257
+
<div className="p-4 text-center text-gray-500">Profile not found.</div>
258
+
) : (
259
+
<div className="p-4 text-center text-gray-500">
260
+
Loading profile content...
199
261
</div>
200
-
<div>
201
-
{posts.map((post) => (
262
+
)}
263
+
</div>
264
+
);
265
+
}
266
+
267
+
export type ProfilePostsFilter = {
268
+
posts: boolean;
269
+
replies: boolean;
270
+
mediaOnly: boolean;
271
+
};
272
+
export const defaultProfilePostsFilter: ProfilePostsFilter = {
273
+
posts: true,
274
+
replies: true,
275
+
mediaOnly: false,
276
+
};
277
+
278
+
function ProfilePostsFilterChipBar({
279
+
filters,
280
+
toggle,
281
+
}: {
282
+
filters: ProfilePostsFilter | null;
283
+
toggle: (key: keyof ProfilePostsFilter) => void;
284
+
}) {
285
+
const empty = !filters?.replies && !filters?.posts;
286
+
const almostEmpty = !filters?.replies && filters?.posts;
287
+
288
+
useEffect(() => {
289
+
if (empty) {
290
+
toggle("posts");
291
+
}
292
+
}, [empty, toggle]);
293
+
294
+
return (
295
+
<div className="flex flex-row flex-wrap gap-2 px-4 pt-4">
296
+
<Chip
297
+
state={filters?.posts ?? true}
298
+
text="Posts"
299
+
onClick={() => (almostEmpty ? null : toggle("posts"))}
300
+
/>
301
+
<Chip
302
+
state={filters?.replies ?? true}
303
+
text="Replies"
304
+
onClick={() => toggle("replies")}
305
+
/>
306
+
<Chip
307
+
state={filters?.mediaOnly ?? false}
308
+
text="Media Only"
309
+
onClick={() => toggle("mediaOnly")}
310
+
/>
311
+
</div>
312
+
);
313
+
}
314
+
315
+
function PostsTab({ did }: { did: string }) {
316
+
// todo: this needs to be a (non-persisted is fine) atom to survive navigation
317
+
const [filterses, setFilterses] = useAtom(profileChipsAtom);
318
+
const filters = filterses?.[did];
319
+
const setFilters = (obj: ProfilePostsFilter) => {
320
+
setFilterses((prev) => {
321
+
return {
322
+
...prev,
323
+
[did]: obj,
324
+
};
325
+
});
326
+
};
327
+
useEffect(() => {
328
+
if (!filters) {
329
+
setFilters(defaultProfilePostsFilter);
330
+
}
331
+
});
332
+
useReusableTabScrollRestore(`Profile` + did);
333
+
const queryClient = useQueryClient();
334
+
const {
335
+
data: identity,
336
+
isLoading: isIdentityLoading,
337
+
error: identityError,
338
+
} = useQueryIdentity(did);
339
+
340
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
341
+
342
+
const {
343
+
data: postsData,
344
+
fetchNextPage,
345
+
hasNextPage,
346
+
isFetchingNextPage,
347
+
isLoading: arePostsLoading,
348
+
} = useInfiniteQueryAuthorFeed(resolvedDid, identity?.pds);
349
+
350
+
React.useEffect(() => {
351
+
if (postsData) {
352
+
postsData.pages.forEach((page) => {
353
+
page.records.forEach((record) => {
354
+
if (!queryClient.getQueryData(["post", record.uri])) {
355
+
queryClient.setQueryData(["post", record.uri], record);
356
+
}
357
+
});
358
+
});
359
+
}
360
+
}, [postsData, queryClient]);
361
+
362
+
const posts = React.useMemo(
363
+
() => postsData?.pages.flatMap((page) => page.records) ?? [],
364
+
[postsData]
365
+
);
366
+
367
+
const toggle = (key: keyof ProfilePostsFilter) => {
368
+
setFilterses((prev) => {
369
+
const existing = prev[did] ?? {
370
+
posts: false,
371
+
replies: false,
372
+
mediaOnly: false,
373
+
}; // default
374
+
375
+
return {
376
+
...prev,
377
+
[did]: {
378
+
...existing,
379
+
[key]: !existing[key], // safely negate
380
+
},
381
+
};
382
+
});
383
+
};
384
+
385
+
return (
386
+
<>
387
+
{/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
388
+
Posts
389
+
</div> */}
390
+
<ProfilePostsFilterChipBar filters={filters} toggle={toggle} />
391
+
<div>
392
+
{posts.map((post) => (
393
+
<UniversalPostRendererATURILoader
394
+
key={post.uri}
395
+
atUri={post.uri}
396
+
feedviewpost={true}
397
+
filterNoReplies={!filters?.replies}
398
+
filterMustHaveMedia={filters?.mediaOnly}
399
+
filterMustBeReply={!filters?.posts}
400
+
/>
401
+
))}
402
+
</div>
403
+
404
+
{/* Loading and "Load More" states */}
405
+
{arePostsLoading && posts.length === 0 && (
406
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
407
+
)}
408
+
{isFetchingNextPage && (
409
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
410
+
)}
411
+
{hasNextPage && !isFetchingNextPage && (
412
+
<button
413
+
onClick={() => fetchNextPage()}
414
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
415
+
>
416
+
Load More Posts
417
+
</button>
418
+
)}
419
+
{posts.length === 0 && !arePostsLoading && (
420
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
421
+
)}
422
+
</>
423
+
);
424
+
}
425
+
426
+
function RepostsTab({ did }: { did: string }) {
427
+
useReusableTabScrollRestore(`Profile` + did);
428
+
const {
429
+
data: identity,
430
+
isLoading: isIdentityLoading,
431
+
error: identityError,
432
+
} = useQueryIdentity(did);
433
+
434
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
435
+
436
+
const {
437
+
data: repostsData,
438
+
fetchNextPage,
439
+
hasNextPage,
440
+
isFetchingNextPage,
441
+
isLoading: arePostsLoading,
442
+
} = useInfiniteQueryAuthorFeed(
443
+
resolvedDid,
444
+
identity?.pds,
445
+
"app.bsky.feed.repost"
446
+
);
447
+
448
+
const reposts = React.useMemo(
449
+
() => repostsData?.pages.flatMap((page) => page.records) ?? [],
450
+
[repostsData]
451
+
);
452
+
453
+
return (
454
+
<>
455
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
456
+
Reposts
457
+
</div>
458
+
<div>
459
+
{reposts.map((repost) => {
460
+
if (
461
+
!repost ||
462
+
!repost?.value ||
463
+
!repost?.value?.subject ||
464
+
// @ts-expect-error blehhhhh
465
+
!repost?.value?.subject?.uri
466
+
)
467
+
return;
468
+
const repostRecord =
469
+
repost.value as unknown as ATPAPI.AppBskyFeedRepost.Record;
470
+
return (
202
471
<UniversalPostRendererATURILoader
203
-
key={post.uri}
204
-
atUri={post.uri}
472
+
key={repostRecord.subject.uri}
473
+
atUri={repostRecord.subject.uri}
205
474
feedviewpost={true}
475
+
repostedby={repost.uri}
206
476
/>
207
-
))}
208
-
</div>
477
+
);
478
+
})}
479
+
</div>
480
+
481
+
{/* Loading and "Load More" states */}
482
+
{arePostsLoading && reposts.length === 0 && (
483
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
484
+
)}
485
+
{isFetchingNextPage && (
486
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
487
+
)}
488
+
{hasNextPage && !isFetchingNextPage && (
489
+
<button
490
+
onClick={() => fetchNextPage()}
491
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
492
+
>
493
+
Load More Posts
494
+
</button>
495
+
)}
496
+
{reposts.length === 0 && !arePostsLoading && (
497
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
498
+
)}
499
+
</>
500
+
);
501
+
}
502
+
503
+
function FeedsTab({ did }: { did: string }) {
504
+
useReusableTabScrollRestore(`Profile` + did);
505
+
const {
506
+
data: identity,
507
+
isLoading: isIdentityLoading,
508
+
error: identityError,
509
+
} = useQueryIdentity(did);
510
+
511
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
512
+
513
+
const {
514
+
data: feedsData,
515
+
fetchNextPage,
516
+
hasNextPage,
517
+
isFetchingNextPage,
518
+
isLoading: arePostsLoading,
519
+
} = useInfiniteQueryAuthorFeed(
520
+
resolvedDid,
521
+
identity?.pds,
522
+
"app.bsky.feed.generator"
523
+
);
209
524
210
-
{/* Loading and "Load More" states */}
211
-
{arePostsLoading && posts.length === 0 && (
212
-
<div className="p-4 text-center text-gray-500">Loading posts...</div>
213
-
)}
214
-
{isFetchingNextPage && (
215
-
<div className="p-4 text-center text-gray-500">Loading more...</div>
216
-
)}
217
-
{hasNextPage && !isFetchingNextPage && (
218
-
<button
219
-
onClick={() => fetchNextPage()}
220
-
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
525
+
const feeds = React.useMemo(
526
+
() => feedsData?.pages.flatMap((page) => page.records) ?? [],
527
+
[feedsData]
528
+
);
529
+
530
+
return (
531
+
<>
532
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
533
+
Feeds
534
+
</div>
535
+
<div>
536
+
{feeds.map((feed) => {
537
+
if (!feed || !feed?.value) return;
538
+
const feedGenRecord =
539
+
feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record;
540
+
return <FeedItemRender feed={feed as any} key={feed.uri} />;
541
+
})}
542
+
</div>
543
+
544
+
{/* Loading and "Load More" states */}
545
+
{arePostsLoading && feeds.length === 0 && (
546
+
<div className="p-4 text-center text-gray-500">Loading feeds...</div>
547
+
)}
548
+
{isFetchingNextPage && (
549
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
550
+
)}
551
+
{hasNextPage && !isFetchingNextPage && (
552
+
<button
553
+
onClick={() => fetchNextPage()}
554
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
555
+
>
556
+
Load More Feeds
557
+
</button>
558
+
)}
559
+
{feeds.length === 0 && !arePostsLoading && (
560
+
<div className="p-4 text-center text-gray-500">No feeds found.</div>
561
+
)}
562
+
</>
563
+
);
564
+
}
565
+
566
+
function LabelsTab({
567
+
did,
568
+
labelerRecord,
569
+
}: {
570
+
did: string;
571
+
labelerRecord?: ATPAPI.AppBskyLabelerService.Record;
572
+
}) {
573
+
useReusableTabScrollRestore(`Profile` + did);
574
+
const { agent } = useAuth();
575
+
// const {
576
+
// data: identity,
577
+
// isLoading: isIdentityLoading,
578
+
// error: identityError,
579
+
// } = useQueryIdentity(did);
580
+
581
+
// const resolvedDid = did.startsWith("did:") ? did : identity?.did;
582
+
583
+
const labelMap = new Map(
584
+
labelerRecord?.policies?.labelValueDefinitions?.map((def) => {
585
+
const locale = def.locales.find((l) => l.lang === "en") ?? def.locales[0];
586
+
return [
587
+
def.identifier,
588
+
{
589
+
name: locale?.name,
590
+
description: locale?.description,
591
+
blur: def.blurs,
592
+
severity: def.severity,
593
+
adultOnly: def.adultOnly,
594
+
defaultSetting: def.defaultSetting,
595
+
},
596
+
];
597
+
})
598
+
);
599
+
600
+
return (
601
+
<>
602
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
603
+
Labels
604
+
</div>
605
+
<div>
606
+
{[...labelMap.entries()].map(([key, item]) => (
607
+
<div
608
+
key={key}
609
+
className="border-gray-300 dark:border-gray-700 border-b px-4 py-4"
221
610
>
222
-
Load More Posts
223
-
</button>
224
-
)}
225
-
{posts.length === 0 && !arePostsLoading && (
226
-
<div className="p-4 text-center text-gray-500">No posts found.</div>
227
-
)}
611
+
<div className="font-semibold text-lg">{item.name}</div>
612
+
<div className="text-sm text-gray-500 dark:text-gray-400">
613
+
{item.description}
614
+
</div>
615
+
<div className="mt-1 text-xs text-gray-400">
616
+
{item.blur && <span>Blur: {item.blur} </span>}
617
+
{item.severity && <span>โข Severity: {item.severity} </span>}
618
+
{item.adultOnly && <span>โข 18+ only</span>}
619
+
</div>
620
+
</div>
621
+
))}
228
622
</div>
623
+
624
+
{/* Loading and "Load More" states */}
625
+
{!labelerRecord && (
626
+
<div className="p-4 text-center text-gray-500">Loading labels...</div>
627
+
)}
628
+
{/* {!labelerRecord && (
629
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
630
+
)} */}
631
+
{/* {hasNextPage && !isFetchingNextPage && (
632
+
<button
633
+
onClick={() => fetchNextPage()}
634
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
635
+
>
636
+
Load More Feeds
637
+
</button>
638
+
)}
639
+
{feeds.length === 0 && !arePostsLoading && (
640
+
<div className="p-4 text-center text-gray-500">No feeds found.</div>
641
+
)} */}
642
+
</>
643
+
);
644
+
}
645
+
646
+
export function FeedItemRenderAturiLoader({
647
+
aturi,
648
+
listmode,
649
+
disableBottomBorder,
650
+
disablePropagation,
651
+
}: {
652
+
aturi: string;
653
+
listmode?: boolean;
654
+
disableBottomBorder?: boolean;
655
+
disablePropagation?: boolean;
656
+
}) {
657
+
const { data: record } = useQueryArbitrary(aturi);
658
+
659
+
if (!record) return;
660
+
return (
661
+
<FeedItemRender
662
+
listmode={listmode}
663
+
feed={record}
664
+
disableBottomBorder={disableBottomBorder}
665
+
disablePropagation={disablePropagation}
666
+
/>
667
+
);
668
+
}
669
+
670
+
export function FeedItemRender({
671
+
feed,
672
+
listmode,
673
+
disableBottomBorder,
674
+
disablePropagation,
675
+
}: {
676
+
feed: { uri: string; cid: string; value: any };
677
+
listmode?: boolean;
678
+
disableBottomBorder?: boolean;
679
+
disablePropagation?: boolean;
680
+
}) {
681
+
const name = listmode
682
+
? (feed.value?.name as string)
683
+
: (feed.value?.displayName as string);
684
+
const aturi = new ATPAPI.AtUri(feed.uri);
685
+
const { data: identity } = useQueryIdentity(aturi.host);
686
+
const resolvedDid = identity?.did;
687
+
const [imgcdn] = useAtom(imgCDNAtom);
688
+
689
+
function getAvatarThumbnailUrl(f: typeof feed) {
690
+
const link = f?.value.avatar?.ref?.["$link"];
691
+
if (!link || !resolvedDid) return null;
692
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
693
+
}
694
+
695
+
const { data: likes } = useQueryConstellation(
696
+
// @ts-expect-error overloads sucks
697
+
!listmode
698
+
? {
699
+
target: feed.uri,
700
+
method: "/links/count",
701
+
collection: "app.bsky.feed.like",
702
+
path: ".subject.uri",
703
+
}
704
+
: undefined
705
+
);
706
+
707
+
return (
708
+
<Link
709
+
className={`px-4 py-4 ${!disableBottomBorder && "border-b"} flex flex-col gap-1`}
710
+
to="/profile/$did/feed/$rkey"
711
+
params={{ did: aturi.host, rkey: aturi.rkey }}
712
+
onClick={(e) => {
713
+
e.stopPropagation();
714
+
}}
715
+
>
716
+
<div className="flex flex-row gap-3">
717
+
<div className="min-w-10 min-h-10">
718
+
<img
719
+
src={getAvatarThumbnailUrl(feed) || defaultpfp}
720
+
className="h-10 w-10 rounded border"
721
+
/>
722
+
</div>
723
+
<div className="flex flex-col">
724
+
<span className="">{name}</span>
725
+
<span 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">
726
+
{feed.value.did || aturi.rkey}
727
+
</span>
728
+
</div>
729
+
<div className="flex-1" />
730
+
{/* <div className="button bg-red-500 rounded-full min-w-[60px]" /> */}
731
+
</div>
732
+
<span className=" text-sm">{feed.value?.description}</span>
733
+
{!listmode && (
734
+
<span className=" text-sm dark:text-gray-400 text-gray-500">
735
+
Liked by {((likes as unknown as any)?.total as number) || 0} users
736
+
</span>
737
+
)}
738
+
</Link>
739
+
);
740
+
}
741
+
742
+
function ListsTab({ did }: { did: string }) {
743
+
useReusableTabScrollRestore(`Profile` + did);
744
+
const {
745
+
data: identity,
746
+
isLoading: isIdentityLoading,
747
+
error: identityError,
748
+
} = useQueryIdentity(did);
749
+
750
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
751
+
752
+
const {
753
+
data: feedsData,
754
+
fetchNextPage,
755
+
hasNextPage,
756
+
isFetchingNextPage,
757
+
isLoading: arePostsLoading,
758
+
} = useInfiniteQueryAuthorFeed(
759
+
resolvedDid,
760
+
identity?.pds,
761
+
"app.bsky.graph.list"
762
+
);
763
+
764
+
const feeds = React.useMemo(
765
+
() => feedsData?.pages.flatMap((page) => page.records) ?? [],
766
+
[feedsData]
767
+
);
768
+
769
+
return (
770
+
<>
771
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
772
+
Feeds
773
+
</div>
774
+
<div>
775
+
{feeds.map((feed) => {
776
+
if (!feed || !feed?.value) return;
777
+
const feedGenRecord =
778
+
feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record;
779
+
return (
780
+
<FeedItemRender listmode={true} feed={feed as any} key={feed.uri} />
781
+
);
782
+
})}
783
+
</div>
784
+
785
+
{/* Loading and "Load More" states */}
786
+
{arePostsLoading && feeds.length === 0 && (
787
+
<div className="p-4 text-center text-gray-500">Loading lists...</div>
788
+
)}
789
+
{isFetchingNextPage && (
790
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
791
+
)}
792
+
{hasNextPage && !isFetchingNextPage && (
793
+
<button
794
+
onClick={() => fetchNextPage()}
795
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
796
+
>
797
+
Load More Lists
798
+
</button>
799
+
)}
800
+
{feeds.length === 0 && !arePostsLoading && (
801
+
<div className="p-4 text-center text-gray-500">No lists found.</div>
802
+
)}
803
+
</>
804
+
);
805
+
}
806
+
807
+
function SelfLikesTab({ did }: { did: string }) {
808
+
useReusableTabScrollRestore(`Profile` + did);
809
+
const {
810
+
data: identity,
811
+
isLoading: isIdentityLoading,
812
+
error: identityError,
813
+
} = useQueryIdentity(did);
814
+
815
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
816
+
817
+
const {
818
+
data: likesData,
819
+
fetchNextPage,
820
+
hasNextPage,
821
+
isFetchingNextPage,
822
+
isLoading: arePostsLoading,
823
+
} = useInfiniteQueryAuthorFeed(
824
+
resolvedDid,
825
+
identity?.pds,
826
+
"app.bsky.feed.like"
827
+
);
828
+
829
+
const likes = React.useMemo(
830
+
() => likesData?.pages.flatMap((page) => page.records) ?? [],
831
+
[likesData]
832
+
);
833
+
834
+
const { setFastState } = useFastSetLikesFromFeed();
835
+
const seededRef = React.useRef(new Set<string>());
836
+
837
+
useEffect(() => {
838
+
for (const like of likes) {
839
+
if (!seededRef.current.has(like.uri)) {
840
+
seededRef.current.add(like.uri);
841
+
const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
842
+
setFastState(record.subject.uri, {
843
+
target: record.subject.uri,
844
+
uri: like.uri,
845
+
cid: like.cid,
846
+
});
847
+
}
848
+
}
849
+
}, [likes, setFastState]);
850
+
851
+
return (
852
+
<>
853
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
854
+
Likes
855
+
</div>
856
+
<div>
857
+
{likes.map((like) => {
858
+
if (
859
+
!like ||
860
+
!like?.value ||
861
+
!like?.value?.subject ||
862
+
// @ts-expect-error blehhhhh
863
+
!like?.value?.subject?.uri
864
+
)
865
+
return;
866
+
const likeRecord =
867
+
like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
868
+
return (
869
+
<UniversalPostRendererATURILoader
870
+
key={likeRecord.subject.uri}
871
+
atUri={likeRecord.subject.uri}
872
+
feedviewpost={true}
873
+
/>
874
+
);
875
+
})}
876
+
</div>
877
+
878
+
{/* Loading and "Load More" states */}
879
+
{arePostsLoading && likes.length === 0 && (
880
+
<div className="p-4 text-center text-gray-500">Loading likes...</div>
881
+
)}
882
+
{isFetchingNextPage && (
883
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
884
+
)}
885
+
{hasNextPage && !isFetchingNextPage && (
886
+
<button
887
+
onClick={() => fetchNextPage()}
888
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
889
+
>
890
+
Load More Likes
891
+
</button>
892
+
)}
893
+
{likes.length === 0 && !arePostsLoading && (
894
+
<div className="p-4 text-center text-gray-500">No likes found.</div>
895
+
)}
229
896
</>
230
897
);
231
898
}
···
249
916
{identity?.did !== agent?.did ? (
250
917
<>
251
918
{!(followRecords?.length && followRecords?.length > 0) ? (
252
-
<Button
919
+
<button
253
920
onClick={(e) => {
254
921
e.stopPropagation();
255
922
toggleFollow({
···
259
926
queryClient: queryClient,
260
927
});
261
928
}}
929
+
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]"
262
930
>
263
931
Follow
264
-
</Button>
932
+
</button>
265
933
) : (
266
-
<Button
934
+
<button
267
935
onClick={(e) => {
268
936
e.stopPropagation();
269
937
toggleFollow({
···
273
941
queryClient: queryClient,
274
942
});
275
943
}}
944
+
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]"
276
945
>
277
946
Unfollow
278
-
</Button>
947
+
</button>
279
948
)}
280
949
</>
281
950
) : (
282
-
<Button variant={"secondary"}>Edit Profile</Button>
951
+
<button
952
+
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]"
953
+
onClick={(e) => {
954
+
renderSnack({
955
+
title: "Not Implemented Yet",
956
+
description: "Sorry...",
957
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
958
+
});
959
+
}}
960
+
>
961
+
Edit Profile
962
+
</button>
283
963
)}
284
964
</>
285
965
);
966
+
}
967
+
968
+
export function BiteButton({
969
+
targetdidorhandle,
970
+
}: {
971
+
targetdidorhandle: string;
972
+
}) {
973
+
const { agent } = useAuth();
974
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
975
+
const [show] = useAtom(enableBitesAtom);
976
+
977
+
if (!show) return;
978
+
979
+
return (
980
+
<>
981
+
<button
982
+
onClick={async (e) => {
983
+
e.stopPropagation();
984
+
await sendBite({
985
+
agent: agent || undefined,
986
+
targetDid: identity?.did,
987
+
});
988
+
}}
989
+
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]"
990
+
>
991
+
Bite
992
+
</button>
993
+
</>
994
+
);
995
+
}
996
+
997
+
async function sendBite({
998
+
agent,
999
+
targetDid,
1000
+
}: {
1001
+
agent?: Agent;
1002
+
targetDid?: string;
1003
+
}) {
1004
+
if (!agent?.did || !targetDid) {
1005
+
renderSnack({
1006
+
title: "Bite Failed",
1007
+
description: "You must be logged-in to bite someone.",
1008
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
1009
+
});
1010
+
return;
1011
+
}
1012
+
const newRecord = {
1013
+
repo: agent.did,
1014
+
collection: "net.wafrn.feed.bite",
1015
+
rkey: TID.next().toString(),
1016
+
record: {
1017
+
$type: "net.wafrn.feed.bite",
1018
+
subject: "at://" + targetDid,
1019
+
createdAt: new Date().toISOString(),
1020
+
},
1021
+
};
1022
+
1023
+
try {
1024
+
await agent.com.atproto.repo.createRecord(newRecord);
1025
+
renderSnack({
1026
+
title: "Bite Sent",
1027
+
description: "Your bite was delivered.",
1028
+
//button: { label: 'Undo', onClick: () => console.log('Undo clicked') },
1029
+
});
1030
+
} catch (err) {
1031
+
console.error("Bite failed:", err);
1032
+
renderSnack({
1033
+
title: "Bite Failed",
1034
+
description: "Your bite failed to be delivered.",
1035
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
1036
+
});
1037
+
}
286
1038
}
287
1039
288
1040
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
+100
src/routes/profile.$did/post.$rkey.liked-by.tsx
+100
src/routes/profile.$did/post.$rkey.liked-by.tsx
···
1
+
import { useInfiniteQuery } from "@tanstack/react-query";
2
+
import { createFileRoute } from "@tanstack/react-router";
3
+
import { useAtom } from "jotai";
4
+
import React from "react";
5
+
6
+
import { Header } from "~/components/Header";
7
+
import { constellationURLAtom } from "~/utils/atoms";
8
+
import { useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery";
9
+
10
+
import {
11
+
EmptyState,
12
+
ErrorState,
13
+
LoadingState,
14
+
NotificationItem,
15
+
} from "../notifications";
16
+
17
+
export const Route = createFileRoute("/profile/$did/post/$rkey/liked-by")({
18
+
component: RouteComponent,
19
+
});
20
+
21
+
function RouteComponent() {
22
+
const { did, rkey } = Route.useParams();
23
+
const { data: identity } = useQueryIdentity(did);
24
+
const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : '';
25
+
26
+
const [constellationurl] = useAtom(constellationURLAtom);
27
+
const infinitequeryresults = useInfiniteQuery({
28
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
29
+
{
30
+
constellation: constellationurl,
31
+
method: "/links",
32
+
target: atUri,
33
+
collection: "app.bsky.feed.like",
34
+
path: ".subject.uri",
35
+
}
36
+
),
37
+
enabled: !!atUri,
38
+
});
39
+
40
+
const {
41
+
data: infiniteLikesData,
42
+
fetchNextPage,
43
+
hasNextPage,
44
+
isFetchingNextPage,
45
+
isLoading,
46
+
isError,
47
+
error,
48
+
} = infinitequeryresults;
49
+
50
+
const likesAturis = React.useMemo(() => {
51
+
// Get all replies from the standard infinite query
52
+
return (
53
+
infiniteLikesData?.pages.flatMap(
54
+
(page) =>
55
+
page?.linking_records.map(
56
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
57
+
) ?? []
58
+
) ?? []
59
+
);
60
+
}, [infiniteLikesData]);
61
+
62
+
return (
63
+
<>
64
+
<Header
65
+
title={`Liked By`}
66
+
backButtonCallback={() => {
67
+
if (window.history.length > 1) {
68
+
window.history.back();
69
+
} else {
70
+
window.location.assign("/");
71
+
}
72
+
}}
73
+
/>
74
+
75
+
<>
76
+
{(() => {
77
+
if (isLoading) return <LoadingState text="Loading likes..." />;
78
+
if (isError) return <ErrorState error={error} />;
79
+
80
+
if (!likesAturis?.length)
81
+
return <EmptyState text="No likes yet." />;
82
+
})()}
83
+
</>
84
+
85
+
{likesAturis.map((m) => (
86
+
<NotificationItem key={m} notification={m} />
87
+
))}
88
+
89
+
{hasNextPage && (
90
+
<button
91
+
onClick={() => fetchNextPage()}
92
+
disabled={isFetchingNextPage}
93
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
94
+
>
95
+
{isFetchingNextPage ? "Loading..." : "Load More"}
96
+
</button>
97
+
)}
98
+
</>
99
+
);
100
+
}
+141
src/routes/profile.$did/post.$rkey.quotes.tsx
+141
src/routes/profile.$did/post.$rkey.quotes.tsx
···
1
+
import { useInfiniteQuery } from "@tanstack/react-query";
2
+
import { createFileRoute } from "@tanstack/react-router";
3
+
import { useAtom } from "jotai";
4
+
import React from "react";
5
+
6
+
import { Header } from "~/components/Header";
7
+
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
8
+
import { constellationURLAtom } from "~/utils/atoms";
9
+
import { type linksRecord,useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery";
10
+
11
+
import {
12
+
EmptyState,
13
+
ErrorState,
14
+
LoadingState,
15
+
} from "../notifications";
16
+
17
+
export const Route = createFileRoute("/profile/$did/post/$rkey/quotes")({
18
+
component: RouteComponent,
19
+
});
20
+
21
+
function RouteComponent() {
22
+
const { did, rkey } = Route.useParams();
23
+
const { data: identity } = useQueryIdentity(did);
24
+
const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : '';
25
+
26
+
const [constellationurl] = useAtom(constellationURLAtom);
27
+
const infinitequeryresultsWithoutMedia = useInfiniteQuery({
28
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
29
+
{
30
+
constellation: constellationurl,
31
+
method: "/links",
32
+
target: atUri,
33
+
collection: "app.bsky.feed.post",
34
+
path: ".embed.record.uri", // embed.record.record.uri and embed.record.uri
35
+
}
36
+
),
37
+
enabled: !!atUri,
38
+
});
39
+
const infinitequeryresultsWithMedia = useInfiniteQuery({
40
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
41
+
{
42
+
constellation: constellationurl,
43
+
method: "/links",
44
+
target: atUri,
45
+
collection: "app.bsky.feed.post",
46
+
path: ".embed.record.record.uri", // embed.record.record.uri and embed.record.uri
47
+
}
48
+
),
49
+
enabled: !!atUri,
50
+
});
51
+
52
+
const {
53
+
data: infiniteQuotesDataWithoutMedia,
54
+
fetchNextPage: fetchNextPageWithoutMedia,
55
+
hasNextPage: hasNextPageWithoutMedia,
56
+
isFetchingNextPage: isFetchingNextPageWithoutMedia,
57
+
isLoading: isLoadingWithoutMedia,
58
+
isError: isErrorWithoutMedia,
59
+
error: errorWithoutMedia,
60
+
} = infinitequeryresultsWithoutMedia;
61
+
const {
62
+
data: infiniteQuotesDataWithMedia,
63
+
fetchNextPage: fetchNextPageWithMedia,
64
+
hasNextPage: hasNextPageWithMedia,
65
+
isFetchingNextPage: isFetchingNextPageWithMedia,
66
+
isLoading: isLoadingWithMedia,
67
+
isError: isErrorWithMedia,
68
+
error: errorWithMedia,
69
+
} = infinitequeryresultsWithMedia;
70
+
71
+
const fetchNextPage = async () => {
72
+
await Promise.all([
73
+
hasNextPageWithMedia && fetchNextPageWithMedia(),
74
+
hasNextPageWithoutMedia && fetchNextPageWithoutMedia(),
75
+
]);
76
+
};
77
+
78
+
const hasNextPage = hasNextPageWithMedia || hasNextPageWithoutMedia;
79
+
const isFetchingNextPage = isFetchingNextPageWithMedia || isFetchingNextPageWithoutMedia;
80
+
const isLoading = isLoadingWithMedia || isLoadingWithoutMedia;
81
+
82
+
const allQuotes = React.useMemo(() => {
83
+
const withPages = infiniteQuotesDataWithMedia?.pages ?? [];
84
+
const withoutPages = infiniteQuotesDataWithoutMedia?.pages ?? [];
85
+
const maxLen = Math.max(withPages.length, withoutPages.length);
86
+
const merged: linksRecord[] = [];
87
+
88
+
for (let i = 0; i < maxLen; i++) {
89
+
const a = withPages[i]?.linking_records ?? [];
90
+
const b = withoutPages[i]?.linking_records ?? [];
91
+
const mergedPage = [...a, ...b].sort((b, a) => a.rkey.localeCompare(b.rkey));
92
+
merged.push(...mergedPage);
93
+
}
94
+
95
+
return merged;
96
+
}, [infiniteQuotesDataWithMedia?.pages, infiniteQuotesDataWithoutMedia?.pages]);
97
+
98
+
const quotesAturis = React.useMemo(() => {
99
+
return allQuotes.flatMap((r) => `at://${r.did}/${r.collection}/${r.rkey}`);
100
+
}, [allQuotes]);
101
+
102
+
return (
103
+
<>
104
+
<Header
105
+
title={`Quotes`}
106
+
backButtonCallback={() => {
107
+
if (window.history.length > 1) {
108
+
window.history.back();
109
+
} else {
110
+
window.location.assign("/");
111
+
}
112
+
}}
113
+
/>
114
+
115
+
<>
116
+
{(() => {
117
+
if (isLoading) return <LoadingState text="Loading quotes..." />;
118
+
if (isErrorWithMedia) return <ErrorState error={errorWithMedia} />;
119
+
if (isErrorWithoutMedia) return <ErrorState error={errorWithoutMedia} />;
120
+
121
+
if (!quotesAturis?.length)
122
+
return <EmptyState text="No quotes yet." />;
123
+
})()}
124
+
</>
125
+
126
+
{quotesAturis.map((m) => (
127
+
<UniversalPostRendererATURILoader key={m} atUri={m} />
128
+
))}
129
+
130
+
{hasNextPage && (
131
+
<button
132
+
onClick={() => fetchNextPage()}
133
+
disabled={isFetchingNextPage}
134
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
135
+
>
136
+
{isFetchingNextPage ? "Loading..." : "Load More"}
137
+
</button>
138
+
)}
139
+
</>
140
+
);
141
+
}
+100
src/routes/profile.$did/post.$rkey.reposted-by.tsx
+100
src/routes/profile.$did/post.$rkey.reposted-by.tsx
···
1
+
import { useInfiniteQuery } from "@tanstack/react-query";
2
+
import { createFileRoute } from "@tanstack/react-router";
3
+
import { useAtom } from "jotai";
4
+
import React from "react";
5
+
6
+
import { Header } from "~/components/Header";
7
+
import { constellationURLAtom } from "~/utils/atoms";
8
+
import { useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery";
9
+
10
+
import {
11
+
EmptyState,
12
+
ErrorState,
13
+
LoadingState,
14
+
NotificationItem,
15
+
} from "../notifications";
16
+
17
+
export const Route = createFileRoute("/profile/$did/post/$rkey/reposted-by")({
18
+
component: RouteComponent,
19
+
});
20
+
21
+
function RouteComponent() {
22
+
const { did, rkey } = Route.useParams();
23
+
const { data: identity } = useQueryIdentity(did);
24
+
const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : '';
25
+
26
+
const [constellationurl] = useAtom(constellationURLAtom);
27
+
const infinitequeryresults = useInfiniteQuery({
28
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
29
+
{
30
+
constellation: constellationurl,
31
+
method: "/links",
32
+
target: atUri,
33
+
collection: "app.bsky.feed.repost",
34
+
path: ".subject.uri",
35
+
}
36
+
),
37
+
enabled: !!atUri,
38
+
});
39
+
40
+
const {
41
+
data: infiniteRepostsData,
42
+
fetchNextPage,
43
+
hasNextPage,
44
+
isFetchingNextPage,
45
+
isLoading,
46
+
isError,
47
+
error,
48
+
} = infinitequeryresults;
49
+
50
+
const repostsAturis = React.useMemo(() => {
51
+
// Get all replies from the standard infinite query
52
+
return (
53
+
infiniteRepostsData?.pages.flatMap(
54
+
(page) =>
55
+
page?.linking_records.map(
56
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
57
+
) ?? []
58
+
) ?? []
59
+
);
60
+
}, [infiniteRepostsData]);
61
+
62
+
return (
63
+
<>
64
+
<Header
65
+
title={`Reposted By`}
66
+
backButtonCallback={() => {
67
+
if (window.history.length > 1) {
68
+
window.history.back();
69
+
} else {
70
+
window.location.assign("/");
71
+
}
72
+
}}
73
+
/>
74
+
75
+
<>
76
+
{(() => {
77
+
if (isLoading) return <LoadingState text="Loading reposts..." />;
78
+
if (isError) return <ErrorState error={error} />;
79
+
80
+
if (!repostsAturis?.length)
81
+
return <EmptyState text="No reposts yet." />;
82
+
})()}
83
+
</>
84
+
85
+
{repostsAturis.map((m) => (
86
+
<NotificationItem key={m} notification={m} />
87
+
))}
88
+
89
+
{hasNextPage && (
90
+
<button
91
+
onClick={() => fetchNextPage()}
92
+
disabled={isFetchingNextPage}
93
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
94
+
>
95
+
{isFetchingNextPage ? "Loading..." : "Load More"}
96
+
</button>
97
+
)}
98
+
</>
99
+
);
100
+
}
+98
-92
src/routes/profile.$did/post.$rkey.tsx
+98
-92
src/routes/profile.$did/post.$rkey.tsx
···
1
1
import { AtUri } from "@atproto/api";
2
2
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
-
import { createFileRoute, Outlet } from "@tanstack/react-router";
3
+
import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router";
4
4
import { useAtom } from "jotai";
5
5
import React, { useLayoutEffect } from "react";
6
6
···
52
52
nopics?: boolean;
53
53
lightboxCallback?: (d: LightboxProps) => void;
54
54
}) {
55
+
const matchRoute = useMatchRoute()
56
+
const showMainPostRoute = !!matchRoute({ to: '/profile/$did/post/$rkey' }) || !!matchRoute({ to: '/profile/$did/post/$rkey/image/$i' })
57
+
55
58
//const { get, set } = usePersistentStore();
56
59
const queryClient = useQueryClient();
57
60
// const [resolvedDid, setResolvedDid] = React.useState<string | null>(null);
···
190
193
data: identity,
191
194
isLoading: isIdentityLoading,
192
195
error: identityError,
193
-
} = useQueryIdentity(did);
196
+
} = useQueryIdentity(showMainPostRoute ? did : undefined);
194
197
195
198
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
196
199
197
200
const atUri = React.useMemo(
198
201
() =>
199
-
resolvedDid
202
+
resolvedDid && showMainPostRoute
200
203
? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}`
201
204
: undefined,
202
-
[resolvedDid, rkey]
205
+
[resolvedDid, rkey, showMainPostRoute]
203
206
);
204
207
205
-
const { data: mainPost } = useQueryPost(atUri);
208
+
const { data: mainPost } = useQueryPost(showMainPostRoute ? atUri : undefined);
206
209
207
210
console.log("atUri",atUri)
208
211
···
215
218
);
216
219
217
220
// @ts-expect-error i hate overloads
218
-
const { data: links } = useQueryConstellation(atUri?{
221
+
const { data: links } = useQueryConstellation(atUri&&showMainPostRoute?{
219
222
method: "/links/all",
220
223
target: atUri,
221
224
} : {
···
248
251
}, [links]);
249
252
250
253
const { data: opreplies } = useQueryConstellation(
251
-
!!opdid && replyCount && replyCount >= 25
254
+
showMainPostRoute && !!opdid && replyCount && replyCount >= 25
252
255
? {
253
256
method: "/links",
254
257
target: atUri,
···
289
292
path: ".reply.parent.uri",
290
293
}
291
294
),
292
-
enabled: !!atUri,
295
+
enabled: !!atUri && showMainPostRoute,
293
296
});
294
297
295
298
const {
···
371
374
const [layoutReady, setLayoutReady] = React.useState(false);
372
375
373
376
useLayoutEffect(() => {
377
+
if (!showMainPostRoute) return
374
378
if (parents.length > 0 && !layoutReady && mainPostRef.current) {
375
379
const mainPostElement = mainPostRef.current;
376
380
···
389
393
// eslint-disable-next-line react-hooks/set-state-in-effect
390
394
setLayoutReady(true);
391
395
}
392
-
}, [parents, layoutReady]);
396
+
}, [parents, layoutReady, showMainPostRoute]);
393
397
394
398
395
399
const [slingshoturl] = useAtom(slingshotURLAtom)
396
400
397
401
React.useEffect(() => {
398
-
if (parentsLoading) {
402
+
if (parentsLoading || !showMainPostRoute) {
399
403
setLayoutReady(false);
400
404
}
401
405
···
403
407
setLayoutReady(true);
404
408
hasPerformedInitialLayout.current = true;
405
409
}
406
-
}, [parentsLoading, mainPost]);
410
+
}, [parentsLoading, mainPost, showMainPostRoute]);
407
411
408
412
React.useEffect(() => {
409
413
if (!mainPost?.value?.reply?.parent?.uri) {
···
444
448
return () => {
445
449
ignore = true;
446
450
};
447
-
}, [mainPost, queryClient]);
451
+
}, [mainPost, queryClient, slingshoturl]);
448
452
449
-
if (!did || !rkey) return <div>Invalid post URI</div>;
450
-
if (isIdentityLoading) return <div>Resolving handle...</div>;
451
-
if (identityError)
453
+
if ((!did || !rkey) && showMainPostRoute) return <div>Invalid post URI</div>;
454
+
if (isIdentityLoading && showMainPostRoute) return <div>Resolving handle...</div>;
455
+
if (identityError && showMainPostRoute)
452
456
return <div style={{ color: "red" }}>{identityError.message}</div>;
453
-
if (!atUri) return <div>Could not construct post URI.</div>;
457
+
if (!atUri && showMainPostRoute) return <div>Could not construct post URI.</div>;
454
458
455
459
return (
456
460
<>
457
461
<Outlet />
458
-
<Header
459
-
title={`Post`}
460
-
backButtonCallback={() => {
461
-
if (window.history.length > 1) {
462
-
window.history.back();
463
-
} else {
464
-
window.location.assign("/");
465
-
}
466
-
}}
467
-
/>
462
+
{showMainPostRoute && (<>
463
+
<Header
464
+
title={`Post`}
465
+
backButtonCallback={() => {
466
+
if (window.history.length > 1) {
467
+
window.history.back();
468
+
} else {
469
+
window.location.assign("/");
470
+
}
471
+
}}
472
+
/>
468
473
469
-
{parentsLoading && (
470
-
<div className="text-center text-gray-500 dark:text-gray-400 flex flex-row">
471
-
<div className="ml-4 w-[42px] flex justify-center">
472
-
<div
473
-
style={{ width: 2, height: "100%", opacity: 0.5 }}
474
-
className="bg-gray-500 dark:bg-gray-400"
475
-
></div>
474
+
{parentsLoading && (
475
+
<div className="text-center text-gray-500 dark:text-gray-400 flex flex-row">
476
+
<div className="ml-4 w-[42px] flex justify-center">
477
+
<div
478
+
style={{ width: 2, height: "100%", opacity: 0.5 }}
479
+
className="bg-gray-500 dark:bg-gray-400"
480
+
></div>
481
+
</div>
482
+
Loading conversation...
476
483
</div>
477
-
Loading conversation...
484
+
)}
485
+
486
+
{/* we should use the reply lines here thats provided by UPR*/}
487
+
<div style={{ maxWidth: 600, padding: 0 }}>
488
+
{parents.map((parent, index) => (
489
+
<UniversalPostRendererATURILoader
490
+
key={parent.uri}
491
+
atUri={parent.uri}
492
+
topReplyLine={index > 0}
493
+
bottomReplyLine={true}
494
+
bottomBorder={false}
495
+
/>
496
+
))}
478
497
</div>
479
-
)}
480
-
481
-
{/* we should use the reply lines here thats provided by UPR*/}
482
-
<div style={{ maxWidth: 600, padding: 0 }}>
483
-
{parents.map((parent, index) => (
498
+
<div ref={mainPostRef}>
484
499
<UniversalPostRendererATURILoader
485
-
key={parent.uri}
486
-
atUri={parent.uri}
487
-
topReplyLine={index > 0}
488
-
bottomReplyLine={true}
489
-
bottomBorder={false}
500
+
atUri={atUri!}
501
+
detailed={true}
502
+
topReplyLine={parentsLoading || parents.length > 0}
503
+
nopics={!!nopics}
504
+
lightboxCallback={lightboxCallback}
490
505
/>
491
-
))}
492
-
</div>
493
-
<div ref={mainPostRef}>
494
-
<UniversalPostRendererATURILoader
495
-
atUri={atUri}
496
-
detailed={true}
497
-
topReplyLine={parentsLoading || parents.length > 0}
498
-
nopics={!!nopics}
499
-
lightboxCallback={lightboxCallback}
500
-
/>
501
-
</div>
502
-
<div
503
-
style={{
504
-
maxWidth: 600,
505
-
//margin: "0px auto 0",
506
-
padding: 0,
507
-
minHeight: "80dvh",
508
-
paddingBottom: "20dvh",
509
-
}}
510
-
>
506
+
</div>
511
507
<div
512
-
className="text-gray-500 dark:text-gray-400 text-sm font-bold"
513
508
style={{
514
-
fontSize: 18,
515
-
margin: "12px 16px 12px 16px",
516
-
fontWeight: 600,
509
+
maxWidth: 600,
510
+
//margin: "0px auto 0",
511
+
padding: 0,
512
+
minHeight: "80dvh",
513
+
paddingBottom: "20dvh",
517
514
}}
518
515
>
519
-
Replies
516
+
<div
517
+
className="text-gray-500 dark:text-gray-400 text-sm font-bold"
518
+
style={{
519
+
fontSize: 18,
520
+
margin: "12px 16px 12px 16px",
521
+
fontWeight: 600,
522
+
}}
523
+
>
524
+
Replies
525
+
</div>
526
+
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
527
+
{replyAturis.length > 0 &&
528
+
replyAturis.map((reply) => {
529
+
//const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`;
530
+
return (
531
+
<UniversalPostRendererATURILoader
532
+
key={reply}
533
+
atUri={reply}
534
+
maxReplies={4}
535
+
/>
536
+
);
537
+
})}
538
+
{hasNextPage && (
539
+
<button
540
+
onClick={() => fetchNextPage()}
541
+
disabled={isFetchingNextPage}
542
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
543
+
>
544
+
{isFetchingNextPage ? "Loading..." : "Load More"}
545
+
</button>
546
+
)}
547
+
</div>
520
548
</div>
521
-
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
522
-
{replyAturis.length > 0 &&
523
-
replyAturis.map((reply) => {
524
-
//const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`;
525
-
return (
526
-
<UniversalPostRendererATURILoader
527
-
key={reply}
528
-
atUri={reply}
529
-
maxReplies={4}
530
-
/>
531
-
);
532
-
})}
533
-
{hasNextPage && (
534
-
<button
535
-
onClick={() => fetchNextPage()}
536
-
disabled={isFetchingNextPage}
537
-
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
538
-
>
539
-
{isFetchingNextPage ? "Loading..." : "Load More"}
540
-
</button>
541
-
)}
542
-
</div>
543
-
</div>
549
+
</>)}
544
550
</>
545
551
);
546
552
}
+217
-9
src/routes/search.tsx
+217
-9
src/routes/search.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
1
+
import type { Agent } from "@atproto/api";
2
+
import { useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, useSearch } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
5
+
import { useEffect,useMemo } from "react";
2
6
3
7
import { Header } from "~/components/Header";
4
8
import { Import } from "~/components/Import";
9
+
import {
10
+
ReusableTabRoute,
11
+
useReusableTabScrollRestore,
12
+
} from "~/components/ReusableTabRoute";
13
+
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
14
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
15
+
import { lycanURLAtom } from "~/utils/atoms";
16
+
import {
17
+
constructLycanRequestIndexQuery,
18
+
useInfiniteQueryLycanSearch,
19
+
useQueryIdentity,
20
+
useQueryLycanStatus,
21
+
} from "~/utils/useQuery";
22
+
23
+
import { renderSnack } from "./__root";
24
+
import { SliderPrimitive } from "./settings";
5
25
6
26
export const Route = createFileRoute("/search")({
7
27
component: Search,
8
28
});
9
29
10
30
export function Search() {
31
+
const queryClient = useQueryClient();
32
+
const { agent, status } = useAuth();
33
+
const { data: identity } = useQueryIdentity(agent?.did);
34
+
const [lycandomain] = useAtom(lycanURLAtom);
35
+
const lycanExists = lycandomain !== "";
36
+
const { data: lycanstatusdata, refetch } = useQueryLycanStatus();
37
+
const lycanIndexed = lycanstatusdata?.status === "finished" || false;
38
+
const lycanIndexing = lycanstatusdata?.status === "in_progress" || false;
39
+
const lycanIndexingProgress = lycanIndexing
40
+
? lycanstatusdata?.progress
41
+
: undefined;
42
+
43
+
const authed = status === "signedIn";
44
+
45
+
const lycanReady = lycanExists && lycanIndexed && authed;
46
+
47
+
const { q }: { q: string } = useSearch({ from: "/search" });
48
+
49
+
// auto-refetch Lycan status until ready
50
+
useEffect(() => {
51
+
if (!lycanExists || !authed) return;
52
+
if (lycanReady) return;
53
+
54
+
const interval = setInterval(() => {
55
+
refetch();
56
+
}, 3000);
57
+
58
+
return () => clearInterval(interval);
59
+
}, [lycanExists, authed, lycanReady, refetch]);
60
+
61
+
const maintext = !lycanExists
62
+
? "Sorry we dont have search. But instead, you can load some of these types of content into Red Dwarf:"
63
+
: authed
64
+
? lycanReady
65
+
? "Lycan Search is enabled and ready! Type to search posts you've interacted with in the past. You can also load some of these types of content into Red Dwarf:"
66
+
: "Sorry, while Lycan Search is enabled, you are not indexed. Index below please. You can load some of these types of content into Red Dwarf:"
67
+
: "Sorry, while Lycan Search is enabled, you are unauthed. Please log in to use Lycan. You can load some of these types of content into Red Dwarf:";
68
+
69
+
async function index(opts: {
70
+
agent?: Agent;
71
+
isAuthed: boolean;
72
+
pdsUrl?: string;
73
+
feedServiceDid?: string;
74
+
}) {
75
+
renderSnack({
76
+
title: "Registering account...",
77
+
});
78
+
try {
79
+
const response = await queryClient.fetchQuery(
80
+
constructLycanRequestIndexQuery(opts)
81
+
);
82
+
if (
83
+
response?.message !== "Import has already started" &&
84
+
response?.message !== "Import has been scheduled"
85
+
) {
86
+
renderSnack({
87
+
title: "Registration failed!",
88
+
description: "Unknown server error (2)",
89
+
});
90
+
} else {
91
+
renderSnack({
92
+
title: "Succesfully sent registration request!",
93
+
description: "Please wait for the server to index your account",
94
+
});
95
+
refetch();
96
+
}
97
+
} catch {
98
+
renderSnack({
99
+
title: "Registration failed!",
100
+
description: "Unknown server error (1)",
101
+
});
102
+
}
103
+
}
104
+
11
105
return (
12
106
<>
13
107
<Header
···
21
115
}}
22
116
/>
23
117
<div className=" flex flex-col items-center mt-4 mx-4 gap-4">
24
-
<Import />
118
+
<Import optionaltextstring={q} />
25
119
<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>
120
+
<p className="text-gray-600 dark:text-gray-400">{maintext}</p>
30
121
<ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400">
31
122
<li>
32
-
Bluesky URLs from supported clients (like{" "}
123
+
Bluesky URLs (from supported clients) (like{" "}
33
124
<code className="text-sm">bsky.app</code> or{" "}
34
125
<code className="text-sm">deer.social</code>).
35
126
</li>
···
39
130
).
40
131
</li>
41
132
<li>
42
-
Plain handles (like{" "}
133
+
User Handles (like{" "}
43
134
<code className="text-sm">@username.bsky.social</code>).
44
135
</li>
45
136
<li>
46
-
Direct DIDs (Decentralized Identifiers, starting with{" "}
137
+
DIDs (Decentralized Identifiers, starting with{" "}
47
138
<code className="text-sm">did:</code>).
48
139
</li>
49
140
</ul>
···
51
142
Simply paste one of these into the import field above and press
52
143
Enter to load the content.
53
144
</p>
145
+
146
+
{lycanExists && authed && !lycanReady ? (
147
+
!lycanIndexing ? (
148
+
<div className="mt-4 mx-auto">
149
+
<button
150
+
onClick={() =>
151
+
index({
152
+
agent: agent || undefined,
153
+
isAuthed: status === "signedIn",
154
+
pdsUrl: identity?.pds,
155
+
feedServiceDid: "did:web:" + lycandomain,
156
+
})
157
+
}
158
+
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
159
+
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
160
+
>
161
+
Index my Account
162
+
</button>
163
+
</div>
164
+
) : (
165
+
<div className="mt-4 gap-2 flex flex-col">
166
+
<span>indexing...</span>
167
+
<SliderPrimitive
168
+
value={lycanIndexingProgress || 0}
169
+
min={0}
170
+
max={1}
171
+
/>
172
+
</div>
173
+
)
174
+
) : (
175
+
<></>
176
+
)}
54
177
</div>
55
178
</div>
179
+
{q ? <SearchTabs query={q} /> : <></>}
56
180
</>
57
181
);
58
182
}
183
+
184
+
function SearchTabs({ query }: { query: string }) {
185
+
return (
186
+
<div>
187
+
<ReusableTabRoute
188
+
route={`search` + query}
189
+
tabs={{
190
+
Likes: <LycanTab query={query} type={"likes"} key={"likes"} />,
191
+
Reposts: <LycanTab query={query} type={"reposts"} key={"reposts"} />,
192
+
Quotes: <LycanTab query={query} type={"quotes"} key={"quotes"} />,
193
+
Pins: <LycanTab query={query} type={"pins"} key={"pins"} />,
194
+
}}
195
+
/>
196
+
</div>
197
+
);
198
+
}
199
+
200
+
function LycanTab({
201
+
query,
202
+
type,
203
+
}: {
204
+
query: string;
205
+
type: "likes" | "pins" | "reposts" | "quotes";
206
+
}) {
207
+
useReusableTabScrollRestore("search" + query);
208
+
209
+
const {
210
+
data: postsData,
211
+
fetchNextPage,
212
+
hasNextPage,
213
+
isFetchingNextPage,
214
+
isLoading: arePostsLoading,
215
+
} = useInfiniteQueryLycanSearch({ query: query, type: type });
216
+
217
+
const posts = useMemo(
218
+
() =>
219
+
postsData?.pages.flatMap((page) => {
220
+
if (page) {
221
+
return page.posts;
222
+
} else {
223
+
return [];
224
+
}
225
+
}) ?? [],
226
+
[postsData]
227
+
);
228
+
229
+
return (
230
+
<>
231
+
{/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
232
+
Posts
233
+
</div> */}
234
+
<div>
235
+
{posts.map((post) => (
236
+
<UniversalPostRendererATURILoader
237
+
key={post}
238
+
atUri={post}
239
+
feedviewpost={true}
240
+
/>
241
+
))}
242
+
</div>
243
+
244
+
{/* Loading and "Load More" states */}
245
+
{arePostsLoading && posts.length === 0 && (
246
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
247
+
)}
248
+
{isFetchingNextPage && (
249
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
250
+
)}
251
+
{hasNextPage && !isFetchingNextPage && (
252
+
<button
253
+
onClick={() => fetchNextPage()}
254
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
255
+
>
256
+
Load More Posts
257
+
</button>
258
+
)}
259
+
{posts.length === 0 && !arePostsLoading && (
260
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
261
+
)}
262
+
</>
263
+
);
264
+
265
+
return <></>;
266
+
}
+188
-17
src/routes/settings.tsx
+188
-17
src/routes/settings.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
2
-
import { useAtom } from "jotai";
3
-
import { Slider } from "radix-ui";
1
+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
2
+
import { useAtom, useAtomValue, useSetAtom } from "jotai";
3
+
import { Slider, Switch } from "radix-ui";
4
+
import { useEffect, useState } from "react";
4
5
5
6
import { Header } from "~/components/Header";
6
7
import Login from "~/components/Login";
···
9
10
defaultconstellationURL,
10
11
defaulthue,
11
12
defaultImgCDN,
13
+
defaultLycanURL,
12
14
defaultslingshotURL,
13
15
defaultVideoCDN,
16
+
enableBitesAtom,
17
+
enableBridgyTextAtom,
18
+
enableWafrnTextAtom,
14
19
hueAtom,
15
20
imgCDNAtom,
21
+
lycanURLAtom,
16
22
slingshotURLAtom,
17
23
videoCDNAtom,
18
24
} from "~/utils/atoms";
19
25
26
+
import { MaterialNavItem } from "./__root";
27
+
20
28
export const Route = createFileRoute("/settings")({
21
29
component: Settings,
22
30
});
23
31
24
32
export function Settings() {
33
+
const navigate = useNavigate();
25
34
return (
26
35
<>
27
36
<Header
···
37
46
<div className="lg:hidden">
38
47
<Login />
39
48
</div>
49
+
<div className="sm:hidden flex flex-col justify-around mt-4">
50
+
<SettingHeading title="Other Pages" top />
51
+
<MaterialNavItem
52
+
InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
53
+
ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
54
+
active={false}
55
+
onClickCallbback={() =>
56
+
navigate({
57
+
to: "/feeds",
58
+
//params: { did: agent.assertDid },
59
+
})
60
+
}
61
+
text="Feeds"
62
+
/>
63
+
<MaterialNavItem
64
+
InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />}
65
+
ActiveIcon={<IconMdiShield className="w-6 h-6" />}
66
+
active={false}
67
+
onClickCallbback={() =>
68
+
navigate({
69
+
to: "/moderation",
70
+
//params: { did: agent.assertDid },
71
+
})
72
+
}
73
+
text="Moderation"
74
+
/>
75
+
</div>
40
76
<div className="h-4" />
77
+
78
+
<SettingHeading title="Personalization" top />
79
+
<Hue />
80
+
81
+
<SettingHeading title="Network Configuration" />
82
+
<div className="flex flex-col px-4 pb-2">
83
+
<span className="text-md">Service Endpoints</span>
84
+
<span className="text-sm text-gray-500 dark:text-gray-400">
85
+
Customize the servers to be used by the app
86
+
</span>
87
+
</div>
41
88
<TextInputSetting
42
89
atom={constellationURLAtom}
43
90
title={"Constellation"}
···
66
113
description={"Customize the Slingshot instance to be used by Red Dwarf"}
67
114
init={defaultVideoCDN}
68
115
/>
116
+
<TextInputSetting
117
+
atom={lycanURLAtom}
118
+
title={"Lycan Search"}
119
+
description={"Enable text search across posts you've interacted with"}
120
+
init={defaultLycanURL}
121
+
/>
69
122
70
-
<Hue />
71
-
<p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm">
72
-
please restart/refresh the app if changes arent applying correctly
123
+
<SettingHeading title="Experimental" />
124
+
<SwitchSetting
125
+
atom={enableBitesAtom}
126
+
title={"Bites"}
127
+
description={"Enable Wafrn Bites to bite and be bitten by other people"}
128
+
//init={false}
129
+
/>
130
+
<div className="h-4" />
131
+
<SwitchSetting
132
+
atom={enableBridgyTextAtom}
133
+
title={"Bridgy Text"}
134
+
description={
135
+
"Show the original text of posts bridged from the Fediverse"
136
+
}
137
+
//init={false}
138
+
/>
139
+
<div className="h-4" />
140
+
<SwitchSetting
141
+
atom={enableWafrnTextAtom}
142
+
title={"Wafrn Text"}
143
+
description={"Show the original text of posts from Wafrn instances"}
144
+
//init={false}
145
+
/>
146
+
<p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4">
147
+
Notice: Please restart/refresh the app if changes arent applying
148
+
correctly
73
149
</p>
74
150
</>
75
151
);
76
152
}
153
+
154
+
export function SettingHeading({
155
+
title,
156
+
top,
157
+
}: {
158
+
title: string;
159
+
top?: boolean;
160
+
}) {
161
+
return (
162
+
<div
163
+
className="px-4"
164
+
style={{ marginTop: top ? 0 : 18, paddingBottom: 12 }}
165
+
>
166
+
<span className=" text-sm font-medium text-gray-500 dark:text-gray-400">
167
+
{title}
168
+
</span>
169
+
</div>
170
+
);
171
+
}
172
+
173
+
export function SwitchSetting({
174
+
atom,
175
+
title,
176
+
description,
177
+
}: {
178
+
atom: typeof enableBitesAtom;
179
+
title?: string;
180
+
description?: string;
181
+
}) {
182
+
const value = useAtomValue(atom);
183
+
const setValue = useSetAtom(atom);
184
+
185
+
const [hydrated, setHydrated] = useState(false);
186
+
// eslint-disable-next-line react-hooks/set-state-in-effect
187
+
useEffect(() => setHydrated(true), []);
188
+
189
+
if (!hydrated) {
190
+
// Avoid rendering Switch until we know storage is loaded
191
+
return null;
192
+
}
193
+
194
+
return (
195
+
<div className="flex items-center gap-4 px-4 ">
196
+
<label htmlFor={`switch-${title}`} className="flex flex-row flex-1">
197
+
<div className="flex flex-col">
198
+
<span className="text-md">{title}</span>
199
+
<span className="text-sm text-gray-500 dark:text-gray-400">
200
+
{description}
201
+
</span>
202
+
</div>
203
+
</label>
204
+
205
+
<Switch.Root
206
+
id={`switch-${title}`}
207
+
checked={value}
208
+
onCheckedChange={(v) => setValue(v)}
209
+
className="m3switch root"
210
+
>
211
+
<Switch.Thumb className="m3switch thumb " />
212
+
</Switch.Root>
213
+
</div>
214
+
);
215
+
}
216
+
77
217
function Hue() {
78
218
const [hue, setHue] = useAtom(hueAtom);
79
219
return (
80
-
<div className="flex flex-col px-4 mt-4 ">
81
-
<span className="z-10">Hue</span>
82
-
<div className="flex flex-row items-center gap-4">
83
-
<SliderComponent
84
-
atom={hueAtom}
85
-
max={360}
86
-
/>
220
+
<div className="flex flex-col px-4">
221
+
<span className="z-[2] text-md">Hue</span>
222
+
<span className="z-[2] text-sm text-gray-500 dark:text-gray-400">
223
+
Change the colors of the app
224
+
</span>
225
+
<div className="z-[1] flex flex-row items-center gap-4">
226
+
<SliderComponent atom={hueAtom} max={360} />
87
227
<button
88
228
onClick={() => setHue(defaulthue ?? 28)}
89
229
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
···
154
294
);
155
295
}
156
296
157
-
158
297
interface SliderProps {
159
298
atom: typeof hueAtom;
160
299
min?: number;
···
168
307
max = 100,
169
308
step = 1,
170
309
}) => {
171
-
172
-
const [value, setValue] = useAtom(atom)
310
+
const [value, setValue] = useAtom(atom);
173
311
174
312
return (
175
313
<Slider.Root
···
186
324
<Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" />
187
325
</Slider.Root>
188
326
);
189
-
};
327
+
};
328
+
329
+
330
+
interface SliderPProps {
331
+
value: number;
332
+
min?: number;
333
+
max?: number;
334
+
step?: number;
335
+
}
336
+
337
+
338
+
export const SliderPrimitive: React.FC<SliderPProps> = ({
339
+
value,
340
+
min = 0,
341
+
max = 100,
342
+
step = 1,
343
+
}) => {
344
+
345
+
return (
346
+
<Slider.Root
347
+
className="relative flex items-center w-full h-4"
348
+
value={[value]}
349
+
min={min}
350
+
max={max}
351
+
step={step}
352
+
onValueChange={(v: number[]) => {}}
353
+
>
354
+
<Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full">
355
+
<Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" />
356
+
</Slider.Track>
357
+
<Slider.Thumb className=" hidden 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" />
358
+
</Slider.Root>
359
+
);
360
+
};
+144
-1
src/styles/app.css
+144
-1
src/styles/app.css
···
33
33
--color-gray-950: oklch(0.129 0.055 var(--safe-hue));
34
34
}
35
35
36
+
:root {
37
+
--link-text-color: oklch(0.5962 0.1987 var(--safe-hue));
38
+
/* max chroma!!! use fallback*/
39
+
/*--link-text-color: oklch(0.6 0.37 var(--safe-hue));*/
40
+
}
41
+
36
42
@layer base {
37
43
html {
38
44
color-scheme: light dark;
···
84
90
.dangerousFediContent {
85
91
& a[href]{
86
92
text-decoration: none;
87
-
color: rgb(29, 122, 242);
93
+
color: var(--link-text-color);
88
94
word-break: break-all;
89
95
}
90
96
}
···
197
203
198
204
/* focus ring */
199
205
.m3input-field.m3input-label.m3input-border input:focus {
206
+
/*border-color: var(--m3input-focus-color);*/
200
207
border-color: var(--m3input-focus-color);
208
+
box-shadow: 0 0 0 1px var(--m3input-focus-color);
201
209
/*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/
202
210
}
203
211
···
231
239
/* radix i love you but like cmon man */
232
240
body[data-scroll-locked]{
233
241
margin-left: var(--removed-body-scroll-bar-size) !important;
242
+
}
243
+
244
+
/* radix tabs */
245
+
246
+
.m3tab[data-radix-collection-item] {
247
+
flex: 1;
248
+
display: flex;
249
+
padding: 12px 8px;
250
+
align-items: center;
251
+
justify-content: center;
252
+
color: var(--color-gray-500);
253
+
font-weight: 500;
254
+
&:hover {
255
+
background-color: var(--color-gray-100);
256
+
cursor: pointer;
257
+
}
258
+
&[aria-selected="true"] {
259
+
color: var(--color-gray-950);
260
+
&::before{
261
+
content: "";
262
+
position: absolute;
263
+
width: min(80px, 80%);
264
+
border-radius: 99px 99px 0px 0px ;
265
+
height: 3px;
266
+
bottom: 0;
267
+
background-color: var(--color-gray-400);
268
+
}
269
+
}
270
+
}
271
+
272
+
@media (prefers-color-scheme: dark) {
273
+
.m3tab[data-radix-collection-item] {
274
+
color: var(--color-gray-400);
275
+
&:hover {
276
+
background-color: var(--color-gray-900);
277
+
cursor: pointer;
278
+
}
279
+
&[aria-selected="true"] {
280
+
color: var(--color-gray-50);
281
+
&::before{
282
+
background-color: var(--color-gray-500);
283
+
}
284
+
}
285
+
}
286
+
}
287
+
288
+
:root{
289
+
--thumb-size: 2rem;
290
+
--root-size: 3.25rem;
291
+
292
+
--switch-off-border: var(--color-gray-400);
293
+
--switch-off-bg: var(--color-gray-200);
294
+
--switch-off-thumb: var(--color-gray-400);
295
+
296
+
297
+
--switch-on-bg: var(--color-gray-500);
298
+
--switch-on-thumb: var(--color-gray-50);
299
+
300
+
}
301
+
@media (prefers-color-scheme: dark) {
302
+
:root {
303
+
--switch-off-border: var(--color-gray-500);
304
+
--switch-off-bg: var(--color-gray-800);
305
+
--switch-off-thumb: var(--color-gray-500);
306
+
307
+
308
+
--switch-on-bg: var(--color-gray-400);
309
+
--switch-on-thumb: var(--color-gray-700);
310
+
}
311
+
}
312
+
313
+
.m3switch.root{
314
+
/*w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-gray-500 transition-colors*/
315
+
/*width: 40px;
316
+
height: 24px;*/
317
+
318
+
inline-size: var(--root-size);
319
+
block-size: 2rem;
320
+
border-radius: 99999px;
321
+
322
+
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
323
+
transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */
324
+
transition-duration: var(--default-transition-duration); /* 150ms */
325
+
326
+
.m3switch.thumb{
327
+
/*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/
328
+
329
+
height: var(--thumb-size);
330
+
width: var(--thumb-size);
331
+
display: inline-block;
332
+
border-radius: 9999px;
333
+
334
+
transform-origin: center;
335
+
336
+
transition-property: transform, translate, scale, rotate;
337
+
transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */
338
+
transition-duration: var(--default-transition-duration); /* 150ms */
339
+
340
+
}
341
+
342
+
&[aria-checked="true"] {
343
+
box-shadow: inset 0px 0px 0px 1.8px transparent;
344
+
background-color: var(--switch-on-bg);
345
+
346
+
.m3switch.thumb{
347
+
/*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/
348
+
349
+
background-color: var(--switch-on-thumb);
350
+
transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.72);
351
+
&:active {
352
+
transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88);
353
+
}
354
+
355
+
}
356
+
&:active .m3switch.thumb{
357
+
transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88);
358
+
}
359
+
}
360
+
361
+
&[aria-checked="false"] {
362
+
box-shadow: inset 0px 0px 0px 1.8px var(--switch-off-border);
363
+
background-color: var(--switch-off-bg);
364
+
.m3switch.thumb{
365
+
/*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/
366
+
367
+
background-color: var(--switch-off-thumb);
368
+
transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.5);
369
+
&:active {
370
+
transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88);
371
+
}
372
+
}
373
+
&:active .m3switch.thumb{
374
+
transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88);
375
+
}
376
+
}
234
377
}
+73
src/utils/atoms.ts
+73
src/utils/atoms.ts
···
2
2
import { atomWithStorage } from "jotai/utils";
3
3
import { useEffect } from "react";
4
4
5
+
import { type ProfilePostsFilter } from "~/routes/profile.$did";
6
+
5
7
export const store = createStore();
6
8
7
9
export const quickAuthAtom = atomWithStorage<string | null>(
···
21
23
{}
22
24
);
23
25
26
+
type TabRouteScrollState = {
27
+
activeTab: string;
28
+
scrollPositions: Record<string, number>;
29
+
};
30
+
/**
31
+
* @deprecated should be safe to remove i think
32
+
*/
33
+
export const notificationsScrollAtom = atom<TabRouteScrollState>({
34
+
activeTab: "mentions",
35
+
scrollPositions: {},
36
+
});
37
+
38
+
export type InteractionFilter = {
39
+
likes: boolean;
40
+
reposts: boolean;
41
+
quotes: boolean;
42
+
replies: boolean;
43
+
showAll: boolean;
44
+
};
45
+
const defaultFilters: InteractionFilter = {
46
+
likes: true,
47
+
reposts: true,
48
+
quotes: true,
49
+
replies: true,
50
+
showAll: false,
51
+
};
52
+
export const postInteractionsFiltersAtom = atomWithStorage<InteractionFilter>(
53
+
"postInteractionsFilters",
54
+
defaultFilters
55
+
);
56
+
57
+
export const reusableTabRouteScrollAtom = atom<Record<string, TabRouteScrollState | undefined> | undefined>({});
58
+
24
59
export const likedPostsAtom = atomWithStorage<Record<string, string>>(
25
60
"likedPosts",
26
61
{}
27
62
);
28
63
64
+
export type LikeRecord = {
65
+
uri: string; // at://did/collection/rkey
66
+
target: string;
67
+
cid: string;
68
+
};
69
+
70
+
export const internalLikedPostsAtom = atomWithStorage<Record<string, LikeRecord | null>>(
71
+
"internal-liked-posts",
72
+
{}
73
+
);
74
+
75
+
export const profileChipsAtom = atom<Record<string, ProfilePostsFilter | null>>({})
76
+
29
77
export const defaultconstellationURL = "constellation.microcosm.blue";
30
78
export const constellationURLAtom = atomWithStorage<string>(
31
79
"constellationURL",
···
42
90
export const videoCDNAtom = atomWithStorage<string>(
43
91
"videocdnurl",
44
92
defaultVideoCDN
93
+
);
94
+
95
+
export const defaultLycanURL = "";
96
+
export const lycanURLAtom = atomWithStorage<string>(
97
+
"lycanURL",
98
+
defaultLycanURL
45
99
);
46
100
47
101
export const defaulthue = 28;
···
80
134
// console.log("atom get ", initial);
81
135
// document.documentElement.style.setProperty(cssVar, initial.toString());
82
136
// }
137
+
138
+
139
+
140
+
// fun stuff
141
+
142
+
export const enableBitesAtom = atomWithStorage<boolean>(
143
+
"enableBitesAtom",
144
+
false
145
+
);
146
+
147
+
export const enableBridgyTextAtom = atomWithStorage<boolean>(
148
+
"enableBridgyTextAtom",
149
+
false
150
+
);
151
+
152
+
export const enableWafrnTextAtom = atomWithStorage<boolean>(
153
+
"enableWafrnTextAtom",
154
+
false
155
+
);
+34
src/utils/likeMutationQueue.ts
+34
src/utils/likeMutationQueue.ts
···
1
+
import { useAtom } from "jotai";
2
+
import { useCallback } from "react";
3
+
4
+
import { type LikeRecord,useLikeMutationQueue as useLikeMutationQueueFromProvider } from "~/providers/LikeMutationQueueProvider";
5
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
+
7
+
import { internalLikedPostsAtom } from "./atoms";
8
+
9
+
export function useFastLike(target: string, cid: string) {
10
+
const { agent } = useAuth();
11
+
const { fastState, fastToggle, backfillState } = useLikeMutationQueueFromProvider();
12
+
13
+
const liked = fastState(target);
14
+
const toggle = () => fastToggle(target, cid);
15
+
/**
16
+
*
17
+
* @deprecated dont use it yet, will cause infinite rerenders
18
+
*/
19
+
const backfill = () => agent?.did && backfillState(target, agent.did);
20
+
21
+
return { liked, toggle, backfill };
22
+
}
23
+
24
+
export function useFastSetLikesFromFeed() {
25
+
const [_, setLikedPosts] = useAtom(internalLikedPostsAtom);
26
+
27
+
const setFastState = useCallback(
28
+
(target: string, record: LikeRecord | null) =>
29
+
setLikedPosts((prev) => ({ ...prev, [target]: record })),
30
+
[setLikedPosts]
31
+
);
32
+
33
+
return { setFastState };
34
+
}
+2
-2
src/utils/oauthClient.ts
+2
-2
src/utils/oauthClient.ts
···
1
1
import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser';
2
2
3
-
// i tried making this https://pds-nd.whey.party but cors is annoying as fuck
4
-
const handleResolverPDS = 'https://bsky.social';
3
+
import resolvers from '../../public/resolvers.json' with { type: 'json' };
4
+
const handleResolverPDS = resolvers.resolver || 'https://bsky.social';
5
5
6
6
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
7
7
// @ts-ignore this should be fine ? the vite plugin should generate this before errors
+414
-163
src/utils/useQuery.ts
+414
-163
src/utils/useQuery.ts
···
5
5
queryOptions,
6
6
useInfiniteQuery,
7
7
useQuery,
8
-
type UseQueryResult} from "@tanstack/react-query";
8
+
type UseQueryResult,
9
+
} from "@tanstack/react-query";
9
10
import { useAtom } from "jotai";
10
11
11
-
import { constellationURLAtom, slingshotURLAtom } from "./atoms";
12
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
12
13
13
-
export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) {
14
+
import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms";
15
+
16
+
export function constructIdentityQuery(
17
+
didorhandle?: string,
18
+
slingshoturl?: string
19
+
) {
14
20
return queryOptions({
15
21
queryKey: ["identity", didorhandle],
16
22
queryFn: async () => {
17
-
if (!didorhandle) return undefined as undefined
23
+
if (!didorhandle) return undefined as undefined;
18
24
const res = await fetch(
19
25
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
20
26
);
···
31
37
}
32
38
},
33
39
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
34
-
gcTime: /*0//*/5 * 60 * 1000,
40
+
gcTime: /*0//*/ 5 * 60 * 1000,
35
41
});
36
42
}
37
43
export function useQueryIdentity(didorhandle: string): UseQueryResult<
···
43
49
},
44
50
Error
45
51
>;
46
-
export function useQueryIdentity(): UseQueryResult<
47
-
undefined,
48
-
Error
49
-
>
50
-
export function useQueryIdentity(didorhandle?: string):
51
-
UseQueryResult<
52
-
{
53
-
did: string;
54
-
handle: string;
55
-
pds: string;
56
-
signing_key: string;
57
-
} | undefined,
58
-
Error
59
-
>
52
+
export function useQueryIdentity(): UseQueryResult<undefined, Error>;
53
+
export function useQueryIdentity(didorhandle?: string): UseQueryResult<
54
+
| {
55
+
did: string;
56
+
handle: string;
57
+
pds: string;
58
+
signing_key: string;
59
+
}
60
+
| undefined,
61
+
Error
62
+
>;
60
63
export function useQueryIdentity(didorhandle?: string) {
61
-
const [slingshoturl] = useAtom(slingshotURLAtom)
64
+
const [slingshoturl] = useAtom(slingshotURLAtom);
62
65
return useQuery(constructIdentityQuery(didorhandle, slingshoturl));
63
66
}
64
67
···
66
69
return queryOptions({
67
70
queryKey: ["post", uri],
68
71
queryFn: async () => {
69
-
if (!uri) return undefined as undefined
72
+
if (!uri) return undefined as undefined;
70
73
const res = await fetch(
71
74
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
72
75
);
···
77
80
return undefined;
78
81
}
79
82
if (res.status === 400) return undefined;
80
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
83
+
if (
84
+
data?.error === "InvalidRequest" &&
85
+
data.message?.includes("Could not find repo")
86
+
) {
81
87
return undefined; // cache โnot foundโ
82
88
}
83
89
try {
84
90
if (!res.ok) throw new Error("Failed to fetch post");
85
-
return (data) as {
91
+
return data as {
86
92
uri: string;
87
93
cid: string;
88
94
value: any;
···
97
103
return failureCount < 2;
98
104
},
99
105
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
100
-
gcTime: /*0//*/5 * 60 * 1000,
106
+
gcTime: /*0//*/ 5 * 60 * 1000,
101
107
});
102
108
}
103
109
export function useQueryPost(uri: string): UseQueryResult<
···
108
114
},
109
115
Error
110
116
>;
111
-
export function useQueryPost(): UseQueryResult<
112
-
undefined,
113
-
Error
114
-
>
115
-
export function useQueryPost(uri?: string):
116
-
UseQueryResult<
117
-
{
118
-
uri: string;
119
-
cid: string;
120
-
value: ATPAPI.AppBskyFeedPost.Record;
121
-
} | undefined,
122
-
Error
123
-
>
117
+
export function useQueryPost(): UseQueryResult<undefined, Error>;
118
+
export function useQueryPost(uri?: string): UseQueryResult<
119
+
| {
120
+
uri: string;
121
+
cid: string;
122
+
value: ATPAPI.AppBskyFeedPost.Record;
123
+
}
124
+
| undefined,
125
+
Error
126
+
>;
124
127
export function useQueryPost(uri?: string) {
125
-
const [slingshoturl] = useAtom(slingshotURLAtom)
128
+
const [slingshoturl] = useAtom(slingshotURLAtom);
126
129
return useQuery(constructPostQuery(uri, slingshoturl));
127
130
}
128
131
···
130
133
return queryOptions({
131
134
queryKey: ["profile", uri],
132
135
queryFn: async () => {
133
-
if (!uri) return undefined as undefined
136
+
if (!uri) return undefined as undefined;
134
137
const res = await fetch(
135
138
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
136
139
);
···
141
144
return undefined;
142
145
}
143
146
if (res.status === 400) return undefined;
144
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
147
+
if (
148
+
data?.error === "InvalidRequest" &&
149
+
data.message?.includes("Could not find repo")
150
+
) {
145
151
return undefined; // cache โnot foundโ
146
152
}
147
153
try {
148
154
if (!res.ok) throw new Error("Failed to fetch post");
149
-
return (data) as {
155
+
return data as {
150
156
uri: string;
151
157
cid: string;
152
158
value: any;
···
161
167
return failureCount < 2;
162
168
},
163
169
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
164
-
gcTime: /*0//*/5 * 60 * 1000,
170
+
gcTime: /*0//*/ 5 * 60 * 1000,
165
171
});
166
172
}
167
173
export function useQueryProfile(uri: string): UseQueryResult<
···
172
178
},
173
179
Error
174
180
>;
175
-
export function useQueryProfile(): UseQueryResult<
176
-
undefined,
177
-
Error
178
-
>;
179
-
export function useQueryProfile(uri?: string):
180
-
UseQueryResult<
181
-
{
181
+
export function useQueryProfile(): UseQueryResult<undefined, Error>;
182
+
export function useQueryProfile(uri?: string): UseQueryResult<
183
+
| {
182
184
uri: string;
183
185
cid: string;
184
186
value: ATPAPI.AppBskyActorProfile.Record;
185
-
} | undefined,
186
-
Error
187
-
>
187
+
}
188
+
| undefined,
189
+
Error
190
+
>;
188
191
export function useQueryProfile(uri?: string) {
189
-
const [slingshoturl] = useAtom(slingshotURLAtom)
192
+
const [slingshoturl] = useAtom(slingshotURLAtom);
190
193
return useQuery(constructProfileQuery(uri, slingshoturl));
191
194
}
192
195
···
222
225
// method: "/links/all",
223
226
// target: string
224
227
// ): QueryOptions<linksAllResponse, Error>;
225
-
export function constructConstellationQuery(query?:{
226
-
constellation: string,
228
+
export function constructConstellationQuery(query?: {
229
+
constellation: string;
227
230
method:
228
231
| "/links"
229
232
| "/links/distinct-dids"
230
233
| "/links/count"
231
234
| "/links/count/distinct-dids"
232
235
| "/links/all"
233
-
| "undefined",
234
-
target: string,
235
-
collection?: string,
236
-
path?: string,
237
-
cursor?: string,
238
-
dids?: string[]
239
-
}
240
-
) {
236
+
| "undefined";
237
+
target: string;
238
+
collection?: string;
239
+
path?: string;
240
+
cursor?: string;
241
+
dids?: string[];
242
+
}) {
241
243
// : QueryOptions<
242
244
// | linksRecordsResponse
243
245
// | linksDidsResponse
···
247
249
// Error
248
250
// >
249
251
return queryOptions({
250
-
queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const,
252
+
queryKey: [
253
+
"constellation",
254
+
query?.method,
255
+
query?.target,
256
+
query?.collection,
257
+
query?.path,
258
+
query?.cursor,
259
+
query?.dids,
260
+
] as const,
251
261
queryFn: async () => {
252
-
if (!query || query.method === "undefined") return undefined as undefined
253
-
const method = query.method
254
-
const target = query.target
255
-
const collection = query?.collection
256
-
const path = query?.path
257
-
const cursor = query.cursor
258
-
const dids = query?.dids
262
+
if (!query || query.method === "undefined") return undefined as undefined;
263
+
const method = query.method;
264
+
const target = query.target;
265
+
const collection = query?.collection;
266
+
const path = query?.path;
267
+
const cursor = query.cursor;
268
+
const dids = query?.dids;
259
269
const res = await fetch(
260
270
`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
271
);
···
281
291
},
282
292
// enforce short lifespan
283
293
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
284
-
gcTime: /*0//*/5 * 60 * 1000,
294
+
gcTime: /*0//*/ 5 * 60 * 1000,
285
295
});
286
296
}
297
+
// todo do more of these instead of overloads since overloads sucks so much apparently
298
+
export function useQueryConstellationLinksCountDistinctDids(query?: {
299
+
method: "/links/count/distinct-dids";
300
+
target: string;
301
+
collection: string;
302
+
path: string;
303
+
cursor?: string;
304
+
}): UseQueryResult<linksCountResponse, Error> | undefined {
305
+
//if (!query) return;
306
+
const [constellationurl] = useAtom(constellationURLAtom);
307
+
const queryres = useQuery(
308
+
constructConstellationQuery(
309
+
query && { constellation: constellationurl, ...query }
310
+
)
311
+
) as unknown as UseQueryResult<linksCountResponse, Error>;
312
+
if (!query) {
313
+
return undefined as undefined;
314
+
}
315
+
return queryres as UseQueryResult<linksCountResponse, Error>;
316
+
}
317
+
287
318
export function useQueryConstellation(query: {
288
319
method: "/links";
289
320
target: string;
···
346
377
>
347
378
| undefined {
348
379
//if (!query) return;
349
-
const [constellationurl] = useAtom(constellationURLAtom)
380
+
const [constellationurl] = useAtom(constellationURLAtom);
350
381
return useQuery(
351
-
constructConstellationQuery(query && {constellation: constellationurl, ...query})
382
+
constructConstellationQuery(
383
+
query && { constellation: constellationurl, ...query }
384
+
)
352
385
);
353
386
}
354
387
355
-
type linksRecord = {
388
+
export type linksRecord = {
356
389
did: string;
357
390
collection: string;
358
391
rkey: string;
···
392
425
}) {
393
426
return queryOptions({
394
427
// The query key includes all dependencies to ensure it refetches when they change
395
-
queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }],
428
+
queryKey: [
429
+
"feedSkeleton",
430
+
options?.feedUri,
431
+
{ isAuthed: options?.isAuthed, did: options?.agent?.did },
432
+
],
396
433
queryFn: async () => {
397
-
if (!options) return undefined as undefined
434
+
if (!options) return undefined as undefined;
398
435
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
399
436
if (isAuthed) {
400
437
// Authenticated flow
401
438
if (!agent || !pdsUrl || !feedServiceDid) {
402
-
throw new Error("Missing required info for authenticated feed fetch.");
439
+
throw new Error(
440
+
"Missing required info for authenticated feed fetch."
441
+
);
403
442
}
404
443
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
405
444
const res = await agent.fetchHandler(url, {
···
409
448
"Content-Type": "application/json",
410
449
},
411
450
});
412
-
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
451
+
if (!res.ok)
452
+
throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
413
453
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
414
454
} else {
415
455
// Unauthenticated flow (using a public PDS/AppView)
416
456
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
417
457
const res = await fetch(url);
418
-
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
458
+
if (!res.ok)
459
+
throw new Error(`Public feed fetch failed: ${res.statusText}`);
419
460
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
420
461
}
421
462
},
···
433
474
return useQuery(constructFeedSkeletonQuery(options));
434
475
}
435
476
436
-
export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) {
477
+
export function constructPreferencesQuery(
478
+
agent?: ATPAPI.Agent | undefined,
479
+
pdsUrl?: string | undefined
480
+
) {
437
481
return queryOptions({
438
-
queryKey: ['preferences', agent?.did],
482
+
queryKey: ["preferences", agent?.did],
439
483
queryFn: async () => {
440
484
if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available");
441
485
const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
···
446
490
});
447
491
}
448
492
export function useQueryPreferences(options: {
449
-
agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined
493
+
agent?: ATPAPI.Agent | undefined;
494
+
pdsUrl?: string | undefined;
450
495
}) {
451
496
return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl));
452
497
}
453
-
454
-
455
498
456
499
export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
457
500
return queryOptions({
458
501
queryKey: ["arbitrary", uri],
459
502
queryFn: async () => {
460
-
if (!uri) return undefined as undefined
503
+
if (!uri) return undefined as undefined;
461
504
const res = await fetch(
462
505
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
463
506
);
···
468
511
return undefined;
469
512
}
470
513
if (res.status === 400) return undefined;
471
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
514
+
if (
515
+
data?.error === "InvalidRequest" &&
516
+
data.message?.includes("Could not find repo")
517
+
) {
472
518
return undefined; // cache โnot foundโ
473
519
}
474
520
try {
475
521
if (!res.ok) throw new Error("Failed to fetch post");
476
-
return (data) as {
522
+
return data as {
477
523
uri: string;
478
524
cid: string;
479
525
value: any;
···
488
534
return failureCount < 2;
489
535
},
490
536
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
491
-
gcTime: /*0//*/5 * 60 * 1000,
537
+
gcTime: /*0//*/ 5 * 60 * 1000,
492
538
});
493
539
}
494
540
export function useQueryArbitrary(uri: string): UseQueryResult<
···
499
545
},
500
546
Error
501
547
>;
502
-
export function useQueryArbitrary(): UseQueryResult<
503
-
undefined,
504
-
Error
505
-
>;
548
+
export function useQueryArbitrary(): UseQueryResult<undefined, Error>;
506
549
export function useQueryArbitrary(uri?: string): UseQueryResult<
507
-
{
508
-
uri: string;
509
-
cid: string;
510
-
value: any;
511
-
} | undefined,
550
+
| {
551
+
uri: string;
552
+
cid: string;
553
+
value: any;
554
+
}
555
+
| undefined,
512
556
Error
513
557
>;
514
558
export function useQueryArbitrary(uri?: string) {
515
-
const [slingshoturl] = useAtom(slingshotURLAtom)
559
+
const [slingshoturl] = useAtom(slingshotURLAtom);
516
560
return useQuery(constructArbitraryQuery(uri, slingshoturl));
517
561
}
518
562
519
-
export function constructFallbackNothingQuery(){
563
+
export function constructFallbackNothingQuery() {
520
564
return queryOptions({
521
565
queryKey: ["nothing"],
522
566
queryFn: async () => {
523
-
return undefined
567
+
return undefined;
524
568
},
525
569
});
526
570
}
···
534
578
}[];
535
579
};
536
580
537
-
export function constructAuthorFeedQuery(did: string, pdsUrl: string) {
581
+
export function constructAuthorFeedQuery(
582
+
did: string,
583
+
pdsUrl: string,
584
+
collection: string = "app.bsky.feed.post"
585
+
) {
538
586
return queryOptions({
539
-
queryKey: ['authorFeed', did],
587
+
queryKey: ["authorFeed", did, collection],
540
588
queryFn: async ({ pageParam }: QueryFunctionContext) => {
541
589
const limit = 25;
542
-
590
+
543
591
const cursor = pageParam as string | undefined;
544
-
const cursorParam = cursor ? `&cursor=${cursor}` : '';
545
-
546
-
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`;
547
-
592
+
const cursorParam = cursor ? `&cursor=${cursor}` : "";
593
+
594
+
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`;
595
+
548
596
const res = await fetch(url);
549
597
if (!res.ok) throw new Error("Failed to fetch author's posts");
550
-
598
+
551
599
return res.json() as Promise<ListRecordsResponse>;
552
600
},
553
601
});
554
602
}
555
603
556
-
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) {
557
-
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!);
558
-
604
+
export function useInfiniteQueryAuthorFeed(
605
+
did: string | undefined,
606
+
pdsUrl: string | undefined,
607
+
collection?: string
608
+
) {
609
+
const { queryKey, queryFn } = constructAuthorFeedQuery(
610
+
did!,
611
+
pdsUrl!,
612
+
collection
613
+
);
614
+
559
615
return useInfiniteQuery({
560
616
queryKey,
561
617
queryFn,
···
573
629
isAuthed: boolean;
574
630
pdsUrl?: string;
575
631
feedServiceDid?: string;
632
+
// todo the hell is a unauthedfeedurl
633
+
unauthedfeedurl?: string;
576
634
}) {
577
-
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
578
-
635
+
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } =
636
+
options;
637
+
579
638
return queryOptions({
580
639
queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
581
-
582
-
queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
640
+
641
+
queryFn: async ({
642
+
pageParam,
643
+
}: QueryFunctionContext): Promise<FeedSkeletonPage> => {
583
644
const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
584
-
585
-
if (isAuthed) {
645
+
646
+
if (isAuthed && !unauthedfeedurl) {
586
647
if (!agent || !pdsUrl || !feedServiceDid) {
587
-
throw new Error("Missing required info for authenticated feed fetch.");
648
+
throw new Error(
649
+
"Missing required info for authenticated feed fetch."
650
+
);
588
651
}
589
652
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
590
653
const res = await agent.fetchHandler(url, {
···
594
657
"Content-Type": "application/json",
595
658
},
596
659
});
597
-
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
660
+
if (!res.ok)
661
+
throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
598
662
return (await res.json()) as FeedSkeletonPage;
599
663
} else {
600
-
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
664
+
const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
601
665
const res = await fetch(url);
602
-
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
666
+
if (!res.ok)
667
+
throw new Error(`Public feed fetch failed: ${res.statusText}`);
603
668
return (await res.json()) as FeedSkeletonPage;
604
669
}
605
670
},
···
612
677
isAuthed: boolean;
613
678
pdsUrl?: string;
614
679
feedServiceDid?: string;
680
+
unauthedfeedurl?: string;
615
681
}) {
616
682
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
617
-
618
-
return {...useInfiniteQuery({
619
-
queryKey,
620
-
queryFn,
621
-
initialPageParam: undefined as never,
622
-
getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
623
-
staleTime: Infinity,
624
-
refetchOnWindowFocus: false,
625
-
enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
626
-
}), queryKey: queryKey};
627
-
}
628
683
684
+
return {
685
+
...useInfiniteQuery({
686
+
queryKey,
687
+
queryFn,
688
+
initialPageParam: undefined as never,
689
+
getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
690
+
staleTime: Infinity,
691
+
refetchOnWindowFocus: false,
692
+
enabled:
693
+
!!options.feedUri &&
694
+
(options.isAuthed
695
+
? ((!!options.agent && !!options.pdsUrl) ||
696
+
!!options.unauthedfeedurl) &&
697
+
!!options.feedServiceDid
698
+
: true),
699
+
}),
700
+
queryKey: queryKey,
701
+
};
702
+
}
629
703
630
704
export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
631
-
constellation: string,
632
-
method: '/links'
633
-
target?: string
634
-
collection: string
635
-
path: string
705
+
constellation: string;
706
+
method: "/links";
707
+
target?: string;
708
+
collection: string;
709
+
path: string;
710
+
staleMult?: number;
636
711
}) {
637
-
console.log(
638
-
'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
639
-
query,
640
-
)
712
+
const safemult = query?.staleMult ?? 1;
713
+
// console.log(
714
+
// 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
715
+
// query,
716
+
// )
641
717
642
718
return infiniteQueryOptions({
643
719
enabled: !!query?.target,
644
720
queryKey: [
645
-
'reddwarf_constellation',
721
+
"reddwarf_constellation",
646
722
query?.method,
647
723
query?.target,
648
724
query?.collection,
649
725
query?.path,
650
726
] as const,
651
727
652
-
queryFn: async ({pageParam}: {pageParam?: string}) => {
653
-
if (!query || !query?.target) return undefined
728
+
queryFn: async ({ pageParam }: { pageParam?: string }) => {
729
+
if (!query || !query?.target) return undefined;
654
730
655
-
const method = query.method
656
-
const target = query.target
657
-
const collection = query.collection
658
-
const path = query.path
659
-
const cursor = pageParam
731
+
const method = query.method;
732
+
const target = query.target;
733
+
const collection = query.collection;
734
+
const path = query.path;
735
+
const cursor = pageParam;
660
736
661
737
const res = await fetch(
662
738
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
663
-
collection ? `&collection=${encodeURIComponent(collection)}` : ''
664
-
}${path ? `&path=${encodeURIComponent(path)}` : ''}${
665
-
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
666
-
}`,
667
-
)
739
+
collection ? `&collection=${encodeURIComponent(collection)}` : ""
740
+
}${path ? `&path=${encodeURIComponent(path)}` : ""}${
741
+
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""
742
+
}`
743
+
);
668
744
669
-
if (!res.ok) throw new Error('Failed to fetch')
745
+
if (!res.ok) throw new Error("Failed to fetch");
670
746
671
-
return (await res.json()) as linksRecordsResponse
747
+
return (await res.json()) as linksRecordsResponse;
672
748
},
673
749
674
-
getNextPageParam: lastPage => {
675
-
return (lastPage as any)?.cursor ?? undefined
750
+
getNextPageParam: (lastPage) => {
751
+
return (lastPage as any)?.cursor ?? undefined;
676
752
},
677
753
initialPageParam: undefined,
678
-
staleTime: 5 * 60 * 1000,
679
-
gcTime: 5 * 60 * 1000,
680
-
})
681
-
}
754
+
staleTime: 5 * 60 * 1000 * safemult,
755
+
gcTime: 5 * 60 * 1000 * safemult,
756
+
});
757
+
}
758
+
759
+
export function useQueryLycanStatus() {
760
+
const [lycanurl] = useAtom(lycanURLAtom);
761
+
const { agent, status } = useAuth();
762
+
const { data: identity } = useQueryIdentity(agent?.did);
763
+
return useQuery(
764
+
constructLycanStatusCheckQuery({
765
+
agent: agent || undefined,
766
+
isAuthed: status === "signedIn",
767
+
pdsUrl: identity?.pds,
768
+
feedServiceDid: "did:web:"+lycanurl,
769
+
})
770
+
);
771
+
}
772
+
773
+
export function constructLycanStatusCheckQuery(options: {
774
+
agent?: ATPAPI.Agent;
775
+
isAuthed: boolean;
776
+
pdsUrl?: string;
777
+
feedServiceDid?: string;
778
+
}) {
779
+
const { agent, isAuthed, pdsUrl, feedServiceDid } = options;
780
+
781
+
return queryOptions({
782
+
queryKey: ["lycanStatus", { isAuthed, did: agent?.did }],
783
+
784
+
queryFn: async () => {
785
+
if (isAuthed && agent && pdsUrl && feedServiceDid) {
786
+
const url = `${pdsUrl}/xrpc/blue.feeds.lycan.getImportStatus`;
787
+
const res = await agent.fetchHandler(url, {
788
+
method: "GET",
789
+
headers: {
790
+
"atproto-proxy": `${feedServiceDid}#lycan`,
791
+
"Content-Type": "application/json",
792
+
},
793
+
});
794
+
if (!res.ok)
795
+
throw new Error(
796
+
`Authenticated lycan status fetch failed: ${res.statusText}`
797
+
);
798
+
return (await res.json()) as statuschek;
799
+
}
800
+
return undefined;
801
+
},
802
+
});
803
+
}
804
+
805
+
type statuschek = {
806
+
[key: string]: unknown;
807
+
error?: "MethodNotImplemented";
808
+
message?: "Method Not Implemented";
809
+
status?: "finished" | "in_progress";
810
+
position?: string,
811
+
progress?: number,
812
+
813
+
};
814
+
815
+
//{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268}
816
+
type importtype = {
817
+
message?: "Import has already started" | "Import has been scheduled"
818
+
}
819
+
820
+
export function constructLycanRequestIndexQuery(options: {
821
+
agent?: ATPAPI.Agent;
822
+
isAuthed: boolean;
823
+
pdsUrl?: string;
824
+
feedServiceDid?: string;
825
+
}) {
826
+
const { agent, isAuthed, pdsUrl, feedServiceDid } = options;
827
+
828
+
return queryOptions({
829
+
queryKey: ["lycanIndex", { isAuthed, did: agent?.did }],
830
+
831
+
queryFn: async () => {
832
+
if (isAuthed && agent && pdsUrl && feedServiceDid) {
833
+
const url = `${pdsUrl}/xrpc/blue.feeds.lycan.startImport`;
834
+
const res = await agent.fetchHandler(url, {
835
+
method: "POST",
836
+
headers: {
837
+
"atproto-proxy": `${feedServiceDid}#lycan`,
838
+
"Content-Type": "application/json",
839
+
},
840
+
});
841
+
if (!res.ok)
842
+
throw new Error(
843
+
`Authenticated lycan status fetch failed: ${res.statusText}`
844
+
);
845
+
return await res.json() as importtype;
846
+
}
847
+
return undefined;
848
+
},
849
+
});
850
+
}
851
+
852
+
type LycanSearchPage = {
853
+
terms: string[];
854
+
posts: string[];
855
+
cursor?: string;
856
+
};
857
+
858
+
859
+
export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) {
860
+
861
+
862
+
const [lycanurl] = useAtom(lycanURLAtom);
863
+
const { agent, status } = useAuth();
864
+
const { data: identity } = useQueryIdentity(agent?.did);
865
+
866
+
const { queryKey, queryFn } = constructLycanSearchQuery({
867
+
agent: agent || undefined,
868
+
isAuthed: status === "signedIn",
869
+
pdsUrl: identity?.pds,
870
+
feedServiceDid: "did:web:"+lycanurl,
871
+
query: options.query,
872
+
type: options.type,
873
+
})
874
+
875
+
return {
876
+
...useInfiniteQuery({
877
+
queryKey,
878
+
queryFn,
879
+
initialPageParam: undefined as never,
880
+
getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined,
881
+
//staleTime: Infinity,
882
+
refetchOnWindowFocus: false,
883
+
// enabled:
884
+
// !!options.feedUri &&
885
+
// (options.isAuthed
886
+
// ? ((!!options.agent && !!options.pdsUrl) ||
887
+
// !!options.unauthedfeedurl) &&
888
+
// !!options.feedServiceDid
889
+
// : true),
890
+
}),
891
+
queryKey: queryKey,
892
+
};
893
+
}
894
+
895
+
896
+
export function constructLycanSearchQuery(options: {
897
+
agent?: ATPAPI.Agent;
898
+
isAuthed: boolean;
899
+
pdsUrl?: string;
900
+
feedServiceDid?: string;
901
+
type: "likes" | "pins" | "reposts" | "quotes";
902
+
query: string;
903
+
}) {
904
+
const { agent, isAuthed, pdsUrl, feedServiceDid, type, query } = options;
905
+
906
+
return infiniteQueryOptions({
907
+
queryKey: ["lycanSearch", query, type, { isAuthed, did: agent?.did }],
908
+
909
+
queryFn: async ({
910
+
pageParam,
911
+
}: QueryFunctionContext): Promise<LycanSearchPage | undefined> => {
912
+
if (isAuthed && agent && pdsUrl && feedServiceDid) {
913
+
const url = `${pdsUrl}/xrpc/blue.feeds.lycan.searchPosts?query=${query}&collection=${type}${pageParam ? `&cursor=${pageParam}` : ""}`;
914
+
const res = await agent.fetchHandler(url, {
915
+
method: "GET",
916
+
headers: {
917
+
"atproto-proxy": `${feedServiceDid}#lycan`,
918
+
"Content-Type": "application/json",
919
+
},
920
+
});
921
+
if (!res.ok)
922
+
throw new Error(
923
+
`Authenticated lycan status fetch failed: ${res.statusText}`
924
+
);
925
+
return (await res.json()) as LycanSearchPage;
926
+
}
927
+
return undefined;
928
+
},
929
+
initialPageParam: undefined as never,
930
+
getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined,
931
+
});
932
+
}
+5
vite.config.ts
+5
vite.config.ts
···
13
13
const PROD_URL = "https://reddwarf.app"
14
14
const DEV_URL = "https://local3768forumtest.whey.party"
15
15
16
+
const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party"
17
+
const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social"
18
+
16
19
function shp(url: string): string {
17
20
return url.replace(/^https?:\/\//, '');
18
21
}
···
23
26
generateMetadataPlugin({
24
27
prod: PROD_URL,
25
28
dev: DEV_URL,
29
+
prodResolver: PROD_HANDLE_RESOLVER_PDS,
30
+
devResolver: DEV_HANDLE_RESOLVER_PDS,
26
31
}),
27
32
TanStackRouterVite({ autoCodeSplitting: true }),
28
33
viteReact({