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