+2
-1
.gitignore
+2
-1
.gitignore
+9
README.md
+9
README.md
···
15
16
run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder)
17
18
## useQuery
19
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
···
15
16
run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder)
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
+
27
## useQuery
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!
29
+62
-30
oauthdev.mts
+62
-30
oauthdev.mts
···
1
-
import fs from 'fs';
2
-
import path from 'path';
3
//import { generateClientMetadata } from './src/helpers/oauthClient'
4
export const generateClientMetadata = (appOrigin: string) => {
5
-
const callbackPath = '/callback';
6
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
-
24
25
-
export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) {
26
return {
27
-
name: 'vite-plugin-generate-metadata',
28
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.');
34
}
35
} else {
36
appOrigin = dev;
37
}
38
-
39
-
40
const metadata = generateClientMetadata(appOrigin);
41
-
const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json');
42
43
fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2));
44
45
// /*mass comment*/ console.log(`โ
Generated client-metadata.json for ${appOrigin}`);
46
},
47
};
48
-
}
···
1
+
import fs from "fs";
2
+
import path from "path";
3
//import { generateClientMetadata } from './src/helpers/oauthClient'
4
export const generateClientMetadata = (appOrigin: string) => {
5
+
const callbackPath = "/callback";
6
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 [
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
+
};
26
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
+
}) {
38
return {
39
+
name: "vite-plugin-generate-metadata",
40
config(_config: any, { mode }: any) {
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
+
);
50
}
51
} else {
52
appOrigin = dev;
53
+
resolver = devResolver;
54
}
55
+
56
const metadata = generateClientMetadata(appOrigin);
57
+
const outputPath = path.resolve(
58
+
process.cwd(),
59
+
"public",
60
+
"client-metadata.json"
61
+
);
62
63
fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2));
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
+
77
// /*mass comment*/ console.log(`โ
Generated client-metadata.json for ${appOrigin}`);
78
},
79
};
80
+
}
+3
src/auto-imports.d.ts
+3
src/auto-imports.d.ts
···
18
const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default
19
const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default
20
const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default
21
const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default
22
}
···
18
const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default
19
const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default
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 IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default
24
const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default
25
}
+117
-164
src/components/Composer.tsx
+117
-164
src/components/Composer.tsx
···
8
import { useQueryPost } from "~/utils/useQuery";
9
10
import { ProfileThing } from "./Login";
11
-
import { Button } from "./radix-m3-rd/Button";
12
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
13
14
const MAX_POST_LENGTH = 300;
15
16
export function Composer() {
17
const [composerState, setComposerState] = useAtom(composerAtom);
18
-
const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false);
19
const { agent } = useAuth();
20
21
const [postText, setPostText] = useState("");
···
114
setPosting(false);
115
}
116
}
117
118
const getPlaceholder = () => {
119
switch (composerState.kind) {
···
131
const isPostButtonDisabled =
132
posting || !postText.trim() || isParentLoading || charsLeft < 0;
133
134
-
function handleAttemptClose() {
135
-
if (postText.trim() && !posting) {
136
-
setCloseConfirmState(true);
137
-
} else {
138
-
setComposerState({ kind: "closed" });
139
-
}
140
-
}
141
-
142
-
function handleConfirmClose() {
143
-
setComposerState({ kind: "closed" });
144
-
setCloseConfirmState(false);
145
-
setPostText("");
146
-
}
147
-
148
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" />
158
159
-
<Dialog.Content className="fixed overflow-y-auto gutter inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]">
160
-
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4">
161
-
<div className="flex flex-row justify-between p-2">
162
-
<Dialog.Close asChild>
163
-
<button
164
-
className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
165
-
disabled={posting}
166
-
aria-label="Close"
167
-
onClick={handleAttemptClose}
168
>
169
-
<svg
170
-
xmlns="http://www.w3.org/2000/svg"
171
-
width="20"
172
-
height="20"
173
-
viewBox="0 0 24 24"
174
-
fill="none"
175
-
stroke="currentColor"
176
-
strokeWidth="2.5"
177
-
strokeLinecap="round"
178
-
strokeLinejoin="round"
179
-
>
180
-
<line x1="18" y1="6" x2="6" y2="18"></line>
181
-
<line x1="6" y1="6" x2="18" y2="18"></line>
182
-
</svg>
183
-
</button>
184
-
</Dialog.Close>
185
186
-
<div className="flex-1" />
187
-
<div className="flex items-center gap-4">
188
-
<span
189
-
className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`}
190
-
>
191
-
{charsLeft}
192
-
</span>
193
-
<Button
194
-
onClick={handlePost}
195
-
disabled={isPostButtonDisabled}
196
-
>
197
-
{posting ? "Posting..." : "Post"}
198
-
</Button>
199
-
</div>
200
</div>
201
202
-
{postSuccess ? (
203
-
<div className="flex flex-col items-center justify-center py-16">
204
-
<span className="text-gray-500 text-6xl mb-4">โ</span>
205
-
<span className="text-xl font-bold text-black dark:text-white">
206
-
Posted!
207
-
</span>
208
-
</div>
209
-
) : (
210
-
<div className="px-4">
211
-
{composerState.kind === "reply" && (
212
-
<div className="mb-1 -mx-4">
213
-
{isParentLoading ? (
214
-
<div className="text-sm text-gray-500 animate-pulse">
215
-
Loading parent post...
216
-
</div>
217
-
) : parentUri ? (
218
-
<UniversalPostRendererATURILoader
219
-
atUri={parentUri}
220
-
bottomReplyLine
221
-
bottomBorder={false}
222
-
/>
223
-
) : (
224
-
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
225
-
Could not load parent post.
226
-
</div>
227
-
)}
228
-
</div>
229
-
)}
230
-
231
-
<div className="flex w-full gap-1 flex-col">
232
-
<ProfileThing agent={agent} large />
233
-
<div className="flex pl-[50px]">
234
-
<AutoGrowTextarea
235
-
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2"
236
-
rows={5}
237
-
placeholder={getPlaceholder()}
238
-
value={postText}
239
-
onChange={(e) => setPostText(e.target.value)}
240
-
disabled={posting}
241
-
autoFocus
242
/>
243
-
</div>
244
</div>
245
246
-
{composerState.kind === "quote" && (
247
-
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
248
-
{isParentLoading ? (
249
-
<div className="text-sm text-gray-500 animate-pulse">
250
-
Loading parent post...
251
-
</div>
252
-
) : parentUri ? (
253
-
<UniversalPostRendererATURILoader
254
-
atUri={parentUri}
255
-
isQuote
256
-
/>
257
-
) : (
258
-
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
259
-
Could not load parent post.
260
-
</div>
261
-
)}
262
-
</div>
263
-
)}
264
-
265
-
{postError && (
266
-
<div className="text-red-500 text-sm my-2 text-center">
267
-
{postError}
268
-
</div>
269
-
)}
270
</div>
271
-
)}
272
-
</div>
273
-
</Dialog.Content>
274
-
</Dialog.Portal>
275
-
</Dialog.Root>
276
277
-
{/* Close confirmation dialog */}
278
-
<Dialog.Root open={closeConfirmState} onOpenChange={setCloseConfirmState}>
279
-
<Dialog.Portal>
280
-
281
-
<Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
282
283
-
<Dialog.Content className="fixed gutter inset-0 z-50 flex items-start justify-center pt-30 sm:pt-40">
284
-
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-md relative mx-4 py-6">
285
-
<div className="text-xl mb-4 text-center">
286
-
Discard your post?
287
-
</div>
288
-
<div className="text-md mb-4 text-center">
289
-
You will lose your draft
290
</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
-
</>
309
);
310
}
311
···
8
import { useQueryPost } from "~/utils/useQuery";
9
10
import { ProfileThing } from "./Login";
11
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
12
13
const MAX_POST_LENGTH = 300;
14
15
export function Composer() {
16
const [composerState, setComposerState] = useAtom(composerAtom);
17
const { agent } = useAuth();
18
19
const [postText, setPostText] = useState("");
···
112
setPosting(false);
113
}
114
}
115
+
// if (composerState.kind === "closed") {
116
+
// return null;
117
+
// }
118
119
const getPlaceholder = () => {
120
switch (composerState.kind) {
···
132
const isPostButtonDisabled =
133
posting || !postText.trim() || isParentLoading || charsLeft < 0;
134
135
return (
136
+
<Dialog.Root
137
+
open={composerState.kind !== "closed"}
138
+
onOpenChange={(open) => {
139
+
if (!open) setComposerState({ kind: "closed" });
140
+
}}
141
+
>
142
+
<Dialog.Portal>
143
+
<Dialog.Overlay className="fixed disablegutter inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
144
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"
164
>
165
+
<line x1="18" y1="6" x2="6" y2="18"></line>
166
+
<line x1="6" y1="6" x2="18" y2="18"></line>
167
+
</svg>
168
+
</button>
169
+
</Dialog.Close>
170
171
+
<div className="flex-1" />
172
+
<div className="flex items-center gap-4">
173
+
<span
174
+
className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`}
175
+
>
176
+
{charsLeft}
177
+
</span>
178
+
<button
179
+
className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
180
+
onClick={handlePost}
181
+
disabled={isPostButtonDisabled}
182
+
>
183
+
{posting ? "Posting..." : "Post"}
184
+
</button>
185
</div>
186
+
</div>
187
188
+
{postSuccess ? (
189
+
<div className="flex flex-col items-center justify-center py-16">
190
+
<span className="text-gray-500 text-6xl mb-4">โ</span>
191
+
<span className="text-xl font-bold text-black dark:text-white">
192
+
Posted!
193
+
</span>
194
+
</div>
195
+
) : (
196
+
<div className="px-4">
197
+
{composerState.kind === "reply" && (
198
+
<div className="mb-1 -mx-4">
199
+
{isParentLoading ? (
200
+
<div className="text-sm text-gray-500 animate-pulse">
201
+
Loading parent post...
202
+
</div>
203
+
) : parentUri ? (
204
+
<UniversalPostRendererATURILoader
205
+
atUri={parentUri}
206
+
bottomReplyLine
207
+
bottomBorder={false}
208
/>
209
+
) : (
210
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
211
+
Could not load parent post.
212
+
</div>
213
+
)}
214
</div>
215
+
)}
216
217
+
<div className="flex w-full gap-1 flex-col">
218
+
<ProfileThing agent={agent} large />
219
+
<div className="flex pl-[50px]">
220
+
<AutoGrowTextarea
221
+
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2"
222
+
rows={5}
223
+
placeholder={getPlaceholder()}
224
+
value={postText}
225
+
onChange={(e) => setPostText(e.target.value)}
226
+
disabled={posting}
227
+
autoFocus
228
+
/>
229
+
</div>
230
</div>
231
232
+
{composerState.kind === "quote" && (
233
+
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
234
+
{isParentLoading ? (
235
+
<div className="text-sm text-gray-500 animate-pulse">
236
+
Loading parent post...
237
+
</div>
238
+
) : parentUri ? (
239
+
<UniversalPostRendererATURILoader
240
+
atUri={parentUri}
241
+
isQuote
242
+
/>
243
+
) : (
244
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
245
+
Could not load parent post.
246
+
</div>
247
+
)}
248
+
</div>
249
+
)}
250
251
+
{postError && (
252
+
<div className="text-red-500 text-sm my-2 text-center">
253
+
{postError}
254
+
</div>
255
+
)}
256
</div>
257
+
)}
258
+
</div>
259
+
</Dialog.Content>
260
+
</Dialog.Portal>
261
+
</Dialog.Root>
262
);
263
}
264
+4
-2
src/components/Header.tsx
+4
-2
src/components/Header.tsx
···
5
6
export function Header({
7
backButtonCallback,
8
-
title
9
}: {
10
backButtonCallback?: () => void;
11
title?: string;
12
}) {
13
const router = useRouter();
14
const [isAtTop] = useAtom(isAtTopAtom);
15
//const what = router.history.
16
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`}>
18
{backButtonCallback ? (<Link
19
to=".."
20
//className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
···
5
6
export function Header({
7
backButtonCallback,
8
+
title,
9
+
bottomBorderDisabled,
10
}: {
11
backButtonCallback?: () => void;
12
title?: string;
13
+
bottomBorderDisabled?: boolean;
14
}) {
15
const router = useRouter();
16
const [isAtTop] = useAtom(isAtTopAtom);
17
//const what = router.history.
18
return (
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`}>
20
{backButtonCallback ? (<Link
21
to=".."
22
//className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
+6
-1
src/components/InfiniteCustomFeed.tsx
+6
-1
src/components/InfiniteCustomFeed.tsx
···
14
feedUri: string;
15
pdsUrl?: string;
16
feedServiceDid?: string;
17
}
18
19
export function InfiniteCustomFeed({
20
feedUri,
21
pdsUrl,
22
feedServiceDid,
23
}: InfiniteCustomFeedProps) {
24
const { agent } = useAuth();
25
-
const authed = !!agent?.did;
26
27
// const identityresultmaybe = useQueryIdentity(agent?.did);
28
// const identity = identityresultmaybe?.data;
···
45
isAuthed: authed ?? false,
46
pdsUrl: pdsUrl,
47
feedServiceDid: feedServiceDid,
48
});
49
const queryClient = useQueryClient();
50
···
14
feedUri: string;
15
pdsUrl?: string;
16
feedServiceDid?: string;
17
+
authedOverride?: boolean;
18
+
unauthedfeedurl?: string;
19
}
20
21
export function InfiniteCustomFeed({
22
feedUri,
23
pdsUrl,
24
feedServiceDid,
25
+
authedOverride,
26
+
unauthedfeedurl,
27
}: InfiniteCustomFeedProps) {
28
const { agent } = useAuth();
29
+
const authed = authedOverride || !!agent?.did;
30
31
// const identityresultmaybe = useQueryIdentity(agent?.did);
32
// const identity = identityresultmaybe?.data;
···
49
isAuthed: authed ?? false,
50
pdsUrl: pdsUrl,
51
feedServiceDid: feedServiceDid,
52
+
unauthedfeedurl: unauthedfeedurl,
53
});
54
const queryClient = useQueryClient();
55
+3
-5
src/components/Login.tsx
+3
-5
src/components/Login.tsx
···
7
import { imgCDNAtom } from "~/utils/atoms";
8
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
9
10
-
import { Button } from "./radix-m3-rd/Button";
11
-
12
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
13
export default function Login({
14
compact = false,
···
51
You are logged in!
52
</p>
53
<ProfileThing agent={agent} large />
54
-
<Button
55
onClick={logout}
56
-
className="mt-4"
57
>
58
Log out
59
-
</Button>
60
</div>
61
</div>
62
);
···
7
import { imgCDNAtom } from "~/utils/atoms";
8
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
9
10
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
11
export default function Login({
12
compact = false,
···
49
You are logged in!
50
</p>
51
<ProfileThing agent={agent} large />
52
+
<button
53
onClick={logout}
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"
55
>
56
Log out
57
+
</button>
58
</div>
59
</div>
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
+
*/
+183
-54
src/components/UniversalPostRenderer.tsx
+183
-54
src/components/UniversalPostRenderer.tsx
···
1
import { useNavigate } from "@tanstack/react-router";
2
import DOMPurify from "dompurify";
3
import { useAtom } from "jotai";
···
9
import {
10
composerAtom,
11
constellationURLAtom,
12
imgCDNAtom,
13
-
likedPostsAtom,
14
} from "~/utils/atoms";
15
import { useHydratedEmbed } from "~/utils/useHydrated";
16
import {
···
38
feedviewpost?: boolean;
39
repostedby?: string;
40
style?: React.CSSProperties;
41
-
ref?: React.Ref<HTMLDivElement>;
42
dataIndexPropPass?: number;
43
nopics?: boolean;
44
lightboxCallback?: (d: LightboxProps) => void;
45
maxReplies?: number;
46
isQuote?: boolean;
47
}
48
49
// export async function cachedGetRecord({
···
152
ref,
153
dataIndexPropPass,
154
nopics,
155
lightboxCallback,
156
maxReplies,
157
isQuote,
158
}: UniversalPostRendererATURILoaderProps) {
159
// todo remove this once tree rendering is implemented, use a prop like isTree
160
const TEMPLINEAR = true;
···
518
? true
519
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
520
? false
521
-
: (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine
522
}
523
topReplyLine={topReplyLine}
524
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
536
ref={ref}
537
dataIndexPropPass={dataIndexPropPass}
538
nopics={nopics}
539
lightboxCallback={lightboxCallback}
540
maxReplies={maxReplies}
541
isQuote={isQuote}
542
/>
543
<>
544
-
{(maxReplies && maxReplies === 0 && replies && replies > 0) ? (
545
<>
546
-
{/* <div>hello</div> */}
547
-
<MoreReplies atUri={atUri} />
548
</>
549
-
) : (<></>)}
550
</>
551
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
552
<>
···
567
ref={ref}
568
dataIndexPropPass={dataIndexPropPass}
569
nopics={nopics}
570
lightboxCallback={lightboxCallback}
571
maxReplies={
572
maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
···
636
ref,
637
dataIndexPropPass,
638
nopics,
639
lightboxCallback,
640
maxReplies,
641
isQuote,
642
}: {
643
postRecord: any;
644
profileRecord: any;
···
654
feedviewpost?: boolean;
655
repostedby?: string;
656
style?: React.CSSProperties;
657
-
ref?: React.Ref<HTMLDivElement>;
658
dataIndexPropPass?: number;
659
nopics?: boolean;
660
lightboxCallback?: (d: LightboxProps) => void;
661
maxReplies?: number;
662
isQuote?: boolean;
663
}) {
664
// /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`);
665
const navigate = useNavigate();
···
729
730
// run();
731
// }, [postRecord, resolved?.did]);
732
733
const {
734
data: hydratedEmbed,
···
824
// }, [fakepost, get, set]);
825
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
826
?.uri;
827
-
const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
828
const replyhookvalue = useQueryIdentity(
829
feedviewpost ? feedviewpostreplydid : undefined
830
);
···
835
repostedby ? aturirepostbydid : undefined
836
);
837
const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle;
838
return (
839
<>
840
{/* <p>
841
{postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)}
842
</p> */}
843
<UniversalPostRenderer
844
expanded={detailed}
845
onPostClick={() =>
···
874
ref={ref}
875
dataIndexPropPass={dataIndexPropPass}
876
nopics={nopics}
877
lightboxCallback={lightboxCallback}
878
maxReplies={maxReplies}
879
isQuote={isQuote}
···
1197
1198
import defaultpfp from "~/../public/favicon.png";
1199
import { useAuth } from "~/providers/UnifiedAuthProvider";
1200
-
import { FollowButton, Mutual } from "~/routes/profile.$did";
1201
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1202
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1203
// import type {
1204
// ViewRecord,
···
1327
ref,
1328
dataIndexPropPass,
1329
nopics,
1330
lightboxCallback,
1331
maxReplies,
1332
}: {
···
1350
depth?: number;
1351
repostedby?: string;
1352
style?: React.CSSProperties;
1353
-
ref?: React.Ref<HTMLDivElement>;
1354
dataIndexPropPass?: number;
1355
nopics?: boolean;
1356
lightboxCallback?: (d: LightboxProps) => void;
1357
maxReplies?: number;
1358
}) {
1359
const parsed = new AtUri(post.uri);
1360
const navigate = useNavigate();
1361
-
const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom);
1362
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
1363
post.viewer?.repost ? true : false
1364
);
1365
-
const [hasLiked, setHasLiked] = useState<boolean>(
1366
-
post.uri in likedPosts || post.viewer?.like ? true : false
1367
-
);
1368
const [, setComposerPost] = useAtom(composerAtom);
1369
const { agent } = useAuth();
1370
-
const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like);
1371
const [retweetUri, setRetweetUri] = useState<string | undefined>(
1372
post.viewer?.repost
1373
);
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
-
};
1399
1400
const repostOrUnrepostPost = async () => {
1401
if (!agent) {
···
1426
: undefined;
1427
1428
const emergencySalt = randomString();
1429
-
const fedi = (post.record as { bridgyOriginalText?: string })
1430
.bridgyOriginalText;
1431
1432
/* fuck you */
1433
const isMainItem = false;
1434
const setMainItem = (any: any) => {};
1435
// eslint-disable-next-line react-hooks/refs
1436
-
console.log("Received ref in UniversalPostRenderer:", ref);
1437
return (
1438
<div ref={ref} style={style} data-index={dataIndexPropPass}>
1439
<div
···
1557
className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1558
/>
1559
<div className=" flex-1 flex flex-row align-middle justify-end">
1560
-
<div className=" flex flex-col justify-start">
1561
<FollowButton targetdidorhandle={post.author.did} />
1562
-
</div>
1563
</div>
1564
</div>
1565
<div className="flex flex-col gap-3">
···
1568
{post.author.displayName || post.author.handle}{" "}
1569
</div>
1570
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1571
-
<Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "}
1572
</div>
1573
</div>
1574
{uprrrsauthor?.description && (
···
1761
<div
1762
style={{
1763
fontSize: 16,
1764
-
marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8,
1765
whiteSpace: "pre-wrap",
1766
textAlign: "left",
1767
overflowWrap: "anywhere",
1768
wordBreak: "break-word",
1769
-
//color: theme.text,
1770
}}
1771
className="text-gray-900 dark:text-gray-100"
1772
>
···
1789
</>
1790
)}
1791
</div>
1792
-
{post.embed && depth < 1 ? (
1793
<PostEmbeds
1794
embed={post.embed}
1795
//moderation={moderation}
···
1811
</div>
1812
</>
1813
)}
1814
-
<div style={{ paddingTop: post.embed && depth < 1 ? 4 : 0 }}>
1815
<>
1816
{expanded && (
1817
<div
···
1907
</DropdownMenu.Root>
1908
<HitSlopButton
1909
onClick={() => {
1910
-
likeOrUnlikePost();
1911
}}
1912
style={{
1913
...btnstyle,
1914
-
...(hasLiked ? { color: "#EC4899" } : {}),
1915
}}
1916
>
1917
-
{hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
1918
-
{(post.likeCount || 0) + (hasLiked ? 1 : 0)}
1919
</HitSlopButton>
1920
<div style={{ display: "flex", gap: 8 }}>
1921
<HitSlopButton
···
2167
}
2168
2169
if (AppBskyEmbedRecord.isView(embed)) {
2170
// custom feed embed (i.e. generator view)
2171
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
2172
// stopgap sorry
···
2176
// <MaybeFeedCard view={embed.record} />
2177
// </div>
2178
// )
2179
}
2180
2181
// list embed
···
2187
// <MaybeListCard view={embed.record} />
2188
// </div>
2189
// )
2190
}
2191
2192
// starter pack embed
···
2198
// <StarterPackCard starterPack={embed.record} />
2199
// </div>
2200
// )
2201
}
2202
2203
// quote post
···
2257
</div>
2258
);
2259
} else {
2260
return <>sorry</>;
2261
}
2262
//return <QuotePostRenderer record={embed.record} moderation={moderation} />;
···
2566
// =
2567
if (AppBskyEmbedVideo.isView(embed)) {
2568
// hls playlist
2569
const playlist = embed.playlist;
2570
return (
2571
<SmartHLSPlayer
···
1
+
import * as ATPAPI from "@atproto/api";
2
import { useNavigate } from "@tanstack/react-router";
3
import DOMPurify from "dompurify";
4
import { useAtom } from "jotai";
···
10
import {
11
composerAtom,
12
constellationURLAtom,
13
+
enableBridgyTextAtom,
14
+
enableWafrnTextAtom,
15
imgCDNAtom,
16
} from "~/utils/atoms";
17
import { useHydratedEmbed } from "~/utils/useHydrated";
18
import {
···
40
feedviewpost?: boolean;
41
repostedby?: string;
42
style?: React.CSSProperties;
43
+
ref?: React.RefObject<HTMLDivElement>;
44
dataIndexPropPass?: number;
45
nopics?: boolean;
46
+
concise?: boolean;
47
lightboxCallback?: (d: LightboxProps) => void;
48
maxReplies?: number;
49
isQuote?: boolean;
50
+
filterNoReplies?: boolean;
51
+
filterMustHaveMedia?: boolean;
52
+
filterMustBeReply?: boolean;
53
}
54
55
// export async function cachedGetRecord({
···
158
ref,
159
dataIndexPropPass,
160
nopics,
161
+
concise,
162
lightboxCallback,
163
maxReplies,
164
isQuote,
165
+
filterNoReplies,
166
+
filterMustHaveMedia,
167
+
filterMustBeReply,
168
}: UniversalPostRendererATURILoaderProps) {
169
// todo remove this once tree rendering is implemented, use a prop like isTree
170
const TEMPLINEAR = true;
···
528
? true
529
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
530
? false
531
+
: maxReplies === 0 && (!replies || (!!replies && replies === 0))
532
+
? false
533
+
: bottomReplyLine
534
}
535
topReplyLine={topReplyLine}
536
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
548
ref={ref}
549
dataIndexPropPass={dataIndexPropPass}
550
nopics={nopics}
551
+
concise={concise}
552
lightboxCallback={lightboxCallback}
553
maxReplies={maxReplies}
554
isQuote={isQuote}
555
+
filterNoReplies={filterNoReplies}
556
+
filterMustHaveMedia={filterMustHaveMedia}
557
+
filterMustBeReply={filterMustBeReply}
558
/>
559
<>
560
+
{maxReplies && maxReplies === 0 && replies && replies > 0 ? (
561
<>
562
+
{/* <div>hello</div> */}
563
+
<MoreReplies atUri={atUri} />
564
</>
565
+
) : (
566
+
<></>
567
+
)}
568
</>
569
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
570
<>
···
585
ref={ref}
586
dataIndexPropPass={dataIndexPropPass}
587
nopics={nopics}
588
+
concise={concise}
589
lightboxCallback={lightboxCallback}
590
maxReplies={
591
maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
···
655
ref,
656
dataIndexPropPass,
657
nopics,
658
+
concise,
659
lightboxCallback,
660
maxReplies,
661
isQuote,
662
+
filterNoReplies,
663
+
filterMustHaveMedia,
664
+
filterMustBeReply,
665
}: {
666
postRecord: any;
667
profileRecord: any;
···
677
feedviewpost?: boolean;
678
repostedby?: string;
679
style?: React.CSSProperties;
680
+
ref?: React.RefObject<HTMLDivElement>;
681
dataIndexPropPass?: number;
682
nopics?: boolean;
683
+
concise?: boolean;
684
lightboxCallback?: (d: LightboxProps) => void;
685
maxReplies?: number;
686
isQuote?: boolean;
687
+
filterNoReplies?: boolean;
688
+
filterMustHaveMedia?: boolean;
689
+
filterMustBeReply?: boolean;
690
}) {
691
// /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`);
692
const navigate = useNavigate();
···
756
757
// run();
758
// }, [postRecord, resolved?.did]);
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
777
const {
778
data: hydratedEmbed,
···
868
// }, [fakepost, get, set]);
869
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
870
?.uri;
871
+
const feedviewpostreplydid =
872
+
thereply && !filterNoReplies ? new AtUri(thereply).host : undefined;
873
const replyhookvalue = useQueryIdentity(
874
feedviewpost ? feedviewpostreplydid : undefined
875
);
···
880
repostedby ? aturirepostbydid : undefined
881
);
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
+
890
return (
891
<>
892
{/* <p>
893
{postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)}
894
</p> */}
895
+
{/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span>
896
+
<span>thereply is {thereply ? "true" : "false"}</span> */}
897
<UniversalPostRenderer
898
expanded={detailed}
899
onPostClick={() =>
···
928
ref={ref}
929
dataIndexPropPass={dataIndexPropPass}
930
nopics={nopics}
931
+
concise={concise}
932
lightboxCallback={lightboxCallback}
933
maxReplies={maxReplies}
934
isQuote={isQuote}
···
1252
1253
import defaultpfp from "~/../public/favicon.png";
1254
import { useAuth } from "~/providers/UnifiedAuthProvider";
1255
+
import {
1256
+
FeedItemRenderAturiLoader,
1257
+
FollowButton,
1258
+
Mutual,
1259
+
} from "~/routes/profile.$did";
1260
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1261
+
import { useFastLike } from "~/utils/likeMutationQueue";
1262
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1263
// import type {
1264
// ViewRecord,
···
1387
ref,
1388
dataIndexPropPass,
1389
nopics,
1390
+
concise,
1391
lightboxCallback,
1392
maxReplies,
1393
}: {
···
1411
depth?: number;
1412
repostedby?: string;
1413
style?: React.CSSProperties;
1414
+
ref?: React.RefObject<HTMLDivElement>;
1415
dataIndexPropPass?: number;
1416
nopics?: boolean;
1417
+
concise?: boolean;
1418
lightboxCallback?: (d: LightboxProps) => void;
1419
maxReplies?: number;
1420
}) {
1421
const parsed = new AtUri(post.uri);
1422
const navigate = useNavigate();
1423
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
1424
post.viewer?.repost ? true : false
1425
);
1426
const [, setComposerPost] = useAtom(composerAtom);
1427
const { agent } = useAuth();
1428
const [retweetUri, setRetweetUri] = useState<string | undefined>(
1429
post.viewer?.repost
1430
);
1431
+
const { liked, toggle, backfill } = useFastLike(post.uri, post.cid);
1432
+
// const bovref = useBackfillOnView(post.uri, post.cid);
1433
+
// React.useLayoutEffect(()=>{
1434
+
// if (expanded && !isQuote) {
1435
+
// backfill();
1436
+
// }
1437
+
// },[backfill, expanded, isQuote])
1438
1439
const repostOrUnrepostPost = async () => {
1440
if (!agent) {
···
1465
: undefined;
1466
1467
const emergencySalt = randomString();
1468
+
1469
+
const [showBridgyText] = useAtom(enableBridgyTextAtom);
1470
+
const [showWafrnText] = useAtom(enableWafrnTextAtom);
1471
+
1472
+
const unfedibridgy = (post.record as { bridgyOriginalText?: string })
1473
.bridgyOriginalText;
1474
+
const unfediwafrnPartial = (post.record as { fullText?: string }).fullText;
1475
+
const unfediwafrnTags = (post.record as { fullTags?: string }).fullTags;
1476
+
const unfediwafrnUnHost = (post.record as { fediverseId?: string })
1477
+
.fediverseId;
1478
+
1479
+
const undfediwafrnHost = unfediwafrnUnHost
1480
+
? new URL(unfediwafrnUnHost).hostname
1481
+
: undefined;
1482
+
1483
+
const tags = unfediwafrnTags
1484
+
? unfediwafrnTags
1485
+
.split("\n")
1486
+
.map((t) => t.trim())
1487
+
.filter(Boolean)
1488
+
: undefined;
1489
+
1490
+
const links = tags
1491
+
? tags
1492
+
.map((tag) => {
1493
+
const encoded = encodeURIComponent(tag);
1494
+
return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(' ','-')}</a>`;
1495
+
})
1496
+
.join("<br>")
1497
+
: "";
1498
+
1499
+
const unfediwafrn = unfediwafrnPartial
1500
+
? unfediwafrnPartial + (links ? `<br>${links}` : "")
1501
+
: undefined;
1502
+
1503
+
const fedi =
1504
+
(showBridgyText ? unfedibridgy : undefined) ??
1505
+
(showWafrnText ? unfediwafrn : undefined);
1506
1507
/* fuck you */
1508
const isMainItem = false;
1509
const setMainItem = (any: any) => {};
1510
// eslint-disable-next-line react-hooks/refs
1511
+
//console.log("Received ref in UniversalPostRenderer:", usedref);
1512
return (
1513
<div ref={ref} style={style} data-index={dataIndexPropPass}>
1514
<div
···
1632
className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1633
/>
1634
<div className=" flex-1 flex flex-row align-middle justify-end">
1635
<FollowButton targetdidorhandle={post.author.did} />
1636
</div>
1637
</div>
1638
<div className="flex flex-col gap-3">
···
1641
{post.author.displayName || post.author.handle}{" "}
1642
</div>
1643
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1644
+
<Mutual targetdidorhandle={post.author.did} />@
1645
+
{post.author.handle}{" "}
1646
</div>
1647
</div>
1648
{uprrrsauthor?.description && (
···
1835
<div
1836
style={{
1837
fontSize: 16,
1838
+
marginBottom: !post.embed || concise ? 0 : 8,
1839
whiteSpace: "pre-wrap",
1840
textAlign: "left",
1841
overflowWrap: "anywhere",
1842
wordBreak: "break-word",
1843
+
...(concise && {
1844
+
display: "-webkit-box",
1845
+
WebkitBoxOrient: "vertical",
1846
+
WebkitLineClamp: 2,
1847
+
overflow: "hidden",
1848
+
}),
1849
}}
1850
className="text-gray-900 dark:text-gray-100"
1851
>
···
1868
</>
1869
)}
1870
</div>
1871
+
{post.embed && depth < 1 && !concise ? (
1872
<PostEmbeds
1873
embed={post.embed}
1874
//moderation={moderation}
···
1890
</div>
1891
</>
1892
)}
1893
+
<div
1894
+
style={{
1895
+
paddingTop: post.embed && !concise && depth < 1 ? 4 : 0,
1896
+
}}
1897
+
>
1898
<>
1899
{expanded && (
1900
<div
···
1990
</DropdownMenu.Root>
1991
<HitSlopButton
1992
onClick={() => {
1993
+
toggle();
1994
}}
1995
style={{
1996
...btnstyle,
1997
+
...(liked ? { color: "#EC4899" } : {}),
1998
}}
1999
>
2000
+
{liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
2001
+
{(post.likeCount || 0) + (liked ? 1 : 0)}
2002
</HitSlopButton>
2003
<div style={{ display: "flex", gap: 8 }}>
2004
<HitSlopButton
···
2250
}
2251
2252
if (AppBskyEmbedRecord.isView(embed)) {
2253
+
// hey im really lazy and im gonna do it the bad way
2254
+
const reallybaduri = (embed?.record as any)?.uri as string | undefined;
2255
+
const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined;
2256
+
2257
// custom feed embed (i.e. generator view)
2258
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
2259
// stopgap sorry
···
2263
// <MaybeFeedCard view={embed.record} />
2264
// </div>
2265
// )
2266
+
} else if (
2267
+
!!reallybaduri &&
2268
+
!!reallybadaturi &&
2269
+
reallybadaturi.collection === "app.bsky.feed.generator"
2270
+
) {
2271
+
return (
2272
+
<div className="rounded-xl border">
2273
+
<FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder />
2274
+
</div>
2275
+
);
2276
}
2277
2278
// list embed
···
2284
// <MaybeListCard view={embed.record} />
2285
// </div>
2286
// )
2287
+
} else if (
2288
+
!!reallybaduri &&
2289
+
!!reallybadaturi &&
2290
+
reallybadaturi.collection === "app.bsky.graph.list"
2291
+
) {
2292
+
return (
2293
+
<div className="rounded-xl border">
2294
+
<FeedItemRenderAturiLoader
2295
+
aturi={reallybaduri}
2296
+
disableBottomBorder
2297
+
listmode
2298
+
disablePropagation
2299
+
/>
2300
+
</div>
2301
+
);
2302
}
2303
2304
// starter pack embed
···
2310
// <StarterPackCard starterPack={embed.record} />
2311
// </div>
2312
// )
2313
+
} else if (
2314
+
!!reallybaduri &&
2315
+
!!reallybadaturi &&
2316
+
reallybadaturi.collection === "app.bsky.graph.starterpack"
2317
+
) {
2318
+
return (
2319
+
<div className="rounded-xl border">
2320
+
<FeedItemRenderAturiLoader
2321
+
aturi={reallybaduri}
2322
+
disableBottomBorder
2323
+
listmode
2324
+
disablePropagation
2325
+
/>
2326
+
</div>
2327
+
);
2328
}
2329
2330
// quote post
···
2384
</div>
2385
);
2386
} else {
2387
+
console.log("what the hell is a ", embed);
2388
return <>sorry</>;
2389
}
2390
//return <QuotePostRenderer record={embed.record} moderation={moderation} />;
···
2694
// =
2695
if (AppBskyEmbedVideo.isView(embed)) {
2696
// hls playlist
2697
+
if (nopics) return;
2698
const playlist = embed.playlist;
2699
return (
2700
<SmartHLSPlayer
-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";
···
+157
src/providers/LikeMutationQueueProvider.tsx
+157
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 { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms";
9
+
import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery";
10
+
11
+
export type LikeRecord = { uri: string; target: string; cid: string };
12
+
export type LikeMutation = { type: 'like'; target: string; cid: string };
13
+
export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord };
14
+
export type Mutation = LikeMutation | UnlikeMutation;
15
+
16
+
interface LikeMutationQueueContextType {
17
+
fastState: (target: string) => LikeRecord | null | undefined;
18
+
fastToggle: (target:string, cid:string) => void;
19
+
backfillState: (target: string, user: string) => Promise<void>;
20
+
}
21
+
22
+
const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined);
23
+
24
+
export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) {
25
+
const { agent } = useAuth();
26
+
const queryClient = useQueryClient();
27
+
const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom);
28
+
const [constellationurl] = useAtom(constellationURLAtom);
29
+
30
+
const likedPostsRef = useRef(likedPosts);
31
+
useEffect(() => {
32
+
likedPostsRef.current = likedPosts;
33
+
}, [likedPosts]);
34
+
35
+
const queueRef = useRef<Mutation[]>([]);
36
+
const runningRef = useRef(false);
37
+
38
+
const fastState = (target: string) => likedPosts[target];
39
+
40
+
const setFastState = useCallback(
41
+
(target: string, record: LikeRecord | null) =>
42
+
setLikedPosts((prev) => ({ ...prev, [target]: record })),
43
+
[setLikedPosts]
44
+
);
45
+
46
+
const enqueue = (mutation: Mutation) => queueRef.current.push(mutation);
47
+
48
+
const fastToggle = useCallback((target: string, cid: string) => {
49
+
const likedRecord = likedPostsRef.current[target];
50
+
51
+
if (likedRecord) {
52
+
setFastState(target, null);
53
+
if (likedRecord.uri !== 'pending') {
54
+
enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord });
55
+
}
56
+
} else {
57
+
setFastState(target, { uri: "pending", target, cid });
58
+
enqueue({ type: "like", target, cid });
59
+
}
60
+
}, [setFastState]);
61
+
62
+
/**
63
+
*
64
+
* @deprecated dont use it yet, will cause infinite rerenders
65
+
*/
66
+
const backfillState = async (target: string, user: string) => {
67
+
const query = constructConstellationQuery({
68
+
constellation: constellationurl,
69
+
method: "/links",
70
+
target,
71
+
collection: "app.bsky.feed.like",
72
+
path: ".subject.uri",
73
+
dids: [user],
74
+
});
75
+
const data = await queryClient.fetchQuery(query);
76
+
const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? [];
77
+
const found = likes.find((r) => r.did === user);
78
+
if (found) {
79
+
const uri = `at://${found.did}/${found.collection}/${found.rkey}`;
80
+
const ciddata = await queryClient.fetchQuery(
81
+
constructArbitraryQuery(uri)
82
+
);
83
+
if (ciddata?.cid)
84
+
setFastState(target, { uri, target, cid: ciddata?.cid });
85
+
} else {
86
+
setFastState(target, null);
87
+
}
88
+
};
89
+
90
+
91
+
useEffect(() => {
92
+
if (!agent?.did) return;
93
+
94
+
const processQueue = async () => {
95
+
if (runningRef.current || queueRef.current.length === 0) return;
96
+
runningRef.current = true;
97
+
98
+
while (queueRef.current.length > 0) {
99
+
const mutation = queueRef.current.shift()!;
100
+
try {
101
+
if (mutation.type === "like") {
102
+
const newRecord = {
103
+
repo: agent.did!,
104
+
collection: "app.bsky.feed.like",
105
+
rkey: TID.next().toString(),
106
+
record: {
107
+
$type: "app.bsky.feed.like",
108
+
subject: { uri: mutation.target, cid: mutation.cid },
109
+
createdAt: new Date().toISOString(),
110
+
},
111
+
};
112
+
const response = await agent.com.atproto.repo.createRecord(newRecord);
113
+
if (!response.success) throw new Error("createRecord failed");
114
+
115
+
const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`;
116
+
setFastState(mutation.target, {
117
+
uri,
118
+
target: mutation.target,
119
+
cid: mutation.cid,
120
+
});
121
+
} else if (mutation.type === "unlike") {
122
+
const aturi = new AtUri(mutation.likeRecordUri);
123
+
await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey });
124
+
setFastState(mutation.target, null);
125
+
}
126
+
} catch (err) {
127
+
console.error("Like mutation failed, reverting:", err);
128
+
if (mutation.type === 'like') {
129
+
setFastState(mutation.target, null);
130
+
} else if (mutation.type === 'unlike') {
131
+
setFastState(mutation.target, mutation.originalRecord);
132
+
}
133
+
}
134
+
}
135
+
runningRef.current = false;
136
+
};
137
+
138
+
const interval = setInterval(processQueue, 1000);
139
+
return () => clearInterval(interval);
140
+
}, [agent, setFastState]);
141
+
142
+
const value = { fastState, fastToggle, backfillState };
143
+
144
+
return (
145
+
<LikeMutationQueueContext value={value}>
146
+
{children}
147
+
</LikeMutationQueueContext>
148
+
);
149
+
}
150
+
151
+
export function useLikeMutationQueue() {
152
+
const context = use(LikeMutationQueueContext);
153
+
if (context === undefined) {
154
+
throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider');
155
+
}
156
+
return context;
157
+
}
+129
src/routeTree.gen.ts
+129
src/routeTree.gen.ts
···
18
import { Route as CallbackIndexRouteImport } from './routes/callback/index'
19
import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout'
20
import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index'
21
import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b'
22
import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a'
23
import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey'
24
import { Route as ProfileDidPostRkeyImageIRouteImport } from './routes/profile.$did/post.$rkey.image.$i'
25
26
const SettingsRoute = SettingsRouteImport.update({
···
67
path: '/profile/$did/',
68
getParentRoute: () => rootRouteImport,
69
} as any)
70
const PathlessLayoutNestedLayoutRouteBRoute =
71
PathlessLayoutNestedLayoutRouteBRouteImport.update({
72
id: '/route-b',
···
84
path: '/profile/$did/post/$rkey',
85
getParentRoute: () => rootRouteImport,
86
} as any)
87
const ProfileDidPostRkeyImageIRoute =
88
ProfileDidPostRkeyImageIRouteImport.update({
89
id: '/image/$i',
···
100
'/callback': typeof CallbackIndexRoute
101
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
102
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
103
'/profile/$did': typeof ProfileDidIndexRoute
104
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
105
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
106
}
107
export interface FileRoutesByTo {
···
113
'/callback': typeof CallbackIndexRoute
114
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
115
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
116
'/profile/$did': typeof ProfileDidIndexRoute
117
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
118
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
119
}
120
export interface FileRoutesById {
···
129
'/callback/': typeof CallbackIndexRoute
130
'/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
131
'/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
132
'/profile/$did/': typeof ProfileDidIndexRoute
133
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
134
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
135
}
136
export interface FileRouteTypes {
···
144
| '/callback'
145
| '/route-a'
146
| '/route-b'
147
| '/profile/$did'
148
| '/profile/$did/post/$rkey'
149
| '/profile/$did/post/$rkey/image/$i'
150
fileRoutesByTo: FileRoutesByTo
151
to:
···
157
| '/callback'
158
| '/route-a'
159
| '/route-b'
160
| '/profile/$did'
161
| '/profile/$did/post/$rkey'
162
| '/profile/$did/post/$rkey/image/$i'
163
id:
164
| '__root__'
···
172
| '/callback/'
173
| '/_pathlessLayout/_nested-layout/route-a'
174
| '/_pathlessLayout/_nested-layout/route-b'
175
| '/profile/$did/'
176
| '/profile/$did/post/$rkey'
177
| '/profile/$did/post/$rkey/image/$i'
178
fileRoutesById: FileRoutesById
179
}
···
185
SearchRoute: typeof SearchRoute
186
SettingsRoute: typeof SettingsRoute
187
CallbackIndexRoute: typeof CallbackIndexRoute
188
ProfileDidIndexRoute: typeof ProfileDidIndexRoute
189
ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren
190
}
191
···
254
preLoaderRoute: typeof ProfileDidIndexRouteImport
255
parentRoute: typeof rootRouteImport
256
}
257
'/_pathlessLayout/_nested-layout/route-b': {
258
id: '/_pathlessLayout/_nested-layout/route-b'
259
path: '/route-b'
···
275
preLoaderRoute: typeof ProfileDidPostRkeyRouteImport
276
parentRoute: typeof rootRouteImport
277
}
278
'/profile/$did/post/$rkey/image/$i': {
279
id: '/profile/$did/post/$rkey/image/$i'
280
path: '/image/$i'
···
316
)
317
318
interface ProfileDidPostRkeyRouteChildren {
319
ProfileDidPostRkeyImageIRoute: typeof ProfileDidPostRkeyImageIRoute
320
}
321
322
const ProfileDidPostRkeyRouteChildren: ProfileDidPostRkeyRouteChildren = {
323
ProfileDidPostRkeyImageIRoute: ProfileDidPostRkeyImageIRoute,
324
}
325
···
334
SearchRoute: SearchRoute,
335
SettingsRoute: SettingsRoute,
336
CallbackIndexRoute: CallbackIndexRoute,
337
ProfileDidIndexRoute: ProfileDidIndexRoute,
338
ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren,
339
}
340
export const routeTree = rootRouteImport
···
18
import { Route as CallbackIndexRouteImport } from './routes/callback/index'
19
import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout'
20
import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index'
21
+
import { Route as ProfileDidFollowsRouteImport } from './routes/profile.$did/follows'
22
+
import { Route as ProfileDidFollowersRouteImport } from './routes/profile.$did/followers'
23
import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b'
24
import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a'
25
import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey'
26
+
import { Route as ProfileDidFeedRkeyRouteImport } from './routes/profile.$did/feed.$rkey'
27
+
import { Route as ProfileDidPostRkeyRepostedByRouteImport } from './routes/profile.$did/post.$rkey.reposted-by'
28
+
import { Route as ProfileDidPostRkeyQuotesRouteImport } from './routes/profile.$did/post.$rkey.quotes'
29
+
import { Route as ProfileDidPostRkeyLikedByRouteImport } from './routes/profile.$did/post.$rkey.liked-by'
30
import { Route as ProfileDidPostRkeyImageIRouteImport } from './routes/profile.$did/post.$rkey.image.$i'
31
32
const SettingsRoute = SettingsRouteImport.update({
···
73
path: '/profile/$did/',
74
getParentRoute: () => rootRouteImport,
75
} as any)
76
+
const ProfileDidFollowsRoute = ProfileDidFollowsRouteImport.update({
77
+
id: '/profile/$did/follows',
78
+
path: '/profile/$did/follows',
79
+
getParentRoute: () => rootRouteImport,
80
+
} as any)
81
+
const ProfileDidFollowersRoute = ProfileDidFollowersRouteImport.update({
82
+
id: '/profile/$did/followers',
83
+
path: '/profile/$did/followers',
84
+
getParentRoute: () => rootRouteImport,
85
+
} as any)
86
const PathlessLayoutNestedLayoutRouteBRoute =
87
PathlessLayoutNestedLayoutRouteBRouteImport.update({
88
id: '/route-b',
···
100
path: '/profile/$did/post/$rkey',
101
getParentRoute: () => rootRouteImport,
102
} as any)
103
+
const ProfileDidFeedRkeyRoute = ProfileDidFeedRkeyRouteImport.update({
104
+
id: '/profile/$did/feed/$rkey',
105
+
path: '/profile/$did/feed/$rkey',
106
+
getParentRoute: () => rootRouteImport,
107
+
} as any)
108
+
const ProfileDidPostRkeyRepostedByRoute =
109
+
ProfileDidPostRkeyRepostedByRouteImport.update({
110
+
id: '/reposted-by',
111
+
path: '/reposted-by',
112
+
getParentRoute: () => ProfileDidPostRkeyRoute,
113
+
} as any)
114
+
const ProfileDidPostRkeyQuotesRoute =
115
+
ProfileDidPostRkeyQuotesRouteImport.update({
116
+
id: '/quotes',
117
+
path: '/quotes',
118
+
getParentRoute: () => ProfileDidPostRkeyRoute,
119
+
} as any)
120
+
const ProfileDidPostRkeyLikedByRoute =
121
+
ProfileDidPostRkeyLikedByRouteImport.update({
122
+
id: '/liked-by',
123
+
path: '/liked-by',
124
+
getParentRoute: () => ProfileDidPostRkeyRoute,
125
+
} as any)
126
const ProfileDidPostRkeyImageIRoute =
127
ProfileDidPostRkeyImageIRouteImport.update({
128
id: '/image/$i',
···
139
'/callback': typeof CallbackIndexRoute
140
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
141
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
142
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
143
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
144
'/profile/$did': typeof ProfileDidIndexRoute
145
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
146
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
147
+
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
148
+
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
149
+
'/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute
150
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
151
}
152
export interface FileRoutesByTo {
···
158
'/callback': typeof CallbackIndexRoute
159
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
160
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
161
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
162
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
163
'/profile/$did': typeof ProfileDidIndexRoute
164
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
165
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
166
+
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
167
+
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
168
+
'/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute
169
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
170
}
171
export interface FileRoutesById {
···
180
'/callback/': typeof CallbackIndexRoute
181
'/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
182
'/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
183
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
184
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
185
'/profile/$did/': typeof ProfileDidIndexRoute
186
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
187
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
188
+
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
189
+
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
190
+
'/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute
191
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
192
}
193
export interface FileRouteTypes {
···
201
| '/callback'
202
| '/route-a'
203
| '/route-b'
204
+
| '/profile/$did/followers'
205
+
| '/profile/$did/follows'
206
| '/profile/$did'
207
+
| '/profile/$did/feed/$rkey'
208
| '/profile/$did/post/$rkey'
209
+
| '/profile/$did/post/$rkey/liked-by'
210
+
| '/profile/$did/post/$rkey/quotes'
211
+
| '/profile/$did/post/$rkey/reposted-by'
212
| '/profile/$did/post/$rkey/image/$i'
213
fileRoutesByTo: FileRoutesByTo
214
to:
···
220
| '/callback'
221
| '/route-a'
222
| '/route-b'
223
+
| '/profile/$did/followers'
224
+
| '/profile/$did/follows'
225
| '/profile/$did'
226
+
| '/profile/$did/feed/$rkey'
227
| '/profile/$did/post/$rkey'
228
+
| '/profile/$did/post/$rkey/liked-by'
229
+
| '/profile/$did/post/$rkey/quotes'
230
+
| '/profile/$did/post/$rkey/reposted-by'
231
| '/profile/$did/post/$rkey/image/$i'
232
id:
233
| '__root__'
···
241
| '/callback/'
242
| '/_pathlessLayout/_nested-layout/route-a'
243
| '/_pathlessLayout/_nested-layout/route-b'
244
+
| '/profile/$did/followers'
245
+
| '/profile/$did/follows'
246
| '/profile/$did/'
247
+
| '/profile/$did/feed/$rkey'
248
| '/profile/$did/post/$rkey'
249
+
| '/profile/$did/post/$rkey/liked-by'
250
+
| '/profile/$did/post/$rkey/quotes'
251
+
| '/profile/$did/post/$rkey/reposted-by'
252
| '/profile/$did/post/$rkey/image/$i'
253
fileRoutesById: FileRoutesById
254
}
···
260
SearchRoute: typeof SearchRoute
261
SettingsRoute: typeof SettingsRoute
262
CallbackIndexRoute: typeof CallbackIndexRoute
263
+
ProfileDidFollowersRoute: typeof ProfileDidFollowersRoute
264
+
ProfileDidFollowsRoute: typeof ProfileDidFollowsRoute
265
ProfileDidIndexRoute: typeof ProfileDidIndexRoute
266
+
ProfileDidFeedRkeyRoute: typeof ProfileDidFeedRkeyRoute
267
ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren
268
}
269
···
332
preLoaderRoute: typeof ProfileDidIndexRouteImport
333
parentRoute: typeof rootRouteImport
334
}
335
+
'/profile/$did/follows': {
336
+
id: '/profile/$did/follows'
337
+
path: '/profile/$did/follows'
338
+
fullPath: '/profile/$did/follows'
339
+
preLoaderRoute: typeof ProfileDidFollowsRouteImport
340
+
parentRoute: typeof rootRouteImport
341
+
}
342
+
'/profile/$did/followers': {
343
+
id: '/profile/$did/followers'
344
+
path: '/profile/$did/followers'
345
+
fullPath: '/profile/$did/followers'
346
+
preLoaderRoute: typeof ProfileDidFollowersRouteImport
347
+
parentRoute: typeof rootRouteImport
348
+
}
349
'/_pathlessLayout/_nested-layout/route-b': {
350
id: '/_pathlessLayout/_nested-layout/route-b'
351
path: '/route-b'
···
367
preLoaderRoute: typeof ProfileDidPostRkeyRouteImport
368
parentRoute: typeof rootRouteImport
369
}
370
+
'/profile/$did/feed/$rkey': {
371
+
id: '/profile/$did/feed/$rkey'
372
+
path: '/profile/$did/feed/$rkey'
373
+
fullPath: '/profile/$did/feed/$rkey'
374
+
preLoaderRoute: typeof ProfileDidFeedRkeyRouteImport
375
+
parentRoute: typeof rootRouteImport
376
+
}
377
+
'/profile/$did/post/$rkey/reposted-by': {
378
+
id: '/profile/$did/post/$rkey/reposted-by'
379
+
path: '/reposted-by'
380
+
fullPath: '/profile/$did/post/$rkey/reposted-by'
381
+
preLoaderRoute: typeof ProfileDidPostRkeyRepostedByRouteImport
382
+
parentRoute: typeof ProfileDidPostRkeyRoute
383
+
}
384
+
'/profile/$did/post/$rkey/quotes': {
385
+
id: '/profile/$did/post/$rkey/quotes'
386
+
path: '/quotes'
387
+
fullPath: '/profile/$did/post/$rkey/quotes'
388
+
preLoaderRoute: typeof ProfileDidPostRkeyQuotesRouteImport
389
+
parentRoute: typeof ProfileDidPostRkeyRoute
390
+
}
391
+
'/profile/$did/post/$rkey/liked-by': {
392
+
id: '/profile/$did/post/$rkey/liked-by'
393
+
path: '/liked-by'
394
+
fullPath: '/profile/$did/post/$rkey/liked-by'
395
+
preLoaderRoute: typeof ProfileDidPostRkeyLikedByRouteImport
396
+
parentRoute: typeof ProfileDidPostRkeyRoute
397
+
}
398
'/profile/$did/post/$rkey/image/$i': {
399
id: '/profile/$did/post/$rkey/image/$i'
400
path: '/image/$i'
···
436
)
437
438
interface ProfileDidPostRkeyRouteChildren {
439
+
ProfileDidPostRkeyLikedByRoute: typeof ProfileDidPostRkeyLikedByRoute
440
+
ProfileDidPostRkeyQuotesRoute: typeof ProfileDidPostRkeyQuotesRoute
441
+
ProfileDidPostRkeyRepostedByRoute: typeof ProfileDidPostRkeyRepostedByRoute
442
ProfileDidPostRkeyImageIRoute: typeof ProfileDidPostRkeyImageIRoute
443
}
444
445
const ProfileDidPostRkeyRouteChildren: ProfileDidPostRkeyRouteChildren = {
446
+
ProfileDidPostRkeyLikedByRoute: ProfileDidPostRkeyLikedByRoute,
447
+
ProfileDidPostRkeyQuotesRoute: ProfileDidPostRkeyQuotesRoute,
448
+
ProfileDidPostRkeyRepostedByRoute: ProfileDidPostRkeyRepostedByRoute,
449
ProfileDidPostRkeyImageIRoute: ProfileDidPostRkeyImageIRoute,
450
}
451
···
460
SearchRoute: SearchRoute,
461
SettingsRoute: SettingsRoute,
462
CallbackIndexRoute: CallbackIndexRoute,
463
+
ProfileDidFollowersRoute: ProfileDidFollowersRoute,
464
+
ProfileDidFollowsRoute: ProfileDidFollowsRoute,
465
ProfileDidIndexRoute: ProfileDidIndexRoute,
466
+
ProfileDidFeedRkeyRoute: ProfileDidFeedRkeyRoute,
467
ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren,
468
}
469
export const routeTree = rootRouteImport
+8
-5
src/routes/__root.tsx
+8
-5
src/routes/__root.tsx
···
22
import Login from "~/components/Login";
23
import { NotFound } from "~/components/NotFound";
24
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
25
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
26
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
27
import { seo } from "~/utils/seo";
···
79
function RootComponent() {
80
return (
81
<UnifiedAuthProvider>
82
-
<RootDocument>
83
-
<KeepAliveProvider>
84
-
<KeepAliveOutlet />
85
-
</KeepAliveProvider>
86
-
</RootDocument>
87
</UnifiedAuthProvider>
88
);
89
}
···
22
import Login from "~/components/Login";
23
import { NotFound } from "~/components/NotFound";
24
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
25
+
import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider";
26
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
27
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
28
import { seo } from "~/utils/seo";
···
80
function RootComponent() {
81
return (
82
<UnifiedAuthProvider>
83
+
<LikeMutationQueueProvider>
84
+
<RootDocument>
85
+
<KeepAliveProvider>
86
+
<KeepAliveOutlet />
87
+
</KeepAliveProvider>
88
+
</RootDocument>
89
+
</LikeMutationQueueProvider>
90
</UnifiedAuthProvider>
91
);
92
}
+44
-33
src/routes/index.tsx
+44
-33
src/routes/index.tsx
···
359
>
360
{!isAuthRestoring && savedFeeds.length > 0 ? (
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
-
})}
395
</div>
396
) : (
397
// <span className="text-xl font-bold ml-2">Home</span>
···
435
</div>
436
);
437
}
438
// not even used lmaooo
439
440
// export async function cachedResolveDIDWEBDOC({
···
359
>
360
{!isAuthRestoring && savedFeeds.length > 0 ? (
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) => {return <FeedTabOnTop key={item} item={item} idx={idx} />})}
363
</div>
364
) : (
365
// <span className="text-xl font-bold ml-2">Home</span>
···
403
</div>
404
);
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
+
449
// not even used lmaooo
450
451
// export async function cachedResolveDIDWEBDOC({
+642
-154
src/routes/notifications.tsx
+642
-154
src/routes/notifications.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
2
import { useAtom } from "jotai";
3
-
import React, { useEffect, useRef,useState } from "react";
4
5
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
-
import { constellationURLAtom } from "~/utils/atoms";
7
8
-
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
9
10
export const Route = createFileRoute("/notifications")({
11
component: NotificationsComponent,
12
});
13
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);
25
26
-
useEffect(() => {
27
-
if (authLoading) return;
28
-
if (authed && agent && agent.assertDid) {
29
-
setDid(agent.assertDid);
30
-
}
31
-
}, [authed, agent, authLoading]);
32
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
-
}
74
75
-
const [constellationURL] = useAtom(constellationURLAtom)
76
77
-
useEffect(() => {
78
-
if (!did) return;
79
-
setLoading(true);
80
-
setError(null);
81
-
const urls = [
82
-
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`,
83
-
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`,
84
-
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`,
85
-
];
86
-
let ignore = false;
87
-
Promise.all(
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;
98
}
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);
113
});
114
-
return () => {
115
-
ignore = true;
116
-
};
117
-
}, [did]);
118
119
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
-
)}
143
</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>
147
)}
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> */}
174
</div>
175
);
176
}
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
4
import { useAtom } from "jotai";
5
+
import * as React from "react";
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";
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
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";
33
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
+
}
54
55
export const Route = createFileRoute("/notifications")({
56
component: NotificationsComponent,
57
});
58
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]);
113
114
+
useReusableTabScrollRestore("Notifications");
115
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." />;
120
121
+
return (
122
+
<>
123
+
{mentionsAturis.map((m) => (
124
+
<UniversalPostRendererATURILoader key={m} atUri={m} />
125
+
))}
126
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);
296
}
297
+
});
298
});
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);
311
312
return (
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>
512
</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 }: { notification: string }) {
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 rounded-full"
622
+
/>
623
+
) : (
624
+
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
625
)}
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} />}
644
</div>
645
);
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
+
}
+663
-81
src/routes/profile.$did/index.tsx
+663
-81
src/routes/profile.$did/index.tsx
···
1
-
import { RichText } from "@atproto/api";
2
import { useQueryClient } from "@tanstack/react-query";
3
-
import { createFileRoute, useNavigate } from "@tanstack/react-router";
4
import { useAtom } from "jotai";
5
import React, { type ReactNode, useEffect, useState } from "react";
6
7
import { Header } from "~/components/Header";
8
-
import { Button } from "~/components/radix-m3-rd/Button";
9
import {
10
renderTextWithFacets,
11
UniversalPostRendererATURILoader,
12
} from "~/components/UniversalPostRenderer";
13
import { useAuth } from "~/providers/UnifiedAuthProvider";
14
-
import { imgCDNAtom } from "~/utils/atoms";
15
import {
16
toggleFollow,
17
useGetFollowState,
18
useGetOneToOneState,
19
} from "~/utils/followState";
20
import {
21
useInfiniteQueryAuthorFeed,
22
useQueryIdentity,
23
useQueryProfile,
24
} from "~/utils/useQuery";
25
26
export const Route = createFileRoute("/profile/$did/")({
27
component: ProfileComponent,
28
});
···
30
function ProfileComponent() {
31
// booo bad this is not always the did it might be a handle, use identity.did instead
32
const { did } = Route.useParams();
33
const navigate = useNavigate();
34
const queryClient = useQueryClient();
35
const {
···
48
const { data: profileRecord } = useQueryProfile(profileUri);
49
const profile = profileRecord?.value;
50
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
const [imgcdn] = useAtom(imgCDNAtom);
77
78
function getAvatarUrl(p: typeof profile) {
···
91
const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did;
92
const description = profile?.description || "";
93
94
-
if (isIdentityLoading) {
95
-
return (
96
-
<div className="p-4 text-center text-gray-500">Resolving profile...</div>
97
-
);
98
-
}
99
100
-
if (identityError) {
101
-
return (
102
-
<div className="p-4 text-center text-red-500">
103
-
Error: {identityError.message}
104
-
</div>
105
-
);
106
-
}
107
108
-
if (!resolvedDid) {
109
-
return (
110
-
<div className="p-4 text-center text-gray-500">Profile not found.</div>
111
-
);
112
-
}
113
114
return (
115
-
<>
116
<Header
117
title={`Profile`}
118
backButtonCallback={() => {
···
122
window.location.assign("/");
123
}
124
}}
125
/>
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
<Link
···
164
</div>
165
166
<div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5">
167
{/*
168
todo: full follow and unfollow backfill (along with partial likes backfill,
169
just enough for it to be useful)
···
171
also save it persistently
172
*/}
173
<FollowButton targetdidorhandle={did} />
174
-
<Button className="rounded-full" variant={"secondary"}>
175
... {/* todo: icon */}
176
-
</Button>
177
</div>
178
179
{/* Info Card */}
···
183
<Mutual targetdidorhandle={did} />
184
{handle}
185
</div>
186
{description && (
187
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
188
{/* {description} */}
···
192
</div>
193
</div>
194
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
199
</div>
200
-
<div>
201
-
{posts.map((post) => (
202
<UniversalPostRendererATURILoader
203
-
key={post.uri}
204
-
atUri={post.uri}
205
feedviewpost={true}
206
/>
207
-
))}
208
</div>
209
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"
221
-
>
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
-
)}
228
</div>
229
</>
230
);
231
}
···
249
{identity?.did !== agent?.did ? (
250
<>
251
{!(followRecords?.length && followRecords?.length > 0) ? (
252
-
<Button
253
onClick={(e) => {
254
e.stopPropagation();
255
toggleFollow({
···
259
queryClient: queryClient,
260
});
261
}}
262
>
263
Follow
264
-
</Button>
265
) : (
266
-
<Button
267
onClick={(e) => {
268
e.stopPropagation();
269
toggleFollow({
···
273
queryClient: queryClient,
274
});
275
}}
276
>
277
Unfollow
278
-
</Button>
279
)}
280
</>
281
) : (
282
-
<Button variant={"secondary"}>Edit Profile</Button>
283
)}
284
</>
285
);
286
}
287
288
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
289
const { agent } = useAuth();
···
1
+
import { Agent, RichText } from "@atproto/api";
2
+
import * as ATPAPI from "@atproto/api";
3
+
import { TID } from "@atproto/common-web";
4
import { useQueryClient } from "@tanstack/react-query";
5
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
6
import { useAtom } from "jotai";
7
import React, { type ReactNode, useEffect, useState } from "react";
8
9
+
import defaultpfp from "~/../public/favicon.png";
10
import { Header } from "~/components/Header";
11
+
import {
12
+
ReusableTabRoute,
13
+
useReusableTabScrollRestore,
14
+
} from "~/components/ReusableTabRoute";
15
import {
16
renderTextWithFacets,
17
UniversalPostRendererATURILoader,
18
} from "~/components/UniversalPostRenderer";
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
20
+
import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms";
21
import {
22
toggleFollow,
23
useGetFollowState,
24
useGetOneToOneState,
25
} from "~/utils/followState";
26
+
import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue";
27
import {
28
useInfiniteQueryAuthorFeed,
29
+
useQueryArbitrary,
30
+
useQueryConstellation,
31
+
useQueryConstellationLinksCountDistinctDids,
32
useQueryIdentity,
33
useQueryProfile,
34
} from "~/utils/useQuery";
35
36
+
import { Chip } from "../notifications";
37
+
38
export const Route = createFileRoute("/profile/$did/")({
39
component: ProfileComponent,
40
});
···
42
function ProfileComponent() {
43
// booo bad this is not always the did it might be a handle, use identity.did instead
44
const { did } = Route.useParams();
45
+
const { agent } = useAuth();
46
const navigate = useNavigate();
47
const queryClient = useQueryClient();
48
const {
···
61
const { data: profileRecord } = useQueryProfile(profileUri);
62
const profile = profileRecord?.value;
63
64
const [imgcdn] = useAtom(imgCDNAtom);
65
66
function getAvatarUrl(p: typeof profile) {
···
79
const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did;
80
const description = profile?.description || "";
81
82
+
const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord;
83
84
+
const resultwhateversure = useQueryConstellationLinksCountDistinctDids(resolvedDid ? {
85
+
method: "/links/count/distinct-dids",
86
+
collection: "app.bsky.graph.follow",
87
+
target: resolvedDid,
88
+
path: ".subject"
89
+
} : undefined)
90
91
+
const followercount = resultwhateversure?.data?.total;
92
93
return (
94
+
<div className="">
95
<Header
96
title={`Profile`}
97
backButtonCallback={() => {
···
101
window.location.assign("/");
102
}
103
}}
104
+
bottomBorderDisabled={true}
105
/>
106
{/* <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">
107
<Link
···
144
</div>
145
146
<div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5">
147
+
<BiteButton targetdidorhandle={did} />
148
{/*
149
todo: full follow and unfollow backfill (along with partial likes backfill,
150
just enough for it to be useful)
···
152
also save it persistently
153
*/}
154
<FollowButton targetdidorhandle={did} />
155
+
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
156
... {/* todo: icon */}
157
+
</button>
158
</div>
159
160
{/* Info Card */}
···
164
<Mutual targetdidorhandle={did} />
165
{handle}
166
</div>
167
+
<div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2">
168
+
<Link to="/profile/$did/followers" params={{did: did}}>{followercount && (<span className="mr-1 text-gray-900 dark:text-gray-200 font-medium">{followercount}</span>)}Followers</Link>
169
+
-
170
+
<Link to="/profile/$did/follows" params={{did: did}}>Follows</Link>
171
+
</div>
172
{description && (
173
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
174
{/* {description} */}
···
178
</div>
179
</div>
180
181
+
{/* this should not be rendered until its ready (the top profile layout is stable) */}
182
+
{isReady ? (
183
+
<ReusableTabRoute
184
+
route={`Profile` + did}
185
+
tabs={{
186
+
Posts: <PostsTab did={did} />,
187
+
Reposts: <RepostsTab did={did} />,
188
+
Feeds: <FeedsTab did={did} />,
189
+
Lists: <ListsTab did={did} />,
190
+
...(identity?.did === agent?.did
191
+
? { Likes: <SelfLikesTab did={did} /> }
192
+
: {}),
193
+
}}
194
+
/>
195
+
) : isIdentityLoading ? (
196
+
<div className="p-4 text-center text-gray-500">
197
+
Resolving profile...
198
</div>
199
+
) : identityError ? (
200
+
<div className="p-4 text-center text-red-500">
201
+
Error: {identityError.message}
202
+
</div>
203
+
) : !resolvedDid ? (
204
+
<div className="p-4 text-center text-gray-500">Profile not found.</div>
205
+
) : (
206
+
<div className="p-4 text-center text-gray-500">
207
+
Loading profile content...
208
+
</div>
209
+
)}
210
+
</div>
211
+
);
212
+
}
213
+
214
+
export type ProfilePostsFilter = {
215
+
posts: boolean,
216
+
replies: boolean,
217
+
mediaOnly: boolean,
218
+
}
219
+
export const defaultProfilePostsFilter: ProfilePostsFilter = {
220
+
posts: true,
221
+
replies: true,
222
+
mediaOnly: false,
223
+
}
224
+
225
+
function ProfilePostsFilterChipBar({filters, toggle}:{filters: ProfilePostsFilter | null, toggle: (key: keyof ProfilePostsFilter) => void}) {
226
+
const empty = (!filters?.replies && !filters?.posts);
227
+
const almostEmpty = (!filters?.replies && filters?.posts);
228
+
229
+
useEffect(() => {
230
+
if (empty) {
231
+
toggle("posts")
232
+
}
233
+
}, [empty, toggle]);
234
+
235
+
return (
236
+
<div className="flex flex-row flex-wrap gap-2 px-4 pt-4">
237
+
<Chip
238
+
state={filters?.posts ?? true}
239
+
text="Posts"
240
+
onClick={() => almostEmpty ? null : toggle("posts")}
241
+
/>
242
+
<Chip
243
+
state={filters?.replies ?? true}
244
+
text="Replies"
245
+
onClick={() => toggle("replies")}
246
+
/>
247
+
<Chip
248
+
state={filters?.mediaOnly ?? false}
249
+
text="Media Only"
250
+
onClick={() => toggle("mediaOnly")}
251
+
/>
252
+
</div>
253
+
);
254
+
}
255
+
256
+
function PostsTab({ did }: { did: string }) {
257
+
// todo: this needs to be a (non-persisted is fine) atom to survive navigation
258
+
const [filterses, setFilterses] = useAtom(profileChipsAtom);
259
+
const filters = filterses?.[did];
260
+
const setFilters = (obj: ProfilePostsFilter) => {
261
+
setFilterses((prev)=>{
262
+
return{
263
+
...prev,
264
+
[did]: obj
265
+
}
266
+
})
267
+
}
268
+
useEffect(()=>{
269
+
if (!filters) {
270
+
setFilters(defaultProfilePostsFilter);
271
+
}
272
+
})
273
+
useReusableTabScrollRestore(`Profile` + did);
274
+
const queryClient = useQueryClient();
275
+
const {
276
+
data: identity,
277
+
isLoading: isIdentityLoading,
278
+
error: identityError,
279
+
} = useQueryIdentity(did);
280
+
281
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
282
+
283
+
const {
284
+
data: postsData,
285
+
fetchNextPage,
286
+
hasNextPage,
287
+
isFetchingNextPage,
288
+
isLoading: arePostsLoading,
289
+
} = useInfiniteQueryAuthorFeed(resolvedDid, identity?.pds);
290
+
291
+
React.useEffect(() => {
292
+
if (postsData) {
293
+
postsData.pages.forEach((page) => {
294
+
page.records.forEach((record) => {
295
+
if (!queryClient.getQueryData(["post", record.uri])) {
296
+
queryClient.setQueryData(["post", record.uri], record);
297
+
}
298
+
});
299
+
});
300
+
}
301
+
}, [postsData, queryClient]);
302
+
303
+
const posts = React.useMemo(
304
+
() => postsData?.pages.flatMap((page) => page.records) ?? [],
305
+
[postsData]
306
+
);
307
+
308
+
const toggle = (key: keyof ProfilePostsFilter) => {
309
+
setFilterses(prev => {
310
+
const existing = prev[did] ?? { posts: false, replies: false, mediaOnly: false }; // default
311
+
312
+
return {
313
+
...prev,
314
+
[did]: {
315
+
...existing,
316
+
[key]: !existing[key], // safely negate
317
+
},
318
+
};
319
+
});
320
+
};
321
+
322
+
return (
323
+
<>
324
+
{/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
325
+
Posts
326
+
</div> */}
327
+
<ProfilePostsFilterChipBar filters={filters} toggle={toggle} />
328
+
<div>
329
+
{posts.map((post) => (
330
+
<UniversalPostRendererATURILoader
331
+
key={post.uri}
332
+
atUri={post.uri}
333
+
feedviewpost={true}
334
+
filterNoReplies={!filters?.replies}
335
+
filterMustHaveMedia={filters?.mediaOnly}
336
+
filterMustBeReply={!filters?.posts}
337
+
/>
338
+
))}
339
+
</div>
340
+
341
+
{/* Loading and "Load More" states */}
342
+
{arePostsLoading && posts.length === 0 && (
343
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
344
+
)}
345
+
{isFetchingNextPage && (
346
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
347
+
)}
348
+
{hasNextPage && !isFetchingNextPage && (
349
+
<button
350
+
onClick={() => fetchNextPage()}
351
+
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"
352
+
>
353
+
Load More Posts
354
+
</button>
355
+
)}
356
+
{posts.length === 0 && !arePostsLoading && (
357
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
358
+
)}
359
+
</>
360
+
);
361
+
}
362
+
363
+
function RepostsTab({ did }: { did: string }) {
364
+
useReusableTabScrollRestore(`Profile` + did);
365
+
const {
366
+
data: identity,
367
+
isLoading: isIdentityLoading,
368
+
error: identityError,
369
+
} = useQueryIdentity(did);
370
+
371
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
372
+
373
+
const {
374
+
data: repostsData,
375
+
fetchNextPage,
376
+
hasNextPage,
377
+
isFetchingNextPage,
378
+
isLoading: arePostsLoading,
379
+
} = useInfiniteQueryAuthorFeed(
380
+
resolvedDid,
381
+
identity?.pds,
382
+
"app.bsky.feed.repost"
383
+
);
384
+
385
+
const reposts = React.useMemo(
386
+
() => repostsData?.pages.flatMap((page) => page.records) ?? [],
387
+
[repostsData]
388
+
);
389
+
390
+
return (
391
+
<>
392
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
393
+
Reposts
394
+
</div>
395
+
<div>
396
+
{reposts.map((repost) => {
397
+
if (
398
+
!repost ||
399
+
!repost?.value ||
400
+
!repost?.value?.subject ||
401
+
// @ts-expect-error blehhhhh
402
+
!repost?.value?.subject?.uri
403
+
)
404
+
return;
405
+
const repostRecord =
406
+
repost.value as unknown as ATPAPI.AppBskyFeedRepost.Record;
407
+
return (
408
<UniversalPostRendererATURILoader
409
+
key={repostRecord.subject.uri}
410
+
atUri={repostRecord.subject.uri}
411
feedviewpost={true}
412
+
repostedby={repost.uri}
413
/>
414
+
);
415
+
})}
416
+
</div>
417
+
418
+
{/* Loading and "Load More" states */}
419
+
{arePostsLoading && reposts.length === 0 && (
420
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
421
+
)}
422
+
{isFetchingNextPage && (
423
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
424
+
)}
425
+
{hasNextPage && !isFetchingNextPage && (
426
+
<button
427
+
onClick={() => fetchNextPage()}
428
+
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"
429
+
>
430
+
Load More Posts
431
+
</button>
432
+
)}
433
+
{reposts.length === 0 && !arePostsLoading && (
434
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
435
+
)}
436
+
</>
437
+
);
438
+
}
439
+
440
+
function FeedsTab({ did }: { did: string }) {
441
+
useReusableTabScrollRestore(`Profile` + did);
442
+
const {
443
+
data: identity,
444
+
isLoading: isIdentityLoading,
445
+
error: identityError,
446
+
} = useQueryIdentity(did);
447
+
448
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
449
+
450
+
const {
451
+
data: feedsData,
452
+
fetchNextPage,
453
+
hasNextPage,
454
+
isFetchingNextPage,
455
+
isLoading: arePostsLoading,
456
+
} = useInfiniteQueryAuthorFeed(
457
+
resolvedDid,
458
+
identity?.pds,
459
+
"app.bsky.feed.generator"
460
+
);
461
+
462
+
const feeds = React.useMemo(
463
+
() => feedsData?.pages.flatMap((page) => page.records) ?? [],
464
+
[feedsData]
465
+
);
466
+
467
+
return (
468
+
<>
469
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
470
+
Feeds
471
+
</div>
472
+
<div>
473
+
{feeds.map((feed) => {
474
+
if (!feed || !feed?.value) return;
475
+
const feedGenRecord =
476
+
feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record;
477
+
return <FeedItemRender feed={feed as any} key={feed.uri} />;
478
+
})}
479
+
</div>
480
+
481
+
{/* Loading and "Load More" states */}
482
+
{arePostsLoading && feeds.length === 0 && (
483
+
<div className="p-4 text-center text-gray-500">Loading feeds...</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 Feeds
494
+
</button>
495
+
)}
496
+
{feeds.length === 0 && !arePostsLoading && (
497
+
<div className="p-4 text-center text-gray-500">No feeds found.</div>
498
+
)}
499
+
</>
500
+
);
501
+
}
502
+
503
+
export function FeedItemRenderAturiLoader({
504
+
aturi,
505
+
listmode,
506
+
disableBottomBorder,
507
+
disablePropagation,
508
+
}: {
509
+
aturi: string;
510
+
listmode?: boolean;
511
+
disableBottomBorder?: boolean;
512
+
disablePropagation?: boolean;
513
+
}) {
514
+
const { data: record } = useQueryArbitrary(aturi);
515
+
516
+
if (!record) return;
517
+
return (
518
+
<FeedItemRender
519
+
listmode={listmode}
520
+
feed={record}
521
+
disableBottomBorder={disableBottomBorder}
522
+
disablePropagation={disablePropagation}
523
+
/>
524
+
);
525
+
}
526
+
527
+
export function FeedItemRender({
528
+
feed,
529
+
listmode,
530
+
disableBottomBorder,
531
+
disablePropagation,
532
+
}: {
533
+
feed: { uri: string; cid: string; value: any };
534
+
listmode?: boolean;
535
+
disableBottomBorder?: boolean;
536
+
disablePropagation?: boolean;
537
+
}) {
538
+
const name = listmode
539
+
? (feed.value?.name as string)
540
+
: (feed.value?.displayName as string);
541
+
const aturi = new ATPAPI.AtUri(feed.uri);
542
+
const { data: identity } = useQueryIdentity(aturi.host);
543
+
const resolvedDid = identity?.did;
544
+
const [imgcdn] = useAtom(imgCDNAtom);
545
+
546
+
function getAvatarThumbnailUrl(f: typeof feed) {
547
+
const link = f?.value.avatar?.ref?.["$link"];
548
+
if (!link || !resolvedDid) return null;
549
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
550
+
}
551
+
552
+
const { data: likes } = useQueryConstellation(
553
+
// @ts-expect-error overloads sucks
554
+
!listmode
555
+
? {
556
+
target: feed.uri,
557
+
method: "/links/count",
558
+
collection: "app.bsky.feed.like",
559
+
path: ".subject.uri",
560
+
}
561
+
: undefined
562
+
);
563
+
564
+
return (
565
+
<Link
566
+
className={`px-4 py-4 ${!disableBottomBorder && "border-b"} flex flex-col gap-1`}
567
+
to="/profile/$did/feed/$rkey"
568
+
params={{ did: aturi.host, rkey: aturi.rkey }}
569
+
onClick={(e) => {
570
+
e.stopPropagation();
571
+
}}
572
+
>
573
+
<div className="flex flex-row gap-3">
574
+
<div className="min-w-10 min-h-10">
575
+
<img
576
+
src={getAvatarThumbnailUrl(feed) || defaultpfp}
577
+
className="h-10 w-10 rounded border"
578
+
/>
579
</div>
580
+
<div className="flex flex-col">
581
+
<span className="">{name}</span>
582
+
<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">
583
+
{feed.value.did || aturi.rkey}
584
+
</span>
585
+
</div>
586
+
<div className="flex-1" />
587
+
{/* <div className="button bg-red-500 rounded-full min-w-[60px]" /> */}
588
+
</div>
589
+
<span className=" text-sm">{feed.value?.description}</span>
590
+
{!listmode && (
591
+
<span className=" text-sm dark:text-gray-400 text-gray-500">
592
+
Liked by {((likes as unknown as any)?.total as number) || 0} users
593
+
</span>
594
+
)}
595
+
</Link>
596
+
);
597
+
}
598
599
+
function ListsTab({ did }: { did: string }) {
600
+
useReusableTabScrollRestore(`Profile` + did);
601
+
const {
602
+
data: identity,
603
+
isLoading: isIdentityLoading,
604
+
error: identityError,
605
+
} = useQueryIdentity(did);
606
+
607
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
608
+
609
+
const {
610
+
data: feedsData,
611
+
fetchNextPage,
612
+
hasNextPage,
613
+
isFetchingNextPage,
614
+
isLoading: arePostsLoading,
615
+
} = useInfiniteQueryAuthorFeed(
616
+
resolvedDid,
617
+
identity?.pds,
618
+
"app.bsky.graph.list"
619
+
);
620
+
621
+
const feeds = React.useMemo(
622
+
() => feedsData?.pages.flatMap((page) => page.records) ?? [],
623
+
[feedsData]
624
+
);
625
+
626
+
return (
627
+
<>
628
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
629
+
Feeds
630
</div>
631
+
<div>
632
+
{feeds.map((feed) => {
633
+
if (!feed || !feed?.value) return;
634
+
const feedGenRecord =
635
+
feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record;
636
+
return (
637
+
<FeedItemRender listmode={true} feed={feed as any} key={feed.uri} />
638
+
);
639
+
})}
640
+
</div>
641
+
642
+
{/* Loading and "Load More" states */}
643
+
{arePostsLoading && feeds.length === 0 && (
644
+
<div className="p-4 text-center text-gray-500">Loading lists...</div>
645
+
)}
646
+
{isFetchingNextPage && (
647
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
648
+
)}
649
+
{hasNextPage && !isFetchingNextPage && (
650
+
<button
651
+
onClick={() => fetchNextPage()}
652
+
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"
653
+
>
654
+
Load More Lists
655
+
</button>
656
+
)}
657
+
{feeds.length === 0 && !arePostsLoading && (
658
+
<div className="p-4 text-center text-gray-500">No lists found.</div>
659
+
)}
660
+
</>
661
+
);
662
+
}
663
+
664
+
function SelfLikesTab({ did }: { did: string }) {
665
+
useReusableTabScrollRestore(`Profile` + did);
666
+
const {
667
+
data: identity,
668
+
isLoading: isIdentityLoading,
669
+
error: identityError,
670
+
} = useQueryIdentity(did);
671
+
672
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
673
+
674
+
const {
675
+
data: likesData,
676
+
fetchNextPage,
677
+
hasNextPage,
678
+
isFetchingNextPage,
679
+
isLoading: arePostsLoading,
680
+
} = useInfiniteQueryAuthorFeed(
681
+
resolvedDid,
682
+
identity?.pds,
683
+
"app.bsky.feed.like"
684
+
);
685
+
686
+
const likes = React.useMemo(
687
+
() => likesData?.pages.flatMap((page) => page.records) ?? [],
688
+
[likesData]
689
+
);
690
+
691
+
const { setFastState } = useFastSetLikesFromFeed();
692
+
const seededRef = React.useRef(new Set<string>());
693
+
694
+
useEffect(() => {
695
+
for (const like of likes) {
696
+
if (!seededRef.current.has(like.uri)) {
697
+
seededRef.current.add(like.uri);
698
+
const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
699
+
setFastState(record.subject.uri, {
700
+
target: record.subject.uri,
701
+
uri: like.uri,
702
+
cid: like.cid,
703
+
});
704
+
}
705
+
}
706
+
}, [likes, setFastState]);
707
+
708
+
return (
709
+
<>
710
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
711
+
Likes
712
+
</div>
713
+
<div>
714
+
{likes.map((like) => {
715
+
if (
716
+
!like ||
717
+
!like?.value ||
718
+
!like?.value?.subject ||
719
+
// @ts-expect-error blehhhhh
720
+
!like?.value?.subject?.uri
721
+
)
722
+
return;
723
+
const likeRecord =
724
+
like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
725
+
return (
726
+
<UniversalPostRendererATURILoader
727
+
key={likeRecord.subject.uri}
728
+
atUri={likeRecord.subject.uri}
729
+
feedviewpost={true}
730
+
/>
731
+
);
732
+
})}
733
+
</div>
734
+
735
+
{/* Loading and "Load More" states */}
736
+
{arePostsLoading && likes.length === 0 && (
737
+
<div className="p-4 text-center text-gray-500">Loading likes...</div>
738
+
)}
739
+
{isFetchingNextPage && (
740
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
741
+
)}
742
+
{hasNextPage && !isFetchingNextPage && (
743
+
<button
744
+
onClick={() => fetchNextPage()}
745
+
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"
746
+
>
747
+
Load More Likes
748
+
</button>
749
+
)}
750
+
{likes.length === 0 && !arePostsLoading && (
751
+
<div className="p-4 text-center text-gray-500">No likes found.</div>
752
+
)}
753
</>
754
);
755
}
···
773
{identity?.did !== agent?.did ? (
774
<>
775
{!(followRecords?.length && followRecords?.length > 0) ? (
776
+
<button
777
onClick={(e) => {
778
e.stopPropagation();
779
toggleFollow({
···
783
queryClient: queryClient,
784
});
785
}}
786
+
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]"
787
>
788
Follow
789
+
</button>
790
) : (
791
+
<button
792
onClick={(e) => {
793
e.stopPropagation();
794
toggleFollow({
···
798
queryClient: queryClient,
799
});
800
}}
801
+
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]"
802
>
803
Unfollow
804
+
</button>
805
)}
806
</>
807
) : (
808
+
<button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]">
809
+
Edit Profile
810
+
</button>
811
)}
812
</>
813
);
814
}
815
+
816
+
export function BiteButton({
817
+
targetdidorhandle,
818
+
}: {
819
+
targetdidorhandle: string;
820
+
}) {
821
+
const { agent } = useAuth();
822
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
823
+
const [show] = useAtom(enableBitesAtom);
824
+
825
+
if (!show) return
826
+
827
+
return (
828
+
<>
829
+
<button
830
+
onClick={(e) => {
831
+
e.stopPropagation();
832
+
sendBite({
833
+
agent: agent || undefined,
834
+
targetDid: identity?.did,
835
+
});
836
+
}}
837
+
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]"
838
+
>
839
+
Bite
840
+
</button>
841
+
</>
842
+
);
843
+
}
844
+
845
+
function sendBite({
846
+
agent,
847
+
targetDid,
848
+
}: {
849
+
agent?: Agent;
850
+
targetDid?: string;
851
+
}) {
852
+
if (!agent?.did || !targetDid) return;
853
+
const newRecord = {
854
+
repo: agent.did,
855
+
collection: "net.wafrn.feed.bite",
856
+
rkey: TID.next().toString(),
857
+
record: {
858
+
$type: "net.wafrn.feed.bite",
859
+
subject: "at://"+targetDid,
860
+
createdAt: new Date().toISOString(),
861
+
},
862
+
};
863
+
864
+
agent.com.atproto.repo.createRecord(newRecord).catch((err) => {
865
+
console.error("Bite failed:", err);
866
+
});
867
+
}
868
+
869
870
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
871
const { agent } = useAuth();
+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
import { AtUri } from "@atproto/api";
2
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
-
import { createFileRoute, Outlet } from "@tanstack/react-router";
4
import { useAtom } from "jotai";
5
import React, { useLayoutEffect } from "react";
6
···
52
nopics?: boolean;
53
lightboxCallback?: (d: LightboxProps) => void;
54
}) {
55
//const { get, set } = usePersistentStore();
56
const queryClient = useQueryClient();
57
// const [resolvedDid, setResolvedDid] = React.useState<string | null>(null);
···
190
data: identity,
191
isLoading: isIdentityLoading,
192
error: identityError,
193
-
} = useQueryIdentity(did);
194
195
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
196
197
const atUri = React.useMemo(
198
() =>
199
-
resolvedDid
200
? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}`
201
: undefined,
202
-
[resolvedDid, rkey]
203
);
204
205
-
const { data: mainPost } = useQueryPost(atUri);
206
207
console.log("atUri",atUri)
208
···
215
);
216
217
// @ts-expect-error i hate overloads
218
-
const { data: links } = useQueryConstellation(atUri?{
219
method: "/links/all",
220
target: atUri,
221
} : {
···
248
}, [links]);
249
250
const { data: opreplies } = useQueryConstellation(
251
-
!!opdid && replyCount && replyCount >= 25
252
? {
253
method: "/links",
254
target: atUri,
···
289
path: ".reply.parent.uri",
290
}
291
),
292
-
enabled: !!atUri,
293
});
294
295
const {
···
371
const [layoutReady, setLayoutReady] = React.useState(false);
372
373
useLayoutEffect(() => {
374
if (parents.length > 0 && !layoutReady && mainPostRef.current) {
375
const mainPostElement = mainPostRef.current;
376
···
389
// eslint-disable-next-line react-hooks/set-state-in-effect
390
setLayoutReady(true);
391
}
392
-
}, [parents, layoutReady]);
393
394
395
const [slingshoturl] = useAtom(slingshotURLAtom)
396
397
React.useEffect(() => {
398
-
if (parentsLoading) {
399
setLayoutReady(false);
400
}
401
···
403
setLayoutReady(true);
404
hasPerformedInitialLayout.current = true;
405
}
406
-
}, [parentsLoading, mainPost]);
407
408
React.useEffect(() => {
409
if (!mainPost?.value?.reply?.parent?.uri) {
···
444
return () => {
445
ignore = true;
446
};
447
-
}, [mainPost, queryClient]);
448
449
-
if (!did || !rkey) return <div>Invalid post URI</div>;
450
-
if (isIdentityLoading) return <div>Resolving handle...</div>;
451
-
if (identityError)
452
return <div style={{ color: "red" }}>{identityError.message}</div>;
453
-
if (!atUri) return <div>Could not construct post URI.</div>;
454
455
return (
456
<>
457
<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
-
/>
468
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>
476
</div>
477
-
Loading conversation...
478
</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) => (
484
<UniversalPostRendererATURILoader
485
-
key={parent.uri}
486
-
atUri={parent.uri}
487
-
topReplyLine={index > 0}
488
-
bottomReplyLine={true}
489
-
bottomBorder={false}
490
/>
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
-
>
511
<div
512
-
className="text-gray-500 dark:text-gray-400 text-sm font-bold"
513
style={{
514
-
fontSize: 18,
515
-
margin: "12px 16px 12px 16px",
516
-
fontWeight: 600,
517
}}
518
>
519
-
Replies
520
</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>
544
</>
545
);
546
}
···
1
import { AtUri } from "@atproto/api";
2
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router";
4
import { useAtom } from "jotai";
5
import React, { useLayoutEffect } from "react";
6
···
52
nopics?: boolean;
53
lightboxCallback?: (d: LightboxProps) => void;
54
}) {
55
+
const matchRoute = useMatchRoute()
56
+
const showMainPostRoute = !!matchRoute({ to: '/profile/$did/post/$rkey' }) || !!matchRoute({ to: '/profile/$did/post/$rkey/image/$i' })
57
+
58
//const { get, set } = usePersistentStore();
59
const queryClient = useQueryClient();
60
// const [resolvedDid, setResolvedDid] = React.useState<string | null>(null);
···
193
data: identity,
194
isLoading: isIdentityLoading,
195
error: identityError,
196
+
} = useQueryIdentity(showMainPostRoute ? did : undefined);
197
198
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
199
200
const atUri = React.useMemo(
201
() =>
202
+
resolvedDid && showMainPostRoute
203
? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}`
204
: undefined,
205
+
[resolvedDid, rkey, showMainPostRoute]
206
);
207
208
+
const { data: mainPost } = useQueryPost(showMainPostRoute ? atUri : undefined);
209
210
console.log("atUri",atUri)
211
···
218
);
219
220
// @ts-expect-error i hate overloads
221
+
const { data: links } = useQueryConstellation(atUri&&showMainPostRoute?{
222
method: "/links/all",
223
target: atUri,
224
} : {
···
251
}, [links]);
252
253
const { data: opreplies } = useQueryConstellation(
254
+
showMainPostRoute && !!opdid && replyCount && replyCount >= 25
255
? {
256
method: "/links",
257
target: atUri,
···
292
path: ".reply.parent.uri",
293
}
294
),
295
+
enabled: !!atUri && showMainPostRoute,
296
});
297
298
const {
···
374
const [layoutReady, setLayoutReady] = React.useState(false);
375
376
useLayoutEffect(() => {
377
+
if (!showMainPostRoute) return
378
if (parents.length > 0 && !layoutReady && mainPostRef.current) {
379
const mainPostElement = mainPostRef.current;
380
···
393
// eslint-disable-next-line react-hooks/set-state-in-effect
394
setLayoutReady(true);
395
}
396
+
}, [parents, layoutReady, showMainPostRoute]);
397
398
399
const [slingshoturl] = useAtom(slingshotURLAtom)
400
401
React.useEffect(() => {
402
+
if (parentsLoading || !showMainPostRoute) {
403
setLayoutReady(false);
404
}
405
···
407
setLayoutReady(true);
408
hasPerformedInitialLayout.current = true;
409
}
410
+
}, [parentsLoading, mainPost, showMainPostRoute]);
411
412
React.useEffect(() => {
413
if (!mainPost?.value?.reply?.parent?.uri) {
···
448
return () => {
449
ignore = true;
450
};
451
+
}, [mainPost, queryClient, slingshoturl]);
452
453
+
if ((!did || !rkey) && showMainPostRoute) return <div>Invalid post URI</div>;
454
+
if (isIdentityLoading && showMainPostRoute) return <div>Resolving handle...</div>;
455
+
if (identityError && showMainPostRoute)
456
return <div style={{ color: "red" }}>{identityError.message}</div>;
457
+
if (!atUri && showMainPostRoute) return <div>Could not construct post URI.</div>;
458
459
return (
460
<>
461
<Outlet />
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
+
/>
473
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...
483
</div>
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
+
))}
497
</div>
498
+
<div ref={mainPostRef}>
499
<UniversalPostRendererATURILoader
500
+
atUri={atUri!}
501
+
detailed={true}
502
+
topReplyLine={parentsLoading || parents.length > 0}
503
+
nopics={!!nopics}
504
+
lightboxCallback={lightboxCallback}
505
/>
506
+
</div>
507
<div
508
style={{
509
+
maxWidth: 600,
510
+
//margin: "0px auto 0",
511
+
padding: 0,
512
+
minHeight: "80dvh",
513
+
paddingBottom: "20dvh",
514
}}
515
>
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>
548
</div>
549
+
</>)}
550
</>
551
);
552
}
+118
-16
src/routes/settings.tsx
+118
-16
src/routes/settings.tsx
···
1
import { createFileRoute } from "@tanstack/react-router";
2
-
import { useAtom } from "jotai";
3
-
import { Slider } from "radix-ui";
4
5
import { Header } from "~/components/Header";
6
import Login from "~/components/Login";
···
11
defaultImgCDN,
12
defaultslingshotURL,
13
defaultVideoCDN,
14
hueAtom,
15
imgCDNAtom,
16
slingshotURLAtom,
···
38
<Login />
39
</div>
40
<div className="h-4" />
41
<TextInputSetting
42
atom={constellationURLAtom}
43
title={"Constellation"}
···
67
init={defaultVideoCDN}
68
/>
69
70
-
<Hue />
71
-
<p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm">
72
-
please restart/refresh the app if changes arent applying correctly
73
</p>
74
</>
75
);
76
}
77
function Hue() {
78
const [hue, setHue] = useAtom(hueAtom);
79
return (
80
-
<div className="flex flex-col px-4 mt-4 ">
81
-
<span className="z-10">Hue</span>
82
-
<div className="flex flex-row items-center gap-4">
83
-
<SliderComponent
84
-
atom={hueAtom}
85
-
max={360}
86
-
/>
87
<button
88
onClick={() => setHue(defaulthue ?? 28)}
89
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
···
154
);
155
}
156
157
-
158
interface SliderProps {
159
atom: typeof hueAtom;
160
min?: number;
···
168
max = 100,
169
step = 1,
170
}) => {
171
-
172
-
const [value, setValue] = useAtom(atom)
173
174
return (
175
<Slider.Root
···
186
<Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" />
187
</Slider.Root>
188
);
189
-
};
···
1
import { createFileRoute } from "@tanstack/react-router";
2
+
import { useAtom, useAtomValue, useSetAtom } from "jotai";
3
+
import { Slider, Switch } from "radix-ui";
4
+
import { useEffect, useState } from "react";
5
6
import { Header } from "~/components/Header";
7
import Login from "~/components/Login";
···
12
defaultImgCDN,
13
defaultslingshotURL,
14
defaultVideoCDN,
15
+
enableBitesAtom,
16
+
enableBridgyTextAtom,
17
+
enableWafrnTextAtom,
18
hueAtom,
19
imgCDNAtom,
20
slingshotURLAtom,
···
42
<Login />
43
</div>
44
<div className="h-4" />
45
+
46
+
<SettingHeading title="Personalization" top />
47
+
<Hue />
48
+
49
+
<SettingHeading title="Network Configuration" />
50
+
<div className="flex flex-col px-4 pb-2">
51
+
<span className="text-md">Service Endpoints</span>
52
+
<span className="text-sm text-gray-500 dark:text-gray-400">
53
+
Customize the servers to be used by the app
54
+
</span>
55
+
</div>
56
<TextInputSetting
57
atom={constellationURLAtom}
58
title={"Constellation"}
···
82
init={defaultVideoCDN}
83
/>
84
85
+
<SettingHeading title="Experimental" />
86
+
<SwitchSetting
87
+
atom={enableBitesAtom}
88
+
title={"Bites"}
89
+
description={"Enable Wafrn Bites to bite and be bitten by other people"}
90
+
//init={false}
91
+
/>
92
+
<div className="h-4" />
93
+
<SwitchSetting
94
+
atom={enableBridgyTextAtom}
95
+
title={"Bridgy Text"}
96
+
description={
97
+
"Show the original text of posts bridged from the Fediverse"
98
+
}
99
+
//init={false}
100
+
/>
101
+
<div className="h-4" />
102
+
<SwitchSetting
103
+
atom={enableWafrnTextAtom}
104
+
title={"Wafrn Text"}
105
+
description={
106
+
"Show the original text of posts from Wafrn instances"
107
+
}
108
+
//init={false}
109
+
/>
110
+
<p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4">
111
+
Notice: Please restart/refresh the app if changes arent applying
112
+
correctly
113
</p>
114
</>
115
);
116
}
117
+
118
+
export function SettingHeading({
119
+
title,
120
+
top,
121
+
}: {
122
+
title: string;
123
+
top?: boolean;
124
+
}) {
125
+
return (
126
+
<div
127
+
className="px-4"
128
+
style={{ marginTop: top ? 0 : 18, paddingBottom: 12 }}
129
+
>
130
+
<span className=" text-sm font-medium text-gray-500 dark:text-gray-400">
131
+
{title}
132
+
</span>
133
+
</div>
134
+
);
135
+
}
136
+
137
+
export function SwitchSetting({
138
+
atom,
139
+
title,
140
+
description,
141
+
}: {
142
+
atom: typeof enableBitesAtom;
143
+
title?: string;
144
+
description?: string;
145
+
}) {
146
+
const value = useAtomValue(atom);
147
+
const setValue = useSetAtom(atom);
148
+
149
+
const [hydrated, setHydrated] = useState(false);
150
+
// eslint-disable-next-line react-hooks/set-state-in-effect
151
+
useEffect(() => setHydrated(true), []);
152
+
153
+
if (!hydrated) {
154
+
// Avoid rendering Switch until we know storage is loaded
155
+
return null;
156
+
}
157
+
158
+
return (
159
+
<div className="flex items-center gap-4 px-4 ">
160
+
<label htmlFor={`switch-${title}`} className="flex flex-row flex-1">
161
+
<div className="flex flex-col">
162
+
<span className="text-md">{title}</span>
163
+
<span className="text-sm text-gray-500 dark:text-gray-400">
164
+
{description}
165
+
</span>
166
+
</div>
167
+
</label>
168
+
169
+
<Switch.Root
170
+
id={`switch-${title}`}
171
+
checked={value}
172
+
onCheckedChange={(v) => setValue(v)}
173
+
className="m3switch root"
174
+
>
175
+
<Switch.Thumb className="m3switch thumb " />
176
+
</Switch.Root>
177
+
</div>
178
+
);
179
+
}
180
+
181
function Hue() {
182
const [hue, setHue] = useAtom(hueAtom);
183
return (
184
+
<div className="flex flex-col px-4">
185
+
<span className="z-[2] text-md">Hue</span>
186
+
<span className="z-[2] text-sm text-gray-500 dark:text-gray-400">
187
+
Change the colors of the app
188
+
</span>
189
+
<div className="z-[1] flex flex-row items-center gap-4">
190
+
<SliderComponent atom={hueAtom} max={360} />
191
<button
192
onClick={() => setHue(defaulthue ?? 28)}
193
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
···
258
);
259
}
260
261
interface SliderProps {
262
atom: typeof hueAtom;
263
min?: number;
···
271
max = 100,
272
step = 1,
273
}) => {
274
+
const [value, setValue] = useAtom(atom);
275
276
return (
277
<Slider.Root
···
288
<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" />
289
</Slider.Root>
290
);
291
+
};
+137
src/styles/app.css
+137
src/styles/app.css
···
197
198
/* focus ring */
199
.m3input-field.m3input-label.m3input-border input:focus {
200
border-color: var(--m3input-focus-color);
201
/*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/
202
}
203
···
231
/* radix i love you but like cmon man */
232
body[data-scroll-locked]{
233
margin-left: var(--removed-body-scroll-bar-size) !important;
234
}
···
197
198
/* focus ring */
199
.m3input-field.m3input-label.m3input-border input:focus {
200
+
/*border-color: var(--m3input-focus-color);*/
201
border-color: var(--m3input-focus-color);
202
+
box-shadow: 0 0 0 1px var(--m3input-focus-color);
203
/*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/
204
}
205
···
233
/* radix i love you but like cmon man */
234
body[data-scroll-locked]{
235
margin-left: var(--removed-body-scroll-bar-size) !important;
236
+
}
237
+
238
+
/* radix tabs */
239
+
240
+
.m3tab[data-radix-collection-item] {
241
+
flex: 1;
242
+
display: flex;
243
+
padding: 12px 8px;
244
+
align-items: center;
245
+
justify-content: center;
246
+
color: var(--color-gray-500);
247
+
font-weight: 500;
248
+
&:hover {
249
+
background-color: var(--color-gray-100);
250
+
cursor: pointer;
251
+
}
252
+
&[aria-selected="true"] {
253
+
color: var(--color-gray-950);
254
+
&::before{
255
+
content: "";
256
+
position: absolute;
257
+
width: min(80px, 80%);
258
+
border-radius: 99px 99px 0px 0px ;
259
+
height: 3px;
260
+
bottom: 0;
261
+
background-color: var(--color-gray-400);
262
+
}
263
+
}
264
+
}
265
+
266
+
@media (prefers-color-scheme: dark) {
267
+
.m3tab[data-radix-collection-item] {
268
+
color: var(--color-gray-400);
269
+
&:hover {
270
+
background-color: var(--color-gray-900);
271
+
cursor: pointer;
272
+
}
273
+
&[aria-selected="true"] {
274
+
color: var(--color-gray-50);
275
+
&::before{
276
+
background-color: var(--color-gray-500);
277
+
}
278
+
}
279
+
}
280
+
}
281
+
282
+
:root{
283
+
--thumb-size: 2rem;
284
+
--root-size: 3.25rem;
285
+
286
+
--switch-off-border: var(--color-gray-400);
287
+
--switch-off-bg: var(--color-gray-200);
288
+
--switch-off-thumb: var(--color-gray-400);
289
+
290
+
291
+
--switch-on-bg: var(--color-gray-500);
292
+
--switch-on-thumb: var(--color-gray-50);
293
+
294
+
}
295
+
@media (prefers-color-scheme: dark) {
296
+
:root {
297
+
--switch-off-border: var(--color-gray-500);
298
+
--switch-off-bg: var(--color-gray-800);
299
+
--switch-off-thumb: var(--color-gray-500);
300
+
301
+
302
+
--switch-on-bg: var(--color-gray-400);
303
+
--switch-on-thumb: var(--color-gray-700);
304
+
}
305
+
}
306
+
307
+
.m3switch.root{
308
+
/*w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-gray-500 transition-colors*/
309
+
/*width: 40px;
310
+
height: 24px;*/
311
+
312
+
inline-size: var(--root-size);
313
+
block-size: 2rem;
314
+
border-radius: 99999px;
315
+
316
+
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
317
+
transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */
318
+
transition-duration: var(--default-transition-duration); /* 150ms */
319
+
320
+
.m3switch.thumb{
321
+
/*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/
322
+
323
+
height: var(--thumb-size);
324
+
width: var(--thumb-size);
325
+
display: inline-block;
326
+
border-radius: 9999px;
327
+
328
+
transform-origin: center;
329
+
330
+
transition-property: transform, translate, scale, rotate;
331
+
transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */
332
+
transition-duration: var(--default-transition-duration); /* 150ms */
333
+
334
+
}
335
+
336
+
&[aria-checked="true"] {
337
+
box-shadow: inset 0px 0px 0px 1.8px transparent;
338
+
background-color: var(--switch-on-bg);
339
+
340
+
.m3switch.thumb{
341
+
/*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/
342
+
343
+
background-color: var(--switch-on-thumb);
344
+
transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.72);
345
+
&:active {
346
+
transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88);
347
+
}
348
+
349
+
}
350
+
&:active .m3switch.thumb{
351
+
transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88);
352
+
}
353
+
}
354
+
355
+
&[aria-checked="false"] {
356
+
box-shadow: inset 0px 0px 0px 1.8px var(--switch-off-border);
357
+
background-color: var(--switch-off-bg);
358
+
.m3switch.thumb{
359
+
/*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/
360
+
361
+
background-color: var(--switch-off-thumb);
362
+
transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.5);
363
+
&:active {
364
+
transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88);
365
+
}
366
+
}
367
+
&:active .m3switch.thumb{
368
+
transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88);
369
+
}
370
+
}
371
}
+67
src/utils/atoms.ts
+67
src/utils/atoms.ts
···
2
import { atomWithStorage } from "jotai/utils";
3
import { useEffect } from "react";
4
5
export const store = createStore();
6
7
export const quickAuthAtom = atomWithStorage<string | null>(
···
21
{}
22
);
23
24
export const likedPostsAtom = atomWithStorage<Record<string, string>>(
25
"likedPosts",
26
{}
27
);
28
29
export const defaultconstellationURL = "constellation.microcosm.blue";
30
export const constellationURLAtom = atomWithStorage<string>(
···
80
// console.log("atom get ", initial);
81
// document.documentElement.style.setProperty(cssVar, initial.toString());
82
// }
···
2
import { atomWithStorage } from "jotai/utils";
3
import { useEffect } from "react";
4
5
+
import { type ProfilePostsFilter } from "~/routes/profile.$did";
6
+
7
export const store = createStore();
8
9
export const quickAuthAtom = atomWithStorage<string | null>(
···
23
{}
24
);
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
+
59
export const likedPostsAtom = atomWithStorage<Record<string, string>>(
60
"likedPosts",
61
{}
62
);
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
77
export const defaultconstellationURL = "constellation.microcosm.blue";
78
export const constellationURLAtom = atomWithStorage<string>(
···
128
// console.log("atom get ", initial);
129
// document.documentElement.style.setProperty(cssVar, initial.toString());
130
// }
131
+
132
+
133
+
134
+
// fun stuff
135
+
136
+
export const enableBitesAtom = atomWithStorage<boolean>(
137
+
"enableBitesAtom",
138
+
false
139
+
);
140
+
141
+
export const enableBridgyTextAtom = atomWithStorage<boolean>(
142
+
"enableBridgyTextAtom",
143
+
false
144
+
);
145
+
146
+
export const enableWafrnTextAtom = atomWithStorage<boolean>(
147
+
"enableWafrnTextAtom",
148
+
false
149
+
);
+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
import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser';
2
3
-
// i tried making this https://pds-nd.whey.party but cors is annoying as fuck
4
-
const handleResolverPDS = 'https://bsky.social';
5
6
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
7
// @ts-ignore this should be fine ? the vite plugin should generate this before errors
···
1
import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser';
2
3
+
import resolvers from '../../public/resolvers.json' with { type: 'json' };
4
+
const handleResolverPDS = resolvers.resolver || 'https://bsky.social';
5
6
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
7
// @ts-ignore this should be fine ? the vite plugin should generate this before errors
+41
-17
src/utils/useQuery.ts
+41
-17
src/utils/useQuery.ts
···
284
gcTime: /*0//*/5 * 60 * 1000,
285
});
286
}
287
export function useQueryConstellation(query: {
288
method: "/links";
289
target: string;
···
352
);
353
}
354
355
-
type linksRecord = {
356
did: string;
357
collection: string;
358
rkey: string;
···
534
}[];
535
};
536
537
-
export function constructAuthorFeedQuery(did: string, pdsUrl: string) {
538
return queryOptions({
539
-
queryKey: ['authorFeed', did],
540
queryFn: async ({ pageParam }: QueryFunctionContext) => {
541
const limit = 25;
542
543
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
548
const res = await fetch(url);
549
if (!res.ok) throw new Error("Failed to fetch author's posts");
···
553
});
554
}
555
556
-
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) {
557
-
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!);
558
559
return useInfiniteQuery({
560
queryKey,
···
573
isAuthed: boolean;
574
pdsUrl?: string;
575
feedServiceDid?: string;
576
}) {
577
-
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
578
579
return queryOptions({
580
queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
···
582
queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
583
const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
584
585
-
if (isAuthed) {
586
if (!agent || !pdsUrl || !feedServiceDid) {
587
throw new Error("Missing required info for authenticated feed fetch.");
588
}
···
597
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
598
return (await res.json()) as FeedSkeletonPage;
599
} else {
600
-
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
601
const res = await fetch(url);
602
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
603
return (await res.json()) as FeedSkeletonPage;
···
612
isAuthed: boolean;
613
pdsUrl?: string;
614
feedServiceDid?: string;
615
}) {
616
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
617
···
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
···
632
method: '/links'
633
target?: string
634
collection: string
635
-
path: string
636
}) {
637
-
console.log(
638
-
'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
639
-
query,
640
-
)
641
642
return infiniteQueryOptions({
643
enabled: !!query?.target,
···
675
return (lastPage as any)?.cursor ?? undefined
676
},
677
initialPageParam: undefined,
678
-
staleTime: 5 * 60 * 1000,
679
-
gcTime: 5 * 60 * 1000,
680
})
681
}
···
284
gcTime: /*0//*/5 * 60 * 1000,
285
});
286
}
287
+
// todo do more of these instead of overloads since overloads sucks so much apparently
288
+
export function useQueryConstellationLinksCountDistinctDids(query?: {
289
+
method: "/links/count/distinct-dids";
290
+
target: string;
291
+
collection: string;
292
+
path: string;
293
+
cursor?: string;
294
+
}): UseQueryResult<linksCountResponse, Error> | undefined {
295
+
//if (!query) return;
296
+
const [constellationurl] = useAtom(constellationURLAtom)
297
+
const queryres = useQuery(
298
+
constructConstellationQuery(query && {constellation: constellationurl, ...query})
299
+
) as unknown as UseQueryResult<linksCountResponse, Error>;
300
+
if (!query) {
301
+
return undefined as undefined;
302
+
}
303
+
return queryres as UseQueryResult<linksCountResponse, Error>;
304
+
}
305
+
306
export function useQueryConstellation(query: {
307
method: "/links";
308
target: string;
···
371
);
372
}
373
374
+
export type linksRecord = {
375
did: string;
376
collection: string;
377
rkey: string;
···
553
}[];
554
};
555
556
+
export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") {
557
return queryOptions({
558
+
queryKey: ['authorFeed', did, collection],
559
queryFn: async ({ pageParam }: QueryFunctionContext) => {
560
const limit = 25;
561
562
const cursor = pageParam as string | undefined;
563
const cursorParam = cursor ? `&cursor=${cursor}` : '';
564
565
+
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`;
566
567
const res = await fetch(url);
568
if (!res.ok) throw new Error("Failed to fetch author's posts");
···
572
});
573
}
574
575
+
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) {
576
+
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection);
577
578
return useInfiniteQuery({
579
queryKey,
···
592
isAuthed: boolean;
593
pdsUrl?: string;
594
feedServiceDid?: string;
595
+
// todo the hell is a unauthedfeedurl
596
+
unauthedfeedurl?: string;
597
}) {
598
+
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = options;
599
600
return queryOptions({
601
queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
···
603
queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
604
const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
605
606
+
if (isAuthed && !unauthedfeedurl) {
607
if (!agent || !pdsUrl || !feedServiceDid) {
608
throw new Error("Missing required info for authenticated feed fetch.");
609
}
···
618
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
619
return (await res.json()) as FeedSkeletonPage;
620
} else {
621
+
const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
622
const res = await fetch(url);
623
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
624
return (await res.json()) as FeedSkeletonPage;
···
633
isAuthed: boolean;
634
pdsUrl?: string;
635
feedServiceDid?: string;
636
+
unauthedfeedurl?: string;
637
}) {
638
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
639
···
644
getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
645
staleTime: Infinity,
646
refetchOnWindowFocus: false,
647
+
enabled: !!options.feedUri && (options.isAuthed ? (!!options.agent && !!options.pdsUrl || !!options.unauthedfeedurl) && !!options.feedServiceDid : true),
648
}), queryKey: queryKey};
649
}
650
···
654
method: '/links'
655
target?: string
656
collection: string
657
+
path: string,
658
+
staleMult?: number
659
}) {
660
+
const safemult = query?.staleMult ?? 1;
661
+
// console.log(
662
+
// 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
663
+
// query,
664
+
// )
665
666
return infiniteQueryOptions({
667
enabled: !!query?.target,
···
699
return (lastPage as any)?.cursor ?? undefined
700
},
701
initialPageParam: undefined,
702
+
staleTime: 5 * 60 * 1000 * safemult,
703
+
gcTime: 5 * 60 * 1000 * safemult,
704
})
705
}
+5
vite.config.ts
+5
vite.config.ts
···
13
const PROD_URL = "https://reddwarf.app"
14
const DEV_URL = "https://local3768forumtest.whey.party"
15
16
function shp(url: string): string {
17
return url.replace(/^https?:\/\//, '');
18
}
···
23
generateMetadataPlugin({
24
prod: PROD_URL,
25
dev: DEV_URL,
26
}),
27
TanStackRouterVite({ autoCodeSplitting: true }),
28
viteReact({
···
13
const PROD_URL = "https://reddwarf.app"
14
const DEV_URL = "https://local3768forumtest.whey.party"
15
16
+
const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party"
17
+
const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social"
18
+
19
function shp(url: string): string {
20
return url.replace(/^https?:\/\//, '');
21
}
···
26
generateMetadataPlugin({
27
prod: PROD_URL,
28
dev: DEV_URL,
29
+
prodResolver: PROD_HANDLE_RESOLVER_PDS,
30
+
devResolver: DEV_HANDLE_RESOLVER_PDS,
31
}),
32
TanStackRouterVite({ autoCodeSplitting: true }),
33
viteReact({