+2
-1
.gitignore
+2
-1
.gitignore
+9
README.md
+9
README.md
···
15
16
run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder)
17
18
## useQuery
19
Red Dwarf has been upgraded from its original bespoke caching system to Tanstack Query (react query). this migration was done to achieve a more robust and maintainable approach to data fetching and caching and state synchronization. ive seen serious performance gains from this switch!
20
···
15
16
run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder)
17
18
+
19
+
20
+
you probably dont need to change these
21
+
```ts
22
+
const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party"
23
+
const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social"
24
+
```
25
+
if you do want to change these, i recommend changing both of these to your own PDS url. i separate the prod and dev urls so that you can change it as needed. here i separated it because if the prod resolver and prod url shares the same domain itll error and prevent logins
26
+
27
## useQuery
28
Red Dwarf has been upgraded from its original bespoke caching system to Tanstack Query (react query). this migration was done to achieve a more robust and maintainable approach to data fetching and caching and state synchronization. ive seen serious performance gains from this switch!
29
+62
-30
oauthdev.mts
+62
-30
oauthdev.mts
···
1
-
import fs from 'fs';
2
-
import path from 'path';
3
//import { generateClientMetadata } from './src/helpers/oauthClient'
4
export const generateClientMetadata = (appOrigin: string) => {
5
-
const callbackPath = '/callback';
6
7
return {
8
-
"client_id": `${appOrigin}/client-metadata.json`,
9
-
"client_name": "ForumTest",
10
-
"client_uri": appOrigin,
11
-
"logo_uri": `${appOrigin}/logo192.png`,
12
-
"tos_uri": `${appOrigin}/terms-of-service`,
13
-
"policy_uri": `${appOrigin}/privacy-policy`,
14
-
"redirect_uris": [`${appOrigin}${callbackPath}`] as [string, ...string[]],
15
-
"scope": "atproto transition:generic",
16
-
"grant_types": ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"],
17
-
"response_types": ["code"] as ["code"],
18
-
"token_endpoint_auth_method": "none" as "none",
19
-
"application_type": "web" as "web",
20
-
"dpop_bound_access_tokens": true
21
-
};
22
-
}
23
-
24
25
-
export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) {
26
return {
27
-
name: 'vite-plugin-generate-metadata',
28
config(_config: any, { mode }: any) {
29
-
let appOrigin;
30
-
if (mode === 'production') {
31
-
appOrigin = prod
32
-
if (!appOrigin || !appOrigin.startsWith('https://')) {
33
-
throw new Error('VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build.');
34
}
35
} else {
36
appOrigin = dev;
37
}
38
-
39
-
40
const metadata = generateClientMetadata(appOrigin);
41
-
const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json');
42
43
fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2));
44
45
// /*mass comment*/ console.log(`โ
Generated client-metadata.json for ${appOrigin}`);
46
},
47
};
48
-
}
···
1
+
import fs from "fs";
2
+
import path from "path";
3
//import { generateClientMetadata } from './src/helpers/oauthClient'
4
export const generateClientMetadata = (appOrigin: string) => {
5
+
const callbackPath = "/callback";
6
7
return {
8
+
client_id: `${appOrigin}/client-metadata.json`,
9
+
client_name: "ForumTest",
10
+
client_uri: appOrigin,
11
+
logo_uri: `${appOrigin}/logo192.png`,
12
+
tos_uri: `${appOrigin}/terms-of-service`,
13
+
policy_uri: `${appOrigin}/privacy-policy`,
14
+
redirect_uris: [`${appOrigin}${callbackPath}`] as [string, ...string[]],
15
+
scope: "atproto transition:generic",
16
+
grant_types: ["authorization_code", "refresh_token"] as [
17
+
"authorization_code",
18
+
"refresh_token",
19
+
],
20
+
response_types: ["code"] as ["code"],
21
+
token_endpoint_auth_method: "none" as "none",
22
+
application_type: "web" as "web",
23
+
dpop_bound_access_tokens: true,
24
+
};
25
+
};
26
27
+
export function generateMetadataPlugin({
28
+
prod,
29
+
dev,
30
+
prodResolver = "https://bsky.social",
31
+
devResolver = prodResolver,
32
+
}: {
33
+
prod: string;
34
+
dev: string;
35
+
prodResolver?: string;
36
+
devResolver?: string;
37
+
}) {
38
return {
39
+
name: "vite-plugin-generate-metadata",
40
config(_config: any, { mode }: any) {
41
+
console.log('๐ก vite mode =', mode)
42
+
let appOrigin, resolver;
43
+
if (mode === "production") {
44
+
appOrigin = prod;
45
+
resolver = prodResolver;
46
+
if (!appOrigin || !appOrigin.startsWith("https://")) {
47
+
throw new Error(
48
+
"VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build."
49
+
);
50
}
51
} else {
52
appOrigin = dev;
53
+
resolver = devResolver;
54
}
55
+
56
const metadata = generateClientMetadata(appOrigin);
57
+
const outputPath = path.resolve(
58
+
process.cwd(),
59
+
"public",
60
+
"client-metadata.json"
61
+
);
62
63
fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2));
64
65
+
const resolvers = {
66
+
resolver: resolver,
67
+
};
68
+
const resolverOutPath = path.resolve(
69
+
process.cwd(),
70
+
"public",
71
+
"resolvers.json"
72
+
);
73
+
74
+
fs.writeFileSync(resolverOutPath, JSON.stringify(resolvers, null, 2));
75
+
76
+
77
// /*mass comment*/ console.log(`โ
Generated client-metadata.json for ${appOrigin}`);
78
},
79
};
80
+
}
+10
package-lock.json
+10
package-lock.json
···
29
"react": "^19.0.0",
30
"react-dom": "^19.0.0",
31
"react-player": "^3.3.2",
32
"tailwindcss": "^4.0.6",
33
"tanstack-router-keepalive": "^1.0.0"
34
},
···
12543
"csstype": "^3.1.0",
12544
"seroval": "~1.3.0",
12545
"seroval-plugins": "~1.3.0"
12546
}
12547
},
12548
"node_modules/source-map": {
···
29
"react": "^19.0.0",
30
"react-dom": "^19.0.0",
31
"react-player": "^3.3.2",
32
+
"sonner": "^2.0.7",
33
"tailwindcss": "^4.0.6",
34
"tanstack-router-keepalive": "^1.0.0"
35
},
···
12544
"csstype": "^3.1.0",
12545
"seroval": "~1.3.0",
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"
12556
}
12557
},
12558
"node_modules/source-map": {
+1
package.json
+1
package.json
+4
src/auto-imports.d.ts
+4
src/auto-imports.d.ts
···
19
const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default
20
const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default
21
const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default
22
const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default
23
const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default
24
}
···
19
const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default
20
const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default
21
const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default
22
+
const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default
23
+
const IconMdiClose: typeof import('~icons/mdi/close.jsx').default
24
const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default
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
28
}
+37
-3
src/components/Import.tsx
+37
-3
src/components/Import.tsx
···
1
import { AtUri } from "@atproto/api";
2
import { useNavigate, type UseNavigateResult } from "@tanstack/react-router";
3
import { useState } from "react";
4
5
/**
6
* Basically the best equivalent to Search that i can do
7
*/
8
-
export function Import() {
9
-
const [textInput, setTextInput] = useState<string | undefined>();
10
const navigate = useNavigate();
11
12
const handleEnter = () => {
13
if (!textInput) return;
14
handleImport({
15
text: textInput,
16
navigate,
17
});
18
};
19
20
return (
21
<div className="w-full relative">
···
23
24
<input
25
type="text"
26
-
placeholder="Import..."
27
value={textInput}
28
onChange={(e) => setTextInput(e.target.value)}
29
onKeyDown={(e) => {
···
38
function handleImport({
39
text,
40
navigate,
41
}: {
42
text: string;
43
navigate: UseNavigateResult<string>;
44
}) {
45
const trimmed = text.trim();
46
// parse text
···
147
// } catch {
148
// // continue
149
// }
150
}
···
1
import { AtUri } from "@atproto/api";
2
import { useNavigate, type UseNavigateResult } from "@tanstack/react-router";
3
+
import { useAtom } from "jotai";
4
import { useState } from "react";
5
+
6
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
+
import { lycanURLAtom } from "~/utils/atoms";
8
+
import { useQueryLycanStatus } from "~/utils/useQuery";
9
10
/**
11
* Basically the best equivalent to Search that i can do
12
*/
13
+
export function Import({
14
+
optionaltextstring,
15
+
}: {
16
+
optionaltextstring?: string;
17
+
}) {
18
+
const [textInput, setTextInput] = useState<string | undefined>(
19
+
optionaltextstring
20
+
);
21
const navigate = useNavigate();
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
+
36
const handleEnter = () => {
37
if (!textInput) return;
38
handleImport({
39
text: textInput,
40
navigate,
41
+
lycanReady:
42
+
lycanReady || (!!lycanIndexingProgress && lycanIndexingProgress > 0),
43
});
44
};
45
+
46
+
const placeholder = lycanReady ? "Search..." : "Import...";
47
48
return (
49
<div className="w-full relative">
···
51
52
<input
53
type="text"
54
+
placeholder={placeholder}
55
value={textInput}
56
onChange={(e) => setTextInput(e.target.value)}
57
onKeyDown={(e) => {
···
66
function handleImport({
67
text,
68
navigate,
69
+
lycanReady,
70
}: {
71
text: string;
72
navigate: UseNavigateResult<string>;
73
+
lycanReady?: boolean;
74
}) {
75
const trimmed = text.trim();
76
// parse text
···
177
// } catch {
178
// // continue
179
// }
180
+
181
+
if (lycanReady) {
182
+
navigate({ to: "/search", search: { q: text } });
183
+
}
184
}
+6
-1
src/components/InfiniteCustomFeed.tsx
+6
-1
src/components/InfiniteCustomFeed.tsx
···
14
feedUri: string;
15
pdsUrl?: string;
16
feedServiceDid?: string;
17
}
18
19
export function InfiniteCustomFeed({
20
feedUri,
21
pdsUrl,
22
feedServiceDid,
23
}: InfiniteCustomFeedProps) {
24
const { agent } = useAuth();
25
-
const authed = !!agent?.did;
26
27
// const identityresultmaybe = useQueryIdentity(agent?.did);
28
// const identity = identityresultmaybe?.data;
···
45
isAuthed: authed ?? false,
46
pdsUrl: pdsUrl,
47
feedServiceDid: feedServiceDid,
48
});
49
const queryClient = useQueryClient();
50
···
14
feedUri: string;
15
pdsUrl?: string;
16
feedServiceDid?: string;
17
+
authedOverride?: boolean;
18
+
unauthedfeedurl?: string;
19
}
20
21
export function InfiniteCustomFeed({
22
feedUri,
23
pdsUrl,
24
feedServiceDid,
25
+
authedOverride,
26
+
unauthedfeedurl,
27
}: InfiniteCustomFeedProps) {
28
const { agent } = useAuth();
29
+
const authed = authedOverride || !!agent?.did;
30
31
// const identityresultmaybe = useQueryIdentity(agent?.did);
32
// const identity = identityresultmaybe?.data;
···
49
isAuthed: authed ?? false,
50
pdsUrl: pdsUrl,
51
feedServiceDid: feedServiceDid,
52
+
unauthedfeedurl: unauthedfeedurl,
53
});
54
const queryClient = useQueryClient();
55
+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
+
*/
+186
-55
src/components/UniversalPostRenderer.tsx
+186
-55
src/components/UniversalPostRenderer.tsx
···
1
import { useNavigate } from "@tanstack/react-router";
2
import DOMPurify from "dompurify";
3
import { useAtom } from "jotai";
···
9
import {
10
composerAtom,
11
constellationURLAtom,
12
imgCDNAtom,
13
-
likedPostsAtom,
14
} from "~/utils/atoms";
15
import { useHydratedEmbed } from "~/utils/useHydrated";
16
import {
···
38
feedviewpost?: boolean;
39
repostedby?: string;
40
style?: React.CSSProperties;
41
-
ref?: React.Ref<HTMLDivElement>;
42
dataIndexPropPass?: number;
43
nopics?: boolean;
44
concise?: boolean;
45
lightboxCallback?: (d: LightboxProps) => void;
46
maxReplies?: number;
47
isQuote?: boolean;
48
}
49
50
// export async function cachedGetRecord({
···
157
lightboxCallback,
158
maxReplies,
159
isQuote,
160
}: UniversalPostRendererATURILoaderProps) {
161
// todo remove this once tree rendering is implemented, use a prop like isTree
162
const TEMPLINEAR = true;
···
520
? true
521
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
522
? false
523
-
: (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine
524
}
525
topReplyLine={topReplyLine}
526
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
542
lightboxCallback={lightboxCallback}
543
maxReplies={maxReplies}
544
isQuote={isQuote}
545
/>
546
<>
547
-
{(maxReplies && maxReplies === 0 && replies && replies > 0) ? (
548
<>
549
-
{/* <div>hello</div> */}
550
-
<MoreReplies atUri={atUri} />
551
</>
552
-
) : (<></>)}
553
</>
554
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
555
<>
···
644
lightboxCallback,
645
maxReplies,
646
isQuote,
647
}: {
648
postRecord: any;
649
profileRecord: any;
···
659
feedviewpost?: boolean;
660
repostedby?: string;
661
style?: React.CSSProperties;
662
-
ref?: React.Ref<HTMLDivElement>;
663
dataIndexPropPass?: number;
664
nopics?: boolean;
665
concise?: boolean;
666
lightboxCallback?: (d: LightboxProps) => void;
667
maxReplies?: number;
668
isQuote?: boolean;
669
}) {
670
// /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`);
671
const navigate = useNavigate();
···
736
// run();
737
// }, [postRecord, resolved?.did]);
738
739
const {
740
data: hydratedEmbed,
741
isLoading: isEmbedLoading,
···
830
// }, [fakepost, get, set]);
831
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
832
?.uri;
833
-
const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
834
const replyhookvalue = useQueryIdentity(
835
feedviewpost ? feedviewpostreplydid : undefined
836
);
···
841
repostedby ? aturirepostbydid : undefined
842
);
843
const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle;
844
return (
845
<>
846
{/* <p>
847
{postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)}
848
</p> */}
849
<UniversalPostRenderer
850
expanded={detailed}
851
onPostClick={() =>
···
1204
1205
import defaultpfp from "~/../public/favicon.png";
1206
import { useAuth } from "~/providers/UnifiedAuthProvider";
1207
-
import { FollowButton, Mutual } from "~/routes/profile.$did";
1208
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1209
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1210
// import type {
1211
// ViewRecord,
···
1358
depth?: number;
1359
repostedby?: string;
1360
style?: React.CSSProperties;
1361
-
ref?: React.Ref<HTMLDivElement>;
1362
dataIndexPropPass?: number;
1363
nopics?: boolean;
1364
concise?: boolean;
···
1367
}) {
1368
const parsed = new AtUri(post.uri);
1369
const navigate = useNavigate();
1370
-
const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom);
1371
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
1372
post.viewer?.repost ? true : false
1373
-
);
1374
-
const [hasLiked, setHasLiked] = useState<boolean>(
1375
-
post.uri in likedPosts || post.viewer?.like ? true : false
1376
);
1377
const [, setComposerPost] = useAtom(composerAtom);
1378
const { agent } = useAuth();
1379
-
const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like);
1380
const [retweetUri, setRetweetUri] = useState<string | undefined>(
1381
post.viewer?.repost
1382
);
1383
-
1384
-
const likeOrUnlikePost = async () => {
1385
-
const newLikedPosts = { ...likedPosts };
1386
-
if (!agent) {
1387
-
console.error("Agent is null or undefined");
1388
-
return;
1389
-
}
1390
-
if (hasLiked) {
1391
-
if (post.uri in likedPosts) {
1392
-
const likeUri = likedPosts[post.uri];
1393
-
setLikeUri(likeUri);
1394
-
}
1395
-
if (likeUri) {
1396
-
await agent.deleteLike(likeUri);
1397
-
setHasLiked(false);
1398
-
delete newLikedPosts[post.uri];
1399
-
}
1400
-
} else {
1401
-
const { uri } = await agent.like(post.uri, post.cid);
1402
-
setLikeUri(uri);
1403
-
setHasLiked(true);
1404
-
newLikedPosts[post.uri] = uri;
1405
-
}
1406
-
setLikedPosts(newLikedPosts);
1407
-
};
1408
1409
const repostOrUnrepostPost = async () => {
1410
if (!agent) {
···
1435
: undefined;
1436
1437
const emergencySalt = randomString();
1438
-
const fedi = (post.record as { bridgyOriginalText?: string })
1439
.bridgyOriginalText;
1440
1441
/* fuck you */
1442
const isMainItem = false;
1443
const setMainItem = (any: any) => {};
1444
// eslint-disable-next-line react-hooks/refs
1445
-
console.log("Received ref in UniversalPostRenderer:", ref);
1446
return (
1447
<div ref={ref} style={style} data-index={dataIndexPropPass}>
1448
<div
···
1575
{post.author.displayName || post.author.handle}{" "}
1576
</div>
1577
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1578
-
<Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "}
1579
</div>
1580
</div>
1581
{uprrrsauthor?.description && (
···
1823
</div>
1824
</>
1825
)}
1826
-
<div style={{ paddingTop: post.embed && !concise && depth < 1 ? 4 : 0 }}>
1827
<>
1828
{expanded && (
1829
<div
···
1919
</DropdownMenu.Root>
1920
<HitSlopButton
1921
onClick={() => {
1922
-
likeOrUnlikePost();
1923
}}
1924
style={{
1925
...btnstyle,
1926
-
...(hasLiked ? { color: "#EC4899" } : {}),
1927
}}
1928
>
1929
-
{hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
1930
-
{(post.likeCount || 0) + (hasLiked ? 1 : 0)}
1931
</HitSlopButton>
1932
<div style={{ display: "flex", gap: 8 }}>
1933
<HitSlopButton
···
1941
"/post/" +
1942
post.uri.split("/").pop()
1943
);
1944
} catch (_e) {
1945
// idk
1946
}
1947
}}
1948
style={{
···
1951
>
1952
<MdiShareVariant />
1953
</HitSlopButton>
1954
-
<span style={btnstyle}>
1955
-
<MdiMoreHoriz />
1956
-
</span>
1957
</div>
1958
</div>
1959
)}
···
2179
}
2180
2181
if (AppBskyEmbedRecord.isView(embed)) {
2182
// custom feed embed (i.e. generator view)
2183
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
2184
// stopgap sorry
···
2188
// <MaybeFeedCard view={embed.record} />
2189
// </div>
2190
// )
2191
}
2192
2193
// list embed
···
2199
// <MaybeListCard view={embed.record} />
2200
// </div>
2201
// )
2202
}
2203
2204
// starter pack embed
···
2210
// <StarterPackCard starterPack={embed.record} />
2211
// </div>
2212
// )
2213
}
2214
2215
// quote post
···
2269
</div>
2270
);
2271
} else {
2272
return <>sorry</>;
2273
}
2274
//return <QuotePostRenderer record={embed.record} moderation={moderation} />;
···
2702
className="link"
2703
style={{
2704
textDecoration: "none",
2705
-
color: "rgb(29, 122, 242)",
2706
wordBreak: "break-all",
2707
}}
2708
target="_blank"
···
2722
result.push(
2723
<span
2724
key={start}
2725
-
style={{ color: "rgb(29, 122, 242)" }}
2726
className=" cursor-pointer"
2727
onClick={(e) => {
2728
e.stopPropagation();
···
2740
result.push(
2741
<span
2742
key={start}
2743
-
style={{ color: "rgb(29, 122, 242)" }}
2744
onClick={(e) => {
2745
e.stopPropagation();
2746
}}
···
1
+
import * as ATPAPI from "@atproto/api";
2
import { useNavigate } from "@tanstack/react-router";
3
import DOMPurify from "dompurify";
4
import { useAtom } from "jotai";
···
10
import {
11
composerAtom,
12
constellationURLAtom,
13
+
enableBridgyTextAtom,
14
+
enableWafrnTextAtom,
15
imgCDNAtom,
16
} from "~/utils/atoms";
17
import { useHydratedEmbed } from "~/utils/useHydrated";
18
import {
···
40
feedviewpost?: boolean;
41
repostedby?: string;
42
style?: React.CSSProperties;
43
+
ref?: React.RefObject<HTMLDivElement>;
44
dataIndexPropPass?: number;
45
nopics?: boolean;
46
concise?: boolean;
47
lightboxCallback?: (d: LightboxProps) => void;
48
maxReplies?: number;
49
isQuote?: boolean;
50
+
filterNoReplies?: boolean;
51
+
filterMustHaveMedia?: boolean;
52
+
filterMustBeReply?: boolean;
53
}
54
55
// export async function cachedGetRecord({
···
162
lightboxCallback,
163
maxReplies,
164
isQuote,
165
+
filterNoReplies,
166
+
filterMustHaveMedia,
167
+
filterMustBeReply,
168
}: UniversalPostRendererATURILoaderProps) {
169
// todo remove this once tree rendering is implemented, use a prop like isTree
170
const TEMPLINEAR = true;
···
528
? true
529
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
530
? false
531
+
: maxReplies === 0 && (!replies || (!!replies && replies === 0))
532
+
? false
533
+
: bottomReplyLine
534
}
535
topReplyLine={topReplyLine}
536
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
552
lightboxCallback={lightboxCallback}
553
maxReplies={maxReplies}
554
isQuote={isQuote}
555
+
filterNoReplies={filterNoReplies}
556
+
filterMustHaveMedia={filterMustHaveMedia}
557
+
filterMustBeReply={filterMustBeReply}
558
/>
559
<>
560
+
{maxReplies && maxReplies === 0 && replies && replies > 0 ? (
561
<>
562
+
{/* <div>hello</div> */}
563
+
<MoreReplies atUri={atUri} />
564
</>
565
+
) : (
566
+
<></>
567
+
)}
568
</>
569
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
570
<>
···
659
lightboxCallback,
660
maxReplies,
661
isQuote,
662
+
filterNoReplies,
663
+
filterMustHaveMedia,
664
+
filterMustBeReply,
665
}: {
666
postRecord: any;
667
profileRecord: any;
···
677
feedviewpost?: boolean;
678
repostedby?: string;
679
style?: React.CSSProperties;
680
+
ref?: React.RefObject<HTMLDivElement>;
681
dataIndexPropPass?: number;
682
nopics?: boolean;
683
concise?: boolean;
684
lightboxCallback?: (d: LightboxProps) => void;
685
maxReplies?: number;
686
isQuote?: boolean;
687
+
filterNoReplies?: boolean;
688
+
filterMustHaveMedia?: boolean;
689
+
filterMustBeReply?: boolean;
690
}) {
691
// /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`);
692
const navigate = useNavigate();
···
757
// run();
758
// }, [postRecord, resolved?.did]);
759
760
+
const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed;
761
+
const hasImages = hasEmbed?.$type === "app.bsky.embed.images";
762
+
const hasVideo = hasEmbed?.$type === "app.bsky.embed.video";
763
+
const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia";
764
+
const isQuotewithImages =
765
+
isquotewithmedia &&
766
+
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
767
+
"app.bsky.embed.images";
768
+
const isQuotewithVideo =
769
+
isquotewithmedia &&
770
+
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
771
+
"app.bsky.embed.video";
772
+
773
+
const hasMedia =
774
+
hasEmbed &&
775
+
(hasImages || hasVideo || isQuotewithImages || isQuotewithVideo);
776
+
777
const {
778
data: hydratedEmbed,
779
isLoading: isEmbedLoading,
···
868
// }, [fakepost, get, set]);
869
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
870
?.uri;
871
+
const feedviewpostreplydid =
872
+
thereply && !filterNoReplies ? new AtUri(thereply).host : undefined;
873
const replyhookvalue = useQueryIdentity(
874
feedviewpost ? feedviewpostreplydid : undefined
875
);
···
880
repostedby ? aturirepostbydid : undefined
881
);
882
const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle;
883
+
884
+
if (filterNoReplies && thereply) return null;
885
+
886
+
if (filterMustHaveMedia && !hasMedia) return null;
887
+
888
+
if (filterMustBeReply && !thereply) return null;
889
+
890
return (
891
<>
892
{/* <p>
893
{postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)}
894
</p> */}
895
+
{/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span>
896
+
<span>thereply is {thereply ? "true" : "false"}</span> */}
897
<UniversalPostRenderer
898
expanded={detailed}
899
onPostClick={() =>
···
1252
1253
import defaultpfp from "~/../public/favicon.png";
1254
import { useAuth } from "~/providers/UnifiedAuthProvider";
1255
+
import { renderSnack } from "~/routes/__root";
1256
+
import {
1257
+
FeedItemRenderAturiLoader,
1258
+
FollowButton,
1259
+
Mutual,
1260
+
} from "~/routes/profile.$did";
1261
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1262
+
import { useFastLike } from "~/utils/likeMutationQueue";
1263
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1264
// import type {
1265
// ViewRecord,
···
1412
depth?: number;
1413
repostedby?: string;
1414
style?: React.CSSProperties;
1415
+
ref?: React.RefObject<HTMLDivElement>;
1416
dataIndexPropPass?: number;
1417
nopics?: boolean;
1418
concise?: boolean;
···
1421
}) {
1422
const parsed = new AtUri(post.uri);
1423
const navigate = useNavigate();
1424
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
1425
post.viewer?.repost ? true : false
1426
);
1427
const [, setComposerPost] = useAtom(composerAtom);
1428
const { agent } = useAuth();
1429
const [retweetUri, setRetweetUri] = useState<string | undefined>(
1430
post.viewer?.repost
1431
);
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])
1439
1440
const repostOrUnrepostPost = async () => {
1441
if (!agent) {
···
1466
: undefined;
1467
1468
const emergencySalt = randomString();
1469
+
1470
+
const [showBridgyText] = useAtom(enableBridgyTextAtom);
1471
+
const [showWafrnText] = useAtom(enableWafrnTextAtom);
1472
+
1473
+
const unfedibridgy = (post.record as { bridgyOriginalText?: string })
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);
1507
1508
/* fuck you */
1509
const isMainItem = false;
1510
const setMainItem = (any: any) => {};
1511
// eslint-disable-next-line react-hooks/refs
1512
+
//console.log("Received ref in UniversalPostRenderer:", usedref);
1513
return (
1514
<div ref={ref} style={style} data-index={dataIndexPropPass}>
1515
<div
···
1642
{post.author.displayName || post.author.handle}{" "}
1643
</div>
1644
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1645
+
<Mutual targetdidorhandle={post.author.did} />@
1646
+
{post.author.handle}{" "}
1647
</div>
1648
</div>
1649
{uprrrsauthor?.description && (
···
1891
</div>
1892
</>
1893
)}
1894
+
<div
1895
+
style={{
1896
+
paddingTop: post.embed && !concise && depth < 1 ? 4 : 0,
1897
+
}}
1898
+
>
1899
<>
1900
{expanded && (
1901
<div
···
1991
</DropdownMenu.Root>
1992
<HitSlopButton
1993
onClick={() => {
1994
+
toggle();
1995
}}
1996
style={{
1997
...btnstyle,
1998
+
...(liked ? { color: "#EC4899" } : {}),
1999
}}
2000
>
2001
+
{liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
2002
+
{(post.likeCount || 0) + (liked ? 1 : 0)}
2003
</HitSlopButton>
2004
<div style={{ display: "flex", gap: 8 }}>
2005
<HitSlopButton
···
2013
"/post/" +
2014
post.uri.split("/").pop()
2015
);
2016
+
renderSnack({
2017
+
title: "Copied to clipboard!",
2018
+
});
2019
} catch (_e) {
2020
// idk
2021
+
renderSnack({
2022
+
title: "Failed to copy link",
2023
+
});
2024
}
2025
}}
2026
style={{
···
2029
>
2030
<MdiShareVariant />
2031
</HitSlopButton>
2032
+
<HitSlopButton
2033
+
onClick={() => {
2034
+
renderSnack({
2035
+
title: "Not implemented yet...",
2036
+
});
2037
+
}}
2038
+
>
2039
+
<span style={btnstyle}>
2040
+
<MdiMoreHoriz />
2041
+
</span>
2042
+
</HitSlopButton>
2043
</div>
2044
</div>
2045
)}
···
2265
}
2266
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
+
2272
// custom feed embed (i.e. generator view)
2273
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
2274
// stopgap sorry
···
2278
// <MaybeFeedCard view={embed.record} />
2279
// </div>
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
+
);
2291
}
2292
2293
// list embed
···
2299
// <MaybeListCard view={embed.record} />
2300
// </div>
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
+
);
2317
}
2318
2319
// starter pack embed
···
2325
// <StarterPackCard starterPack={embed.record} />
2326
// </div>
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
+
);
2343
}
2344
2345
// quote post
···
2399
</div>
2400
);
2401
} else {
2402
+
console.log("what the hell is a ", embed);
2403
return <>sorry</>;
2404
}
2405
//return <QuotePostRenderer record={embed.record} moderation={moderation} />;
···
2833
className="link"
2834
style={{
2835
textDecoration: "none",
2836
+
color: "var(--link-text-color)",
2837
wordBreak: "break-all",
2838
}}
2839
target="_blank"
···
2853
result.push(
2854
<span
2855
key={start}
2856
+
style={{ color: "var(--link-text-color)" }}
2857
className=" cursor-pointer"
2858
onClick={(e) => {
2859
e.stopPropagation();
···
2871
result.push(
2872
<span
2873
key={start}
2874
+
style={{ color: "var(--link-text-color)" }}
2875
onClick={(e) => {
2876
e.stopPropagation();
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
+
}
+84
src/routeTree.gen.ts
+84
src/routeTree.gen.ts
···
12
import { Route as SettingsRouteImport } from './routes/settings'
13
import { Route as SearchRouteImport } from './routes/search'
14
import { Route as NotificationsRouteImport } from './routes/notifications'
15
import { Route as FeedsRouteImport } from './routes/feeds'
16
import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout'
17
import { Route as IndexRouteImport } from './routes/index'
18
import { Route as CallbackIndexRouteImport } from './routes/callback/index'
19
import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout'
20
import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index'
21
import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b'
22
import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a'
23
import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey'
24
import { Route as ProfileDidPostRkeyRepostedByRouteImport } from './routes/profile.$did/post.$rkey.reposted-by'
25
import { Route as ProfileDidPostRkeyQuotesRouteImport } from './routes/profile.$did/post.$rkey.quotes'
26
import { Route as ProfileDidPostRkeyLikedByRouteImport } from './routes/profile.$did/post.$rkey.liked-by'
···
41
path: '/notifications',
42
getParentRoute: () => rootRouteImport,
43
} as any)
44
const FeedsRoute = FeedsRouteImport.update({
45
id: '/feeds',
46
path: '/feeds',
···
70
path: '/profile/$did/',
71
getParentRoute: () => rootRouteImport,
72
} as any)
73
const PathlessLayoutNestedLayoutRouteBRoute =
74
PathlessLayoutNestedLayoutRouteBRouteImport.update({
75
id: '/route-b',
···
87
path: '/profile/$did/post/$rkey',
88
getParentRoute: () => rootRouteImport,
89
} as any)
90
const ProfileDidPostRkeyRepostedByRoute =
91
ProfileDidPostRkeyRepostedByRouteImport.update({
92
id: '/reposted-by',
···
115
export interface FileRoutesByFullPath {
116
'/': typeof IndexRoute
117
'/feeds': typeof FeedsRoute
118
'/notifications': typeof NotificationsRoute
119
'/search': typeof SearchRoute
120
'/settings': typeof SettingsRoute
121
'/callback': typeof CallbackIndexRoute
122
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
123
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
124
'/profile/$did': typeof ProfileDidIndexRoute
125
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
126
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
127
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
···
131
export interface FileRoutesByTo {
132
'/': typeof IndexRoute
133
'/feeds': typeof FeedsRoute
134
'/notifications': typeof NotificationsRoute
135
'/search': typeof SearchRoute
136
'/settings': typeof SettingsRoute
137
'/callback': typeof CallbackIndexRoute
138
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
139
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
140
'/profile/$did': typeof ProfileDidIndexRoute
141
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
142
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
143
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
···
149
'/': typeof IndexRoute
150
'/_pathlessLayout': typeof PathlessLayoutRouteWithChildren
151
'/feeds': typeof FeedsRoute
152
'/notifications': typeof NotificationsRoute
153
'/search': typeof SearchRoute
154
'/settings': typeof SettingsRoute
···
156
'/callback/': typeof CallbackIndexRoute
157
'/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
158
'/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
159
'/profile/$did/': typeof ProfileDidIndexRoute
160
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
161
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
162
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
···
168
fullPaths:
169
| '/'
170
| '/feeds'
171
| '/notifications'
172
| '/search'
173
| '/settings'
174
| '/callback'
175
| '/route-a'
176
| '/route-b'
177
| '/profile/$did'
178
| '/profile/$did/post/$rkey'
179
| '/profile/$did/post/$rkey/liked-by'
180
| '/profile/$did/post/$rkey/quotes'
···
184
to:
185
| '/'
186
| '/feeds'
187
| '/notifications'
188
| '/search'
189
| '/settings'
190
| '/callback'
191
| '/route-a'
192
| '/route-b'
193
| '/profile/$did'
194
| '/profile/$did/post/$rkey'
195
| '/profile/$did/post/$rkey/liked-by'
196
| '/profile/$did/post/$rkey/quotes'
···
201
| '/'
202
| '/_pathlessLayout'
203
| '/feeds'
204
| '/notifications'
205
| '/search'
206
| '/settings'
···
208
| '/callback/'
209
| '/_pathlessLayout/_nested-layout/route-a'
210
| '/_pathlessLayout/_nested-layout/route-b'
211
| '/profile/$did/'
212
| '/profile/$did/post/$rkey'
213
| '/profile/$did/post/$rkey/liked-by'
214
| '/profile/$did/post/$rkey/quotes'
···
220
IndexRoute: typeof IndexRoute
221
PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren
222
FeedsRoute: typeof FeedsRoute
223
NotificationsRoute: typeof NotificationsRoute
224
SearchRoute: typeof SearchRoute
225
SettingsRoute: typeof SettingsRoute
226
CallbackIndexRoute: typeof CallbackIndexRoute
227
ProfileDidIndexRoute: typeof ProfileDidIndexRoute
228
ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren
229
}
230
···
251
preLoaderRoute: typeof NotificationsRouteImport
252
parentRoute: typeof rootRouteImport
253
}
254
'/feeds': {
255
id: '/feeds'
256
path: '/feeds'
···
293
preLoaderRoute: typeof ProfileDidIndexRouteImport
294
parentRoute: typeof rootRouteImport
295
}
296
'/_pathlessLayout/_nested-layout/route-b': {
297
id: '/_pathlessLayout/_nested-layout/route-b'
298
path: '/route-b'
···
312
path: '/profile/$did/post/$rkey'
313
fullPath: '/profile/$did/post/$rkey'
314
preLoaderRoute: typeof ProfileDidPostRkeyRouteImport
315
parentRoute: typeof rootRouteImport
316
}
317
'/profile/$did/post/$rkey/reposted-by': {
···
396
IndexRoute: IndexRoute,
397
PathlessLayoutRoute: PathlessLayoutRouteWithChildren,
398
FeedsRoute: FeedsRoute,
399
NotificationsRoute: NotificationsRoute,
400
SearchRoute: SearchRoute,
401
SettingsRoute: SettingsRoute,
402
CallbackIndexRoute: CallbackIndexRoute,
403
ProfileDidIndexRoute: ProfileDidIndexRoute,
404
ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren,
405
}
406
export const routeTree = rootRouteImport
···
12
import { Route as SettingsRouteImport } from './routes/settings'
13
import { Route as SearchRouteImport } from './routes/search'
14
import { Route as NotificationsRouteImport } from './routes/notifications'
15
+
import { Route as ModerationRouteImport } from './routes/moderation'
16
import { Route as FeedsRouteImport } from './routes/feeds'
17
import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout'
18
import { Route as IndexRouteImport } from './routes/index'
19
import { Route as CallbackIndexRouteImport } from './routes/callback/index'
20
import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout'
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'
24
import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b'
25
import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a'
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'
···
45
path: '/notifications',
46
getParentRoute: () => rootRouteImport,
47
} as any)
48
+
const ModerationRoute = ModerationRouteImport.update({
49
+
id: '/moderation',
50
+
path: '/moderation',
51
+
getParentRoute: () => rootRouteImport,
52
+
} as any)
53
const FeedsRoute = FeedsRouteImport.update({
54
id: '/feeds',
55
path: '/feeds',
···
79
path: '/profile/$did/',
80
getParentRoute: () => rootRouteImport,
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)
92
const PathlessLayoutNestedLayoutRouteBRoute =
93
PathlessLayoutNestedLayoutRouteBRouteImport.update({
94
id: '/route-b',
···
106
path: '/profile/$did/post/$rkey',
107
getParentRoute: () => rootRouteImport,
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',
···
139
export interface FileRoutesByFullPath {
140
'/': typeof IndexRoute
141
'/feeds': typeof FeedsRoute
142
+
'/moderation': typeof ModerationRoute
143
'/notifications': typeof NotificationsRoute
144
'/search': typeof SearchRoute
145
'/settings': typeof SettingsRoute
146
'/callback': typeof CallbackIndexRoute
147
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
148
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
149
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
150
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
151
'/profile/$did': typeof ProfileDidIndexRoute
152
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
153
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
154
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
155
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
···
159
export interface FileRoutesByTo {
160
'/': typeof IndexRoute
161
'/feeds': typeof FeedsRoute
162
+
'/moderation': typeof ModerationRoute
163
'/notifications': typeof NotificationsRoute
164
'/search': typeof SearchRoute
165
'/settings': typeof SettingsRoute
166
'/callback': typeof CallbackIndexRoute
167
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
168
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
169
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
170
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
171
'/profile/$did': typeof ProfileDidIndexRoute
172
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
173
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
174
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
175
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
···
181
'/': typeof IndexRoute
182
'/_pathlessLayout': typeof PathlessLayoutRouteWithChildren
183
'/feeds': typeof FeedsRoute
184
+
'/moderation': typeof ModerationRoute
185
'/notifications': typeof NotificationsRoute
186
'/search': typeof SearchRoute
187
'/settings': typeof SettingsRoute
···
189
'/callback/': typeof CallbackIndexRoute
190
'/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
191
'/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
192
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
193
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
194
'/profile/$did/': typeof ProfileDidIndexRoute
195
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
196
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
197
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
198
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
···
204
fullPaths:
205
| '/'
206
| '/feeds'
207
+
| '/moderation'
208
| '/notifications'
209
| '/search'
210
| '/settings'
211
| '/callback'
212
| '/route-a'
213
| '/route-b'
214
+
| '/profile/$did/followers'
215
+
| '/profile/$did/follows'
216
| '/profile/$did'
217
+
| '/profile/$did/feed/$rkey'
218
| '/profile/$did/post/$rkey'
219
| '/profile/$did/post/$rkey/liked-by'
220
| '/profile/$did/post/$rkey/quotes'
···
224
to:
225
| '/'
226
| '/feeds'
227
+
| '/moderation'
228
| '/notifications'
229
| '/search'
230
| '/settings'
231
| '/callback'
232
| '/route-a'
233
| '/route-b'
234
+
| '/profile/$did/followers'
235
+
| '/profile/$did/follows'
236
| '/profile/$did'
237
+
| '/profile/$did/feed/$rkey'
238
| '/profile/$did/post/$rkey'
239
| '/profile/$did/post/$rkey/liked-by'
240
| '/profile/$did/post/$rkey/quotes'
···
245
| '/'
246
| '/_pathlessLayout'
247
| '/feeds'
248
+
| '/moderation'
249
| '/notifications'
250
| '/search'
251
| '/settings'
···
253
| '/callback/'
254
| '/_pathlessLayout/_nested-layout/route-a'
255
| '/_pathlessLayout/_nested-layout/route-b'
256
+
| '/profile/$did/followers'
257
+
| '/profile/$did/follows'
258
| '/profile/$did/'
259
+
| '/profile/$did/feed/$rkey'
260
| '/profile/$did/post/$rkey'
261
| '/profile/$did/post/$rkey/liked-by'
262
| '/profile/$did/post/$rkey/quotes'
···
268
IndexRoute: typeof IndexRoute
269
PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren
270
FeedsRoute: typeof FeedsRoute
271
+
ModerationRoute: typeof ModerationRoute
272
NotificationsRoute: typeof NotificationsRoute
273
SearchRoute: typeof SearchRoute
274
SettingsRoute: typeof SettingsRoute
275
CallbackIndexRoute: typeof CallbackIndexRoute
276
+
ProfileDidFollowersRoute: typeof ProfileDidFollowersRoute
277
+
ProfileDidFollowsRoute: typeof ProfileDidFollowsRoute
278
ProfileDidIndexRoute: typeof ProfileDidIndexRoute
279
+
ProfileDidFeedRkeyRoute: typeof ProfileDidFeedRkeyRoute
280
ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren
281
}
282
···
303
preLoaderRoute: typeof NotificationsRouteImport
304
parentRoute: typeof rootRouteImport
305
}
306
+
'/moderation': {
307
+
id: '/moderation'
308
+
path: '/moderation'
309
+
fullPath: '/moderation'
310
+
preLoaderRoute: typeof ModerationRouteImport
311
+
parentRoute: typeof rootRouteImport
312
+
}
313
'/feeds': {
314
id: '/feeds'
315
path: '/feeds'
···
352
preLoaderRoute: typeof ProfileDidIndexRouteImport
353
parentRoute: typeof rootRouteImport
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
+
}
369
'/_pathlessLayout/_nested-layout/route-b': {
370
id: '/_pathlessLayout/_nested-layout/route-b'
371
path: '/route-b'
···
385
path: '/profile/$did/post/$rkey'
386
fullPath: '/profile/$did/post/$rkey'
387
preLoaderRoute: typeof ProfileDidPostRkeyRouteImport
388
+
parentRoute: typeof rootRouteImport
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': {
···
476
IndexRoute: IndexRoute,
477
PathlessLayoutRoute: PathlessLayoutRouteWithChildren,
478
FeedsRoute: FeedsRoute,
479
+
ModerationRoute: ModerationRoute,
480
NotificationsRoute: NotificationsRoute,
481
SearchRoute: SearchRoute,
482
SettingsRoute: SettingsRoute,
483
CallbackIndexRoute: CallbackIndexRoute,
484
+
ProfileDidFollowersRoute: ProfileDidFollowersRoute,
485
+
ProfileDidFollowsRoute: ProfileDidFollowsRoute,
486
ProfileDidIndexRoute: ProfileDidIndexRoute,
487
+
ProfileDidFeedRkeyRoute: ProfileDidFeedRkeyRoute,
488
ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren,
489
}
490
export const routeTree = rootRouteImport
+178
-16
src/routes/__root.tsx
+178
-16
src/routes/__root.tsx
···
14
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
15
import { useAtom } from "jotai";
16
import * as React from "react";
17
import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive";
18
19
import { Composer } from "~/components/Composer";
···
22
import Login from "~/components/Login";
23
import { NotFound } from "~/components/NotFound";
24
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
25
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
26
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
27
import { seo } from "~/utils/seo";
···
79
function RootComponent() {
80
return (
81
<UnifiedAuthProvider>
82
-
<RootDocument>
83
-
<KeepAliveProvider>
84
-
<KeepAliveOutlet />
85
-
</KeepAliveProvider>
86
-
</RootDocument>
87
</UnifiedAuthProvider>
88
);
89
}
90
91
function RootDocument({ children }: { children: React.ReactNode }) {
92
useAtomCssVar(hueAtom, "--tw-gray-hue");
93
const location = useLocation();
···
103
const isSettings = location.pathname.startsWith("/settings");
104
const isSearch = location.pathname.startsWith("/search");
105
const isFeeds = location.pathname.startsWith("/feeds");
106
107
const locationEnum:
108
| "feeds"
···
110
| "settings"
111
| "notifications"
112
| "profile"
113
| "home" = isFeeds
114
? "feeds"
115
: isSearch
···
120
? "notifications"
121
: isProfile
122
? "profile"
123
-
: "home";
124
125
const [, setComposerPost] = useAtom(composerAtom);
126
···
131
<div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950">
132
<nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start">
133
<div className="flex items-center gap-3 mb-4">
134
-
<FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
135
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
136
Red Dwarf{" "}
137
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
193
})
194
}
195
text="Feeds"
196
/>
197
<MaterialNavItem
198
InactiveIcon={
···
232
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
233
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
234
//active={true}
235
-
onClickCallbback={() => setComposerPost({ kind: 'root' })}
236
text="Post"
237
/>
238
</div>
···
370
371
<nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start">
372
<div className="flex items-center gap-3 mb-4">
373
-
<FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
374
</div>
375
<MaterialNavItem
376
small
···
433
/>
434
<MaterialNavItem
435
small
436
InactiveIcon={
437
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
438
}
···
472
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
473
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
474
//active={true}
475
-
onClickCallbback={() => setComposerPost({ kind: 'root' })}
476
text="Post"
477
/>
478
</div>
···
482
<button
483
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
style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }}
485
-
onClick={() => setComposerPost({ kind: 'root' })}
486
type="button"
487
aria-label="Create Post"
488
>
···
499
</main>
500
501
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
502
-
<div className="px-4 pt-4"><Import /></div>
503
<Login />
504
505
<div className="flex-1"></div>
506
<p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4">
507
-
Red Dwarf is a Bluesky client that does not rely on any Bluesky API App Servers. Instead, it uses Microcosm to fetch records directly from each users' PDS (via Slingshot) and connect them using backlinks (via Constellation)
508
</p>
509
</aside>
510
</div>
···
651
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
652
}
653
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
654
-
active={locationEnum === "settings"}
655
onClickCallbback={() =>
656
navigate({
657
to: "/settings",
···
680
) : (
681
<div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10">
682
<div className="flex items-center gap-2">
683
-
<FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
684
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
685
Red Dwarf{" "}
686
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
700
);
701
}
702
703
-
function MaterialNavItem({
704
InactiveIcon,
705
ActiveIcon,
706
text,
···
14
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
15
import { useAtom } from "jotai";
16
import * as React from "react";
17
+
import { toast as sonnerToast } from "sonner";
18
+
import { Toaster } from "sonner";
19
import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive";
20
21
import { Composer } from "~/components/Composer";
···
24
import Login from "~/components/Login";
25
import { NotFound } from "~/components/NotFound";
26
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
27
+
import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider";
28
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
29
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
30
import { seo } from "~/utils/seo";
···
82
function RootComponent() {
83
return (
84
<UnifiedAuthProvider>
85
+
<LikeMutationQueueProvider>
86
+
<RootDocument>
87
+
<KeepAliveProvider>
88
+
<AppToaster />
89
+
<KeepAliveOutlet />
90
+
</KeepAliveProvider>
91
+
</RootDocument>
92
+
</LikeMutationQueueProvider>
93
</UnifiedAuthProvider>
94
);
95
}
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
+
201
function RootDocument({ children }: { children: React.ReactNode }) {
202
useAtomCssVar(hueAtom, "--tw-gray-hue");
203
const location = useLocation();
···
213
const isSettings = location.pathname.startsWith("/settings");
214
const isSearch = location.pathname.startsWith("/search");
215
const isFeeds = location.pathname.startsWith("/feeds");
216
+
const isModeration = location.pathname.startsWith("/moderation");
217
218
const locationEnum:
219
| "feeds"
···
221
| "settings"
222
| "notifications"
223
| "profile"
224
+
| "moderation"
225
| "home" = isFeeds
226
? "feeds"
227
: isSearch
···
232
? "notifications"
233
: isProfile
234
? "profile"
235
+
: isModeration
236
+
? "moderation"
237
+
: "home";
238
239
const [, setComposerPost] = useAtom(composerAtom);
240
···
245
<div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950">
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">
247
<div className="flex items-center gap-3 mb-4">
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
+
/>
255
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
256
Red Dwarf{" "}
257
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
313
})
314
}
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"
328
/>
329
<MaterialNavItem
330
InactiveIcon={
···
364
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
365
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
366
//active={true}
367
+
onClickCallbback={() => setComposerPost({ kind: "root" })}
368
text="Post"
369
/>
370
</div>
···
502
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">
504
<div className="flex items-center gap-3 mb-4">
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
+
/>
512
</div>
513
<MaterialNavItem
514
small
···
571
/>
572
<MaterialNavItem
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
587
InactiveIcon={
588
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
589
}
···
623
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
624
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
625
//active={true}
626
+
onClickCallbback={() => setComposerPost({ kind: "root" })}
627
text="Post"
628
/>
629
</div>
···
633
<button
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"
635
style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }}
636
+
onClick={() => setComposerPost({ kind: "root" })}
637
type="button"
638
aria-label="Create Post"
639
>
···
650
</main>
651
652
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
653
+
<div className="px-4 pt-4">
654
+
<Import />
655
+
</div>
656
<Login />
657
658
<div className="flex-1"></div>
659
<p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4">
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)
664
</p>
665
</aside>
666
</div>
···
807
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
808
}
809
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
810
+
active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"}
811
onClickCallbback={() =>
812
navigate({
813
to: "/settings",
···
836
) : (
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">
838
<div className="flex items-center gap-2">
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
+
/>
846
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
847
Red Dwarf{" "}
848
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
862
);
863
}
864
865
+
export function MaterialNavItem({
866
InactiveIcon,
867
ActiveIcon,
868
text,
+18
-1
src/routes/feeds.tsx
+18
-1
src/routes/feeds.tsx
···
1
import { createFileRoute } from "@tanstack/react-router";
2
3
+
import { Header } from "~/components/Header";
4
+
5
export const Route = createFileRoute("/feeds")({
6
component: Feeds,
7
});
8
9
export function Feeds() {
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
+
);
26
}
+44
-33
src/routes/index.tsx
+44
-33
src/routes/index.tsx
···
359
>
360
{!isAuthRestoring && savedFeeds.length > 0 ? (
361
<div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}>
362
-
{savedFeeds.map((item: any, idx: number) => {
363
-
const label = item.value.split("/").pop() || item.value;
364
-
const isActive = selectedFeed === item.value;
365
-
return (
366
-
<button
367
-
key={item.value || idx}
368
-
className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${
369
-
isActive
370
-
? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600"
371
-
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800"
372
-
// ? "bg-gray-500 text-white"
373
-
// : item.pinned
374
-
// ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
375
-
// : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
376
-
}`}
377
-
onClick={() => setSelectedFeed(item.value)}
378
-
title={item.value}
379
-
>
380
-
{label}
381
-
{item.pinned && (
382
-
<span
383
-
className={`ml-1 text-xs ${
384
-
isActive
385
-
? "text-gray-900 dark:text-gray-100"
386
-
: "text-gray-600 dark:text-gray-400"
387
-
}`}
388
-
>
389
-
โ
390
-
</span>
391
-
)}
392
-
</button>
393
-
);
394
-
})}
395
</div>
396
) : (
397
// <span className="text-xl font-bold ml-2">Home</span>
···
435
</div>
436
);
437
}
438
// not even used lmaooo
439
440
// export async function cachedResolveDIDWEBDOC({
···
359
>
360
{!isAuthRestoring && savedFeeds.length > 0 ? (
361
<div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}>
362
+
{savedFeeds.map((item: any, idx: number) => {return <FeedTabOnTop key={item} item={item} idx={idx} />})}
363
</div>
364
) : (
365
// <span className="text-xl font-bold ml-2">Home</span>
···
403
</div>
404
);
405
}
406
+
407
+
408
+
// todo please use types this is dangerous very dangerous.
409
+
// todo fix this whenever proper preferences is handled
410
+
function FeedTabOnTop({item, idx}:{item: any, idx: number}) {
411
+
const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom);
412
+
const selectedFeed = persistentSelectedFeed
413
+
const setSelectedFeed = setPersistentSelectedFeed
414
+
const rkey = item.value.split("/").pop() || item.value;
415
+
const isActive = selectedFeed === item.value;
416
+
const { data: feedrecord } = useQueryArbitrary(item.value)
417
+
const label = feedrecord?.value?.displayName || rkey
418
+
return (
419
+
<button
420
+
key={item.value || idx}
421
+
className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${
422
+
isActive
423
+
? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600"
424
+
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800"
425
+
// ? "bg-gray-500 text-white"
426
+
// : item.pinned
427
+
// ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
428
+
// : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
429
+
}`}
430
+
onClick={() => setSelectedFeed(item.value)}
431
+
title={item.value}
432
+
>
433
+
{label}
434
+
{item.pinned && (
435
+
<span
436
+
className={`ml-1 text-xs ${
437
+
isActive
438
+
? "text-gray-900 dark:text-gray-100"
439
+
: "text-gray-600 dark:text-gray-400"
440
+
}`}
441
+
>
442
+
โ
443
+
</span>
444
+
)}
445
+
</button>
446
+
);
447
+
}
448
+
449
// not even used lmaooo
450
451
// export async function cachedResolveDIDWEBDOC({
+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
+
}
+247
-123
src/routes/notifications.tsx
+247
-123
src/routes/notifications.tsx
···
1
import { AtUri } from "@atproto/api";
2
-
import * as TabsPrimitive from "@radix-ui/react-tabs";
3
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
4
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
5
import { useAtom } from "jotai";
6
import * as React from "react";
7
-
import { useEffect, useLayoutEffect } from "react";
8
9
import defaultpfp from "~/../public/favicon.png";
10
import { Header } from "~/components/Header";
11
import {
12
MdiCardsHeartOutline,
13
MdiCommentOutline,
14
MdiRepeat,
···
17
import { useAuth } from "~/providers/UnifiedAuthProvider";
18
import {
19
constellationURLAtom,
20
imgCDNAtom,
21
-
isAtTopAtom,
22
-
notificationsScrollAtom,
23
} from "~/utils/atoms";
24
import {
25
useInfiniteQueryAuthorFeed,
···
55
});
56
57
export default function NotificationsTabs() {
58
-
const [notifState, setNotifState] = useAtom(notificationsScrollAtom);
59
-
const activeTab = notifState.activeTab;
60
-
const [isAtTop] = useAtom(isAtTopAtom);
61
-
62
-
const handleValueChange = (newTab: string) => {
63
-
console.log(newTab);
64
-
setNotifState((prev) => {
65
-
const wow = {
66
-
...prev,
67
-
scrollPositions: {
68
-
...prev.scrollPositions,
69
-
[prev.activeTab]: window.scrollY,
70
-
},
71
-
activeTab: newTab,
72
-
};
73
-
//console.log(wow);
74
-
return wow;
75
-
});
76
-
};
77
-
78
-
useLayoutEffect(() => {
79
-
return () => {
80
-
setNotifState((prev) => {
81
-
const wow = {
82
-
...prev,
83
-
scrollPositions: {
84
-
...prev.scrollPositions,
85
-
[activeTab]: window.scrollY,
86
-
},
87
-
};
88
-
//console.log(wow);
89
-
return wow;
90
-
});
91
-
};
92
-
// eslint-disable-next-line react-hooks/exhaustive-deps
93
-
}, []);
94
-
95
return (
96
-
<TabsPrimitive.Root
97
-
value={activeTab}
98
-
onValueChange={handleValueChange}
99
-
className={`w-full`}
100
-
>
101
-
<TabsPrimitive.List
102
-
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`}
103
-
>
104
-
<TabsPrimitive.Trigger
105
-
value="mentions"
106
-
className="m3tab"
107
-
// styling is in app.css
108
-
>
109
-
Mentions
110
-
</TabsPrimitive.Trigger>
111
-
<TabsPrimitive.Trigger value="follows" className="m3tab">
112
-
Follows
113
-
</TabsPrimitive.Trigger>
114
-
<TabsPrimitive.Trigger value="postInteractions" className="m3tab">
115
-
Post Interactions
116
-
</TabsPrimitive.Trigger>
117
-
</TabsPrimitive.List>
118
-
119
-
<TabsPrimitive.Content value="mentions" className="flex-1">
120
-
{activeTab === "mentions" && <MentionsTab />}
121
-
</TabsPrimitive.Content>
122
-
123
-
<TabsPrimitive.Content value="follows" className="flex-1">
124
-
{activeTab === "follows" && <FollowsTab />}
125
-
</TabsPrimitive.Content>
126
-
127
-
<TabsPrimitive.Content value="postInteractions" className="flex-1">
128
-
{activeTab === "postInteractions" && <PostInteractionsTab />}
129
-
</TabsPrimitive.Content>
130
-
</TabsPrimitive.Root>
131
);
132
}
133
···
169
);
170
}, [infiniteMentionsData]);
171
172
-
const [notifState] = useAtom(notificationsScrollAtom);
173
-
const activeTab = notifState.activeTab;
174
-
useEffect(() => {
175
-
const savedY = notifState.scrollPositions[activeTab] ?? 0;
176
-
window.scrollTo(0, savedY);
177
-
}, [activeTab, notifState.scrollPositions]);
178
179
if (isLoading) return <LoadingState text="Loading mentions..." />;
180
if (isError) return <ErrorState error={error} />;
···
200
);
201
}
202
203
-
function FollowsTab() {
204
const { agent } = useAuth();
205
const [constellationurl] = useAtom(constellationURLAtom);
206
const infinitequeryresults = useInfiniteQuery({
207
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
208
{
209
constellation: constellationurl,
210
method: "/links",
211
-
target: agent?.did,
212
collection: "app.bsky.graph.follow",
213
path: ".subject",
214
}
215
),
216
-
enabled: !!agent?.did,
217
});
218
219
const {
···
238
);
239
}, [infiniteFollowsData]);
240
241
-
const [notifState] = useAtom(notificationsScrollAtom);
242
-
const activeTab = notifState.activeTab;
243
-
useEffect(() => {
244
-
const savedY = notifState.scrollPositions[activeTab] ?? 0;
245
-
window.scrollTo(0, savedY);
246
-
}, [activeTab, notifState.scrollPositions]);
247
248
-
if (isLoading) return <LoadingState text="Loading mentions..." />;
249
if (isError) return <ErrorState error={error} />;
250
251
-
if (!followsAturis?.length) return <EmptyState text="No mentions yet." />;
252
253
return (
254
<>
···
298
[postsData]
299
);
300
301
-
const [notifState] = useAtom(notificationsScrollAtom);
302
-
const activeTab = notifState.activeTab;
303
-
useEffect(() => {
304
-
const savedY = notifState.scrollPositions[activeTab] ?? 0;
305
-
window.scrollTo(0, savedY);
306
-
}, [activeTab, notifState.scrollPositions]);
307
308
return (
309
<>
310
-
{posts.map((m) => (
311
<PostInteractionsItem key={m.uri} uri={m.uri} />
312
))}
313
···
324
);
325
}
326
327
-
const ORDER: ("like" | "repost" | "reply" | "quote")[] = [
328
-
"like",
329
-
"repost",
330
-
"reply",
331
-
"quote",
332
-
];
333
334
function PostInteractionsItem({ uri }: { uri: string }) {
335
const { data: links } = useQueryConstellation({
336
method: "/links/all",
337
target: uri,
···
352
353
const all = likes + replies + reposts + quotes;
354
355
return (
356
<div className="flex flex-col">
357
<div className="border rounded-xl mx-4 mt-4 overflow-hidden">
358
<UniversalPostRendererATURILoader
359
isQuote
···
363
concise={true}
364
/>
365
<div className="flex flex-col divide-x">
366
-
<InteractionsButton
367
-
key={likes}
368
type={"like"}
369
uri={uri}
370
count={likes}
371
-
/>
372
-
<InteractionsButton
373
-
key={reposts}
374
type={"repost"}
375
uri={uri}
376
count={reposts}
377
-
/>
378
-
<InteractionsButton
379
-
key={replies}
380
type={"reply"}
381
uri={uri}
382
count={replies}
383
-
/>
384
-
<InteractionsButton
385
-
key={quotes}
386
type={"quote"}
387
uri={uri}
388
count={quotes}
389
-
/>
390
{!all && (
391
<div className="text-center text-gray-500 dark:text-gray-400 pb-3 pt-2 border-t">
392
No interactions yet.
···
442
) : (
443
<></>
444
)}
445
-
{type}
446
-
{/* bad grammar replys */}
447
-
{count > 1 ? "s" : ""} <div className="flex-1" /> {count}
448
</Link>
449
);
450
}
451
452
-
export function NotificationItem({ notification }: { notification: string }) {
453
const aturi = new AtUri(notification);
454
const navigate = useNavigate();
455
const { data: identity } = useQueryIdentity(aturi.host);
456
const resolvedDid = identity?.did;
···
494
<img
495
src={avatar || defaultpfp}
496
alt={identity?.handle}
497
-
className="w-10 h-10 rounded-full"
498
/>
499
) : (
500
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
···
1
import { AtUri } from "@atproto/api";
2
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
4
import { useAtom } from "jotai";
5
import * as React from "react";
6
7
import defaultpfp from "~/../public/favicon.png";
8
import { Header } from "~/components/Header";
9
import {
10
+
ReusableTabRoute,
11
+
useReusableTabScrollRestore,
12
+
} from "~/components/ReusableTabRoute";
13
+
import {
14
MdiCardsHeartOutline,
15
MdiCommentOutline,
16
MdiRepeat,
···
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
20
import {
21
constellationURLAtom,
22
+
enableBitesAtom,
23
imgCDNAtom,
24
+
postInteractionsFiltersAtom,
25
} from "~/utils/atoms";
26
import {
27
useInfiniteQueryAuthorFeed,
···
57
});
58
59
export default function NotificationsTabs() {
60
+
const [bitesEnabled] = useAtom(enableBitesAtom);
61
return (
62
+
<ReusableTabRoute
63
+
route={`Notifications`}
64
+
tabs={{
65
+
Mentions: <MentionsTab />,
66
+
Follows: <FollowsTab />,
67
+
"Post Interactions": <PostInteractionsTab />,
68
+
...bitesEnabled ? {
69
+
Bites: <BitesTab />,
70
+
} : {}
71
+
}}
72
+
/>
73
);
74
}
75
···
111
);
112
}, [infiniteMentionsData]);
113
114
+
useReusableTabScrollRestore("Notifications");
115
116
if (isLoading) return <LoadingState text="Loading mentions..." />;
117
if (isError) return <ErrorState error={error} />;
···
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 {
···
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
<>
···
304
[postsData]
305
);
306
307
+
useReusableTabScrollRestore("Notifications");
308
+
309
+
const [filters] = useAtom(postInteractionsFiltersAtom);
310
+
const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts);
311
312
return (
313
<>
314
+
<PostInteractionsFilterChipBar />
315
+
{!empty && posts.map((m) => (
316
<PostInteractionsItem key={m.uri} uri={m.uri} />
317
))}
318
···
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,
···
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
···
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.
···
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;
···
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" />
+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
+
}
+829
-80
src/routes/profile.$did/index.tsx
+829
-80
src/routes/profile.$did/index.tsx
···
1
-
import { RichText } from "@atproto/api";
2
import { useQueryClient } from "@tanstack/react-query";
3
-
import { createFileRoute, useNavigate } from "@tanstack/react-router";
4
import { useAtom } from "jotai";
5
import React, { type ReactNode, useEffect, useState } from "react";
6
7
import { Header } from "~/components/Header";
8
import {
9
renderTextWithFacets,
10
UniversalPostRendererATURILoader,
11
} from "~/components/UniversalPostRenderer";
12
import { useAuth } from "~/providers/UnifiedAuthProvider";
13
-
import { imgCDNAtom } from "~/utils/atoms";
14
import {
15
toggleFollow,
16
useGetFollowState,
17
useGetOneToOneState,
18
} from "~/utils/followState";
19
import {
20
useInfiniteQueryAuthorFeed,
21
useQueryIdentity,
22
useQueryProfile,
23
} from "~/utils/useQuery";
24
25
export const Route = createFileRoute("/profile/$did/")({
26
component: ProfileComponent,
···
29
function ProfileComponent() {
30
// booo bad this is not always the did it might be a handle, use identity.did instead
31
const { did } = Route.useParams();
32
const navigate = useNavigate();
33
const queryClient = useQueryClient();
34
const {
···
37
error: identityError,
38
} = useQueryIdentity(did);
39
40
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
41
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
42
const pdsUrl = identity?.pds;
···
47
const { data: profileRecord } = useQueryProfile(profileUri);
48
const profile = profileRecord?.value;
49
50
-
const {
51
-
data: postsData,
52
-
fetchNextPage,
53
-
hasNextPage,
54
-
isFetchingNextPage,
55
-
isLoading: arePostsLoading,
56
-
} = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl);
57
-
58
-
React.useEffect(() => {
59
-
if (postsData) {
60
-
postsData.pages.forEach((page) => {
61
-
page.records.forEach((record) => {
62
-
if (!queryClient.getQueryData(["post", record.uri])) {
63
-
queryClient.setQueryData(["post", record.uri], record);
64
-
}
65
-
});
66
-
});
67
-
}
68
-
}, [postsData, queryClient]);
69
-
70
-
const posts = React.useMemo(
71
-
() => postsData?.pages.flatMap((page) => page.records) ?? [],
72
-
[postsData]
73
-
);
74
-
75
const [imgcdn] = useAtom(imgCDNAtom);
76
77
function getAvatarUrl(p: typeof profile) {
···
90
const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did;
91
const description = profile?.description || "";
92
93
-
if (isIdentityLoading) {
94
-
return (
95
-
<div className="p-4 text-center text-gray-500">Resolving profile...</div>
96
-
);
97
-
}
98
99
-
if (identityError) {
100
-
return (
101
-
<div className="p-4 text-center text-red-500">
102
-
Error: {identityError.message}
103
-
</div>
104
-
);
105
-
}
106
107
-
if (!resolvedDid) {
108
-
return (
109
-
<div className="p-4 text-center text-gray-500">Profile not found.</div>
110
-
);
111
-
}
112
113
return (
114
-
<>
115
<Header
116
title={`Profile`}
117
backButtonCallback={() => {
···
121
window.location.assign("/");
122
}
123
}}
124
/>
125
{/* <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">
126
<Link
···
155
156
{/* Avatar (PFP) */}
157
<div className="absolute left-[16px] top-[100px] ">
158
-
<img
159
-
src={getAvatarUrl(profile) || "/favicon.png"}
160
-
alt="avatar"
161
-
className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700"
162
-
/>
163
</div>
164
165
<div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5">
166
{/*
167
todo: full follow and unfollow backfill (along with partial likes backfill,
168
just enough for it to be useful)
···
170
also save it persistently
171
*/}
172
<FollowButton targetdidorhandle={did} />
173
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
174
... {/* todo: icon */}
175
</button>
176
</div>
···
182
<Mutual targetdidorhandle={did} />
183
{handle}
184
</div>
185
{description && (
186
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
187
{/* {description} */}
···
191
</div>
192
</div>
193
194
-
{/* Posts Section */}
195
-
<div className="max-w-2xl mx-auto">
196
-
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
197
-
Posts
198
</div>
199
-
<div>
200
-
{posts.map((post) => (
201
<UniversalPostRendererATURILoader
202
-
key={post.uri}
203
-
atUri={post.uri}
204
feedviewpost={true}
205
/>
206
-
))}
207
-
</div>
208
209
-
{/* Loading and "Load More" states */}
210
-
{arePostsLoading && posts.length === 0 && (
211
-
<div className="p-4 text-center text-gray-500">Loading posts...</div>
212
-
)}
213
-
{isFetchingNextPage && (
214
-
<div className="p-4 text-center text-gray-500">Loading more...</div>
215
-
)}
216
-
{hasNextPage && !isFetchingNextPage && (
217
-
<button
218
-
onClick={() => fetchNextPage()}
219
-
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"
220
>
221
-
Load More Posts
222
-
</button>
223
-
)}
224
-
{posts.length === 0 && !arePostsLoading && (
225
-
<div className="p-4 text-center text-gray-500">No posts found.</div>
226
-
)}
227
</div>
228
</>
229
);
230
}
···
280
)}
281
</>
282
) : (
283
-
<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]">
284
Edit Profile
285
</button>
286
)}
287
</>
288
);
289
}
290
291
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
···
1
+
import { Agent, RichText } from "@atproto/api";
2
+
import * as ATPAPI from "@atproto/api";
3
+
import { TID } from "@atproto/common-web";
4
import { useQueryClient } from "@tanstack/react-query";
5
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
6
import { useAtom } from "jotai";
7
import React, { type ReactNode, useEffect, useState } from "react";
8
9
+
import defaultpfp from "~/../public/favicon.png";
10
import { Header } from "~/components/Header";
11
import {
12
+
ReusableTabRoute,
13
+
useReusableTabScrollRestore,
14
+
} from "~/components/ReusableTabRoute";
15
+
import {
16
renderTextWithFacets,
17
UniversalPostRendererATURILoader,
18
} from "~/components/UniversalPostRenderer";
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
20
+
import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms";
21
import {
22
toggleFollow,
23
useGetFollowState,
24
useGetOneToOneState,
25
} from "~/utils/followState";
26
+
import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue";
27
import {
28
useInfiniteQueryAuthorFeed,
29
+
useQueryArbitrary,
30
+
useQueryConstellation,
31
+
useQueryConstellationLinksCountDistinctDids,
32
useQueryIdentity,
33
useQueryProfile,
34
} from "~/utils/useQuery";
35
+
import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx";
36
+
37
+
import { renderSnack } from "../__root";
38
+
import { Chip } from "../notifications";
39
40
export const Route = createFileRoute("/profile/$did/")({
41
component: ProfileComponent,
···
44
function ProfileComponent() {
45
// booo bad this is not always the did it might be a handle, use identity.did instead
46
const { did } = Route.useParams();
47
+
const { agent } = useAuth();
48
const navigate = useNavigate();
49
const queryClient = useQueryClient();
50
const {
···
53
error: identityError,
54
} = useQueryIdentity(did);
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
+
70
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
71
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
72
const pdsUrl = identity?.pds;
···
77
const { data: profileRecord } = useQueryProfile(profileUri);
78
const profile = profileRecord?.value;
79
80
const [imgcdn] = useAtom(imgCDNAtom);
81
82
function getAvatarUrl(p: typeof profile) {
···
95
const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did;
96
const description = profile?.description || "";
97
98
+
const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord;
99
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
+
);
110
111
+
const followercount = resultwhateversure?.data?.total;
112
113
return (
114
+
<div className="">
115
<Header
116
title={`Profile`}
117
backButtonCallback={() => {
···
121
window.location.assign("/");
122
}
123
}}
124
+
bottomBorderDisabled={true}
125
/>
126
{/* <div className="flex gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700">
127
<Link
···
156
157
{/* Avatar (PFP) */}
158
<div className="absolute left-[16px] top-[100px] ">
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
+
)}
172
</div>
173
174
<div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5">
175
+
<BiteButton targetdidorhandle={did} />
176
{/*
177
todo: full follow and unfollow backfill (along with partial likes backfill,
178
just enough for it to be useful)
···
180
also save it persistently
181
*/}
182
<FollowButton targetdidorhandle={did} />
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
+
>
193
... {/* todo: icon */}
194
</button>
195
</div>
···
201
<Mutual targetdidorhandle={did} />
202
{handle}
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>
218
{description && (
219
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
220
{/* {description} */}
···
224
</div>
225
</div>
226
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...
261
+
</div>
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 (
471
<UniversalPostRendererATURILoader
472
+
key={repostRecord.subject.uri}
473
+
atUri={repostRecord.subject.uri}
474
feedviewpost={true}
475
+
repostedby={repost.uri}
476
/>
477
+
);
478
+
})}
479
+
</div>
480
+
481
+
{/* Loading and "Load More" states */}
482
+
{arePostsLoading && reposts.length === 0 && (
483
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
484
+
)}
485
+
{isFetchingNextPage && (
486
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
487
+
)}
488
+
{hasNextPage && !isFetchingNextPage && (
489
+
<button
490
+
onClick={() => fetchNextPage()}
491
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
492
+
>
493
+
Load More Posts
494
+
</button>
495
+
)}
496
+
{reposts.length === 0 && !arePostsLoading && (
497
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
498
+
)}
499
+
</>
500
+
);
501
+
}
502
+
503
+
function FeedsTab({ did }: { did: string }) {
504
+
useReusableTabScrollRestore(`Profile` + did);
505
+
const {
506
+
data: identity,
507
+
isLoading: isIdentityLoading,
508
+
error: identityError,
509
+
} = useQueryIdentity(did);
510
+
511
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
512
+
513
+
const {
514
+
data: feedsData,
515
+
fetchNextPage,
516
+
hasNextPage,
517
+
isFetchingNextPage,
518
+
isLoading: arePostsLoading,
519
+
} = useInfiniteQueryAuthorFeed(
520
+
resolvedDid,
521
+
identity?.pds,
522
+
"app.bsky.feed.generator"
523
+
);
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"
610
>
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
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
+
)}
896
</>
897
);
898
}
···
948
)}
949
</>
950
) : (
951
+
<button
952
+
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
953
+
onClick={(e) => {
954
+
renderSnack({
955
+
title: "Not Implemented Yet",
956
+
description: "Sorry...",
957
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
958
+
});
959
+
}}
960
+
>
961
Edit Profile
962
</button>
963
)}
964
</>
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
+
}
1038
}
1039
1040
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
+217
-9
src/routes/search.tsx
+217
-9
src/routes/search.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
2
3
import { Header } from "~/components/Header";
4
import { Import } from "~/components/Import";
5
6
export const Route = createFileRoute("/search")({
7
component: Search,
8
});
9
10
export function Search() {
11
return (
12
<>
13
<Header
···
21
}}
22
/>
23
<div className=" flex flex-col items-center mt-4 mx-4 gap-4">
24
-
<Import />
25
<div className="flex flex-col">
26
-
<p className="text-gray-600 dark:text-gray-400">
27
-
Sorry we dont have search. But instead, you can load some of these
28
-
types of content into Red Dwarf:
29
-
</p>
30
<ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400">
31
<li>
32
-
Bluesky URLs from supported clients (like{" "}
33
<code className="text-sm">bsky.app</code> or{" "}
34
<code className="text-sm">deer.social</code>).
35
</li>
···
39
).
40
</li>
41
<li>
42
-
Plain handles (like{" "}
43
<code className="text-sm">@username.bsky.social</code>).
44
</li>
45
<li>
46
-
Direct DIDs (Decentralized Identifiers, starting with{" "}
47
<code className="text-sm">did:</code>).
48
</li>
49
</ul>
···
51
Simply paste one of these into the import field above and press
52
Enter to load the content.
53
</p>
54
</div>
55
</div>
56
</>
57
);
58
}
···
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";
6
7
import { Header } from "~/components/Header";
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";
25
26
export const Route = createFileRoute("/search")({
27
component: Search,
28
});
29
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
+
105
return (
106
<>
107
<Header
···
115
}}
116
/>
117
<div className=" flex flex-col items-center mt-4 mx-4 gap-4">
118
+
<Import optionaltextstring={q} />
119
<div className="flex flex-col">
120
+
<p className="text-gray-600 dark:text-gray-400">{maintext}</p>
121
<ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400">
122
<li>
123
+
Bluesky URLs (from supported clients) (like{" "}
124
<code className="text-sm">bsky.app</code> or{" "}
125
<code className="text-sm">deer.social</code>).
126
</li>
···
130
).
131
</li>
132
<li>
133
+
User Handles (like{" "}
134
<code className="text-sm">@username.bsky.social</code>).
135
</li>
136
<li>
137
+
DIDs (Decentralized Identifiers, starting with{" "}
138
<code className="text-sm">did:</code>).
139
</li>
140
</ul>
···
142
Simply paste one of these into the import field above and press
143
Enter to load the content.
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
+
)}
177
</div>
178
</div>
179
+
{q ? <SearchTabs query={q} /> : <></>}
180
</>
181
);
182
}
183
+
184
+
function SearchTabs({ query }: { query: string }) {
185
+
return (
186
+
<div>
187
+
<ReusableTabRoute
188
+
route={`search` + query}
189
+
tabs={{
190
+
Likes: <LycanTab query={query} type={"likes"} key={"likes"} />,
191
+
Reposts: <LycanTab query={query} type={"reposts"} key={"reposts"} />,
192
+
Quotes: <LycanTab query={query} type={"quotes"} key={"quotes"} />,
193
+
Pins: <LycanTab query={query} type={"pins"} key={"pins"} />,
194
+
}}
195
+
/>
196
+
</div>
197
+
);
198
+
}
199
+
200
+
function LycanTab({
201
+
query,
202
+
type,
203
+
}: {
204
+
query: string;
205
+
type: "likes" | "pins" | "reposts" | "quotes";
206
+
}) {
207
+
useReusableTabScrollRestore("search" + query);
208
+
209
+
const {
210
+
data: postsData,
211
+
fetchNextPage,
212
+
hasNextPage,
213
+
isFetchingNextPage,
214
+
isLoading: arePostsLoading,
215
+
} = useInfiniteQueryLycanSearch({ query: query, type: type });
216
+
217
+
const posts = useMemo(
218
+
() =>
219
+
postsData?.pages.flatMap((page) => {
220
+
if (page) {
221
+
return page.posts;
222
+
} else {
223
+
return [];
224
+
}
225
+
}) ?? [],
226
+
[postsData]
227
+
);
228
+
229
+
return (
230
+
<>
231
+
{/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
232
+
Posts
233
+
</div> */}
234
+
<div>
235
+
{posts.map((post) => (
236
+
<UniversalPostRendererATURILoader
237
+
key={post}
238
+
atUri={post}
239
+
feedviewpost={true}
240
+
/>
241
+
))}
242
+
</div>
243
+
244
+
{/* Loading and "Load More" states */}
245
+
{arePostsLoading && posts.length === 0 && (
246
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
247
+
)}
248
+
{isFetchingNextPage && (
249
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
250
+
)}
251
+
{hasNextPage && !isFetchingNextPage && (
252
+
<button
253
+
onClick={() => fetchNextPage()}
254
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
255
+
>
256
+
Load More Posts
257
+
</button>
258
+
)}
259
+
{posts.length === 0 && !arePostsLoading && (
260
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
261
+
)}
262
+
</>
263
+
);
264
+
265
+
return <></>;
266
+
}
+188
-17
src/routes/settings.tsx
+188
-17
src/routes/settings.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
2
-
import { useAtom } from "jotai";
3
-
import { Slider } from "radix-ui";
4
5
import { Header } from "~/components/Header";
6
import Login from "~/components/Login";
···
9
defaultconstellationURL,
10
defaulthue,
11
defaultImgCDN,
12
defaultslingshotURL,
13
defaultVideoCDN,
14
hueAtom,
15
imgCDNAtom,
16
slingshotURLAtom,
17
videoCDNAtom,
18
} from "~/utils/atoms";
19
20
export const Route = createFileRoute("/settings")({
21
component: Settings,
22
});
23
24
export function Settings() {
25
return (
26
<>
27
<Header
···
37
<div className="lg:hidden">
38
<Login />
39
</div>
40
<div className="h-4" />
41
<TextInputSetting
42
atom={constellationURLAtom}
43
title={"Constellation"}
···
66
description={"Customize the Slingshot instance to be used by Red Dwarf"}
67
init={defaultVideoCDN}
68
/>
69
70
-
<Hue />
71
-
<p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm">
72
-
please restart/refresh the app if changes arent applying correctly
73
</p>
74
</>
75
);
76
}
77
function Hue() {
78
const [hue, setHue] = useAtom(hueAtom);
79
return (
80
-
<div className="flex flex-col px-4 mt-4 ">
81
-
<span className="z-10">Hue</span>
82
-
<div className="flex flex-row items-center gap-4">
83
-
<SliderComponent
84
-
atom={hueAtom}
85
-
max={360}
86
-
/>
87
<button
88
onClick={() => setHue(defaulthue ?? 28)}
89
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
···
154
);
155
}
156
157
-
158
interface SliderProps {
159
atom: typeof hueAtom;
160
min?: number;
···
168
max = 100,
169
step = 1,
170
}) => {
171
-
172
-
const [value, setValue] = useAtom(atom)
173
174
return (
175
<Slider.Root
···
186
<Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" />
187
</Slider.Root>
188
);
189
-
};
···
1
+
import { createFileRoute, 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";
5
6
import { Header } from "~/components/Header";
7
import Login from "~/components/Login";
···
10
defaultconstellationURL,
11
defaulthue,
12
defaultImgCDN,
13
+
defaultLycanURL,
14
defaultslingshotURL,
15
defaultVideoCDN,
16
+
enableBitesAtom,
17
+
enableBridgyTextAtom,
18
+
enableWafrnTextAtom,
19
hueAtom,
20
imgCDNAtom,
21
+
lycanURLAtom,
22
slingshotURLAtom,
23
videoCDNAtom,
24
} from "~/utils/atoms";
25
26
+
import { MaterialNavItem } from "./__root";
27
+
28
export const Route = createFileRoute("/settings")({
29
component: Settings,
30
});
31
32
export function Settings() {
33
+
const navigate = useNavigate();
34
return (
35
<>
36
<Header
···
46
<div className="lg:hidden">
47
<Login />
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>
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>
88
<TextInputSetting
89
atom={constellationURLAtom}
90
title={"Constellation"}
···
113
description={"Customize the Slingshot instance to be used by Red Dwarf"}
114
init={defaultVideoCDN}
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
+
/>
122
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
149
</p>
150
</>
151
);
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
+
217
function Hue() {
218
const [hue, setHue] = useAtom(hueAtom);
219
return (
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} />
227
<button
228
onClick={() => setHue(defaulthue ?? 28)}
229
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
···
294
);
295
}
296
297
interface SliderProps {
298
atom: typeof hueAtom;
299
min?: number;
···
307
max = 100,
308
step = 1,
309
}) => {
310
+
const [value, setValue] = useAtom(atom);
311
312
return (
313
<Slider.Root
···
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" />
325
</Slider.Root>
326
);
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
+
};
+98
-1
src/styles/app.css
+98
-1
src/styles/app.css
···
33
--color-gray-950: oklch(0.129 0.055 var(--safe-hue));
34
}
35
36
@layer base {
37
html {
38
color-scheme: light dark;
···
84
.dangerousFediContent {
85
& a[href]{
86
text-decoration: none;
87
-
color: rgb(29, 122, 242);
88
word-break: break-all;
89
}
90
}
···
275
&::before{
276
background-color: var(--color-gray-500);
277
}
278
}
279
}
280
}
···
33
--color-gray-950: oklch(0.129 0.055 var(--safe-hue));
34
}
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
+
42
@layer base {
43
html {
44
color-scheme: light dark;
···
90
.dangerousFediContent {
91
& a[href]{
92
text-decoration: none;
93
+
color: var(--link-text-color);
94
word-break: break-all;
95
}
96
}
···
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
}
377
}
+66
-2
src/utils/atoms.ts
+66
-2
src/utils/atoms.ts
···
2
import { atomWithStorage } from "jotai/utils";
3
import { useEffect } from "react";
4
5
export const store = createStore();
6
7
export const quickAuthAtom = atomWithStorage<string | null>(
···
21
{}
22
);
23
24
-
type NotificationsScrollState = {
25
activeTab: string;
26
scrollPositions: Record<string, number>;
27
};
28
-
export const notificationsScrollAtom = atom<NotificationsScrollState>({
29
activeTab: "mentions",
30
scrollPositions: {},
31
});
32
33
export const likedPostsAtom = atomWithStorage<Record<string, string>>(
34
"likedPosts",
35
{}
36
);
37
38
export const defaultconstellationURL = "constellation.microcosm.blue";
39
export const constellationURLAtom = atomWithStorage<string>(
40
"constellationURL",
···
51
export const videoCDNAtom = atomWithStorage<string>(
52
"videocdnurl",
53
defaultVideoCDN
54
);
55
56
export const defaulthue = 28;
···
89
// console.log("atom get ", initial);
90
// document.documentElement.style.setProperty(cssVar, initial.toString());
91
// }
···
2
import { atomWithStorage } from "jotai/utils";
3
import { useEffect } from "react";
4
5
+
import { type ProfilePostsFilter } from "~/routes/profile.$did";
6
+
7
export const store = createStore();
8
9
export const quickAuthAtom = atomWithStorage<string | null>(
···
23
{}
24
);
25
26
+
type TabRouteScrollState = {
27
activeTab: string;
28
scrollPositions: Record<string, number>;
29
};
30
+
/**
31
+
* @deprecated should be safe to remove i think
32
+
*/
33
+
export const notificationsScrollAtom = atom<TabRouteScrollState>({
34
activeTab: "mentions",
35
scrollPositions: {},
36
});
37
38
+
export type InteractionFilter = {
39
+
likes: boolean;
40
+
reposts: boolean;
41
+
quotes: boolean;
42
+
replies: boolean;
43
+
showAll: boolean;
44
+
};
45
+
const defaultFilters: InteractionFilter = {
46
+
likes: true,
47
+
reposts: true,
48
+
quotes: true,
49
+
replies: true,
50
+
showAll: false,
51
+
};
52
+
export const postInteractionsFiltersAtom = atomWithStorage<InteractionFilter>(
53
+
"postInteractionsFilters",
54
+
defaultFilters
55
+
);
56
+
57
+
export const reusableTabRouteScrollAtom = atom<Record<string, TabRouteScrollState | undefined> | undefined>({});
58
+
59
export const likedPostsAtom = atomWithStorage<Record<string, string>>(
60
"likedPosts",
61
{}
62
);
63
64
+
export type LikeRecord = {
65
+
uri: string; // at://did/collection/rkey
66
+
target: string;
67
+
cid: string;
68
+
};
69
+
70
+
export const internalLikedPostsAtom = atomWithStorage<Record<string, LikeRecord | null>>(
71
+
"internal-liked-posts",
72
+
{}
73
+
);
74
+
75
+
export const profileChipsAtom = atom<Record<string, ProfilePostsFilter | null>>({})
76
+
77
export const defaultconstellationURL = "constellation.microcosm.blue";
78
export const constellationURLAtom = atomWithStorage<string>(
79
"constellationURL",
···
90
export const videoCDNAtom = atomWithStorage<string>(
91
"videocdnurl",
92
defaultVideoCDN
93
+
);
94
+
95
+
export const defaultLycanURL = "";
96
+
export const lycanURLAtom = atomWithStorage<string>(
97
+
"lycanURL",
98
+
defaultLycanURL
99
);
100
101
export const defaulthue = 28;
···
134
// console.log("atom get ", initial);
135
// document.documentElement.style.setProperty(cssVar, initial.toString());
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
import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser';
2
3
-
// i tried making this https://pds-nd.whey.party but cors is annoying as fuck
4
-
const handleResolverPDS = 'https://bsky.social';
5
6
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
7
// @ts-ignore this should be fine ? the vite plugin should generate this before errors
···
1
import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser';
2
3
+
import resolvers from '../../public/resolvers.json' with { type: 'json' };
4
+
const handleResolverPDS = resolvers.resolver || 'https://bsky.social';
5
6
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
7
// @ts-ignore this should be fine ? the vite plugin should generate this before errors
+409
-158
src/utils/useQuery.ts
+409
-158
src/utils/useQuery.ts
···
5
queryOptions,
6
useInfiniteQuery,
7
useQuery,
8
-
type UseQueryResult} from "@tanstack/react-query";
9
import { useAtom } from "jotai";
10
11
-
import { constellationURLAtom, slingshotURLAtom } from "./atoms";
12
13
-
export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) {
14
return queryOptions({
15
queryKey: ["identity", didorhandle],
16
queryFn: async () => {
17
-
if (!didorhandle) return undefined as undefined
18
const res = await fetch(
19
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
20
);
···
31
}
32
},
33
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
34
-
gcTime: /*0//*/5 * 60 * 1000,
35
});
36
}
37
export function useQueryIdentity(didorhandle: string): UseQueryResult<
···
43
},
44
Error
45
>;
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
-
>
60
export function useQueryIdentity(didorhandle?: string) {
61
-
const [slingshoturl] = useAtom(slingshotURLAtom)
62
return useQuery(constructIdentityQuery(didorhandle, slingshoturl));
63
}
64
···
66
return queryOptions({
67
queryKey: ["post", uri],
68
queryFn: async () => {
69
-
if (!uri) return undefined as undefined
70
const res = await fetch(
71
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
72
);
···
77
return undefined;
78
}
79
if (res.status === 400) return undefined;
80
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
81
return undefined; // cache โnot foundโ
82
}
83
try {
84
if (!res.ok) throw new Error("Failed to fetch post");
85
-
return (data) as {
86
uri: string;
87
cid: string;
88
value: any;
···
97
return failureCount < 2;
98
},
99
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
100
-
gcTime: /*0//*/5 * 60 * 1000,
101
});
102
}
103
export function useQueryPost(uri: string): UseQueryResult<
···
108
},
109
Error
110
>;
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
-
>
124
export function useQueryPost(uri?: string) {
125
-
const [slingshoturl] = useAtom(slingshotURLAtom)
126
return useQuery(constructPostQuery(uri, slingshoturl));
127
}
128
···
130
return queryOptions({
131
queryKey: ["profile", uri],
132
queryFn: async () => {
133
-
if (!uri) return undefined as undefined
134
const res = await fetch(
135
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
136
);
···
141
return undefined;
142
}
143
if (res.status === 400) return undefined;
144
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
145
return undefined; // cache โnot foundโ
146
}
147
try {
148
if (!res.ok) throw new Error("Failed to fetch post");
149
-
return (data) as {
150
uri: string;
151
cid: string;
152
value: any;
···
161
return failureCount < 2;
162
},
163
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
164
-
gcTime: /*0//*/5 * 60 * 1000,
165
});
166
}
167
export function useQueryProfile(uri: string): UseQueryResult<
···
172
},
173
Error
174
>;
175
-
export function useQueryProfile(): UseQueryResult<
176
-
undefined,
177
-
Error
178
-
>;
179
-
export function useQueryProfile(uri?: string):
180
-
UseQueryResult<
181
-
{
182
uri: string;
183
cid: string;
184
value: ATPAPI.AppBskyActorProfile.Record;
185
-
} | undefined,
186
-
Error
187
-
>
188
export function useQueryProfile(uri?: string) {
189
-
const [slingshoturl] = useAtom(slingshotURLAtom)
190
return useQuery(constructProfileQuery(uri, slingshoturl));
191
}
192
···
222
// method: "/links/all",
223
// target: string
224
// ): QueryOptions<linksAllResponse, Error>;
225
-
export function constructConstellationQuery(query?:{
226
-
constellation: string,
227
method:
228
| "/links"
229
| "/links/distinct-dids"
230
| "/links/count"
231
| "/links/count/distinct-dids"
232
| "/links/all"
233
-
| "undefined",
234
-
target: string,
235
-
collection?: string,
236
-
path?: string,
237
-
cursor?: string,
238
-
dids?: string[]
239
-
}
240
-
) {
241
// : QueryOptions<
242
// | linksRecordsResponse
243
// | linksDidsResponse
···
247
// Error
248
// >
249
return queryOptions({
250
-
queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const,
251
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
259
const res = await fetch(
260
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
261
);
···
281
},
282
// enforce short lifespan
283
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
284
-
gcTime: /*0//*/5 * 60 * 1000,
285
});
286
}
287
export function useQueryConstellation(query: {
288
method: "/links";
289
target: string;
···
346
>
347
| undefined {
348
//if (!query) return;
349
-
const [constellationurl] = useAtom(constellationURLAtom)
350
return useQuery(
351
-
constructConstellationQuery(query && {constellation: constellationurl, ...query})
352
);
353
}
354
···
392
}) {
393
return queryOptions({
394
// 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 }],
396
queryFn: async () => {
397
-
if (!options) return undefined as undefined
398
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
399
if (isAuthed) {
400
// Authenticated flow
401
if (!agent || !pdsUrl || !feedServiceDid) {
402
-
throw new Error("Missing required info for authenticated feed fetch.");
403
}
404
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
405
const res = await agent.fetchHandler(url, {
···
409
"Content-Type": "application/json",
410
},
411
});
412
-
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
413
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
414
} else {
415
// Unauthenticated flow (using a public PDS/AppView)
416
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
417
const res = await fetch(url);
418
-
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
419
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
420
}
421
},
···
433
return useQuery(constructFeedSkeletonQuery(options));
434
}
435
436
-
export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) {
437
return queryOptions({
438
-
queryKey: ['preferences', agent?.did],
439
queryFn: async () => {
440
if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available");
441
const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
···
446
});
447
}
448
export function useQueryPreferences(options: {
449
-
agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined
450
}) {
451
return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl));
452
}
453
454
-
455
-
456
export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
457
return queryOptions({
458
queryKey: ["arbitrary", uri],
459
queryFn: async () => {
460
-
if (!uri) return undefined as undefined
461
const res = await fetch(
462
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
463
);
···
468
return undefined;
469
}
470
if (res.status === 400) return undefined;
471
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
472
return undefined; // cache โnot foundโ
473
}
474
try {
475
if (!res.ok) throw new Error("Failed to fetch post");
476
-
return (data) as {
477
uri: string;
478
cid: string;
479
value: any;
···
488
return failureCount < 2;
489
},
490
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
491
-
gcTime: /*0//*/5 * 60 * 1000,
492
});
493
}
494
export function useQueryArbitrary(uri: string): UseQueryResult<
···
499
},
500
Error
501
>;
502
-
export function useQueryArbitrary(): UseQueryResult<
503
-
undefined,
504
-
Error
505
-
>;
506
export function useQueryArbitrary(uri?: string): UseQueryResult<
507
-
{
508
-
uri: string;
509
-
cid: string;
510
-
value: any;
511
-
} | undefined,
512
Error
513
>;
514
export function useQueryArbitrary(uri?: string) {
515
-
const [slingshoturl] = useAtom(slingshotURLAtom)
516
return useQuery(constructArbitraryQuery(uri, slingshoturl));
517
}
518
519
-
export function constructFallbackNothingQuery(){
520
return queryOptions({
521
queryKey: ["nothing"],
522
queryFn: async () => {
523
-
return undefined
524
},
525
});
526
}
···
534
}[];
535
};
536
537
-
export function constructAuthorFeedQuery(did: string, pdsUrl: string) {
538
return queryOptions({
539
-
queryKey: ['authorFeed', did],
540
queryFn: async ({ pageParam }: QueryFunctionContext) => {
541
const limit = 25;
542
-
543
const cursor = pageParam as string | undefined;
544
-
const cursorParam = cursor ? `&cursor=${cursor}` : '';
545
-
546
-
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`;
547
-
548
const res = await fetch(url);
549
if (!res.ok) throw new Error("Failed to fetch author's posts");
550
-
551
return res.json() as Promise<ListRecordsResponse>;
552
},
553
});
554
}
555
556
-
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) {
557
-
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!);
558
-
559
return useInfiniteQuery({
560
queryKey,
561
queryFn,
···
573
isAuthed: boolean;
574
pdsUrl?: string;
575
feedServiceDid?: string;
576
}) {
577
-
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
578
-
579
return queryOptions({
580
queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
581
-
582
-
queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
583
const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
584
-
585
-
if (isAuthed) {
586
if (!agent || !pdsUrl || !feedServiceDid) {
587
-
throw new Error("Missing required info for authenticated feed fetch.");
588
}
589
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
590
const res = await agent.fetchHandler(url, {
···
594
"Content-Type": "application/json",
595
},
596
});
597
-
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
598
return (await res.json()) as FeedSkeletonPage;
599
} else {
600
-
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
601
const res = await fetch(url);
602
-
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
603
return (await res.json()) as FeedSkeletonPage;
604
}
605
},
···
612
isAuthed: boolean;
613
pdsUrl?: string;
614
feedServiceDid?: string;
615
}) {
616
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
617
-
618
-
return {...useInfiniteQuery({
619
-
queryKey,
620
-
queryFn,
621
-
initialPageParam: undefined as never,
622
-
getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
623
-
staleTime: Infinity,
624
-
refetchOnWindowFocus: false,
625
-
enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
626
-
}), queryKey: queryKey};
627
}
628
-
629
630
export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
631
-
constellation: string,
632
-
method: '/links'
633
-
target?: string
634
-
collection: string
635
-
path: string
636
}) {
637
// console.log(
638
// 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
639
// query,
···
642
return infiniteQueryOptions({
643
enabled: !!query?.target,
644
queryKey: [
645
-
'reddwarf_constellation',
646
query?.method,
647
query?.target,
648
query?.collection,
649
query?.path,
650
] as const,
651
652
-
queryFn: async ({pageParam}: {pageParam?: string}) => {
653
-
if (!query || !query?.target) return undefined
654
655
-
const method = query.method
656
-
const target = query.target
657
-
const collection = query.collection
658
-
const path = query.path
659
-
const cursor = pageParam
660
661
const res = await fetch(
662
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
663
-
collection ? `&collection=${encodeURIComponent(collection)}` : ''
664
-
}${path ? `&path=${encodeURIComponent(path)}` : ''}${
665
-
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
666
-
}`,
667
-
)
668
669
-
if (!res.ok) throw new Error('Failed to fetch')
670
671
-
return (await res.json()) as linksRecordsResponse
672
},
673
674
-
getNextPageParam: lastPage => {
675
-
return (lastPage as any)?.cursor ?? undefined
676
},
677
initialPageParam: undefined,
678
-
staleTime: 5 * 60 * 1000,
679
-
gcTime: 5 * 60 * 1000,
680
-
})
681
-
}
···
5
queryOptions,
6
useInfiniteQuery,
7
useQuery,
8
+
type UseQueryResult,
9
+
} from "@tanstack/react-query";
10
import { useAtom } from "jotai";
11
12
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
13
+
14
+
import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms";
15
16
+
export function constructIdentityQuery(
17
+
didorhandle?: string,
18
+
slingshoturl?: string
19
+
) {
20
return queryOptions({
21
queryKey: ["identity", didorhandle],
22
queryFn: async () => {
23
+
if (!didorhandle) return undefined as undefined;
24
const res = await fetch(
25
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
26
);
···
37
}
38
},
39
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
40
+
gcTime: /*0//*/ 5 * 60 * 1000,
41
});
42
}
43
export function useQueryIdentity(didorhandle: string): UseQueryResult<
···
49
},
50
Error
51
>;
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
+
>;
63
export function useQueryIdentity(didorhandle?: string) {
64
+
const [slingshoturl] = useAtom(slingshotURLAtom);
65
return useQuery(constructIdentityQuery(didorhandle, slingshoturl));
66
}
67
···
69
return queryOptions({
70
queryKey: ["post", uri],
71
queryFn: async () => {
72
+
if (!uri) return undefined as undefined;
73
const res = await fetch(
74
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
75
);
···
80
return undefined;
81
}
82
if (res.status === 400) return undefined;
83
+
if (
84
+
data?.error === "InvalidRequest" &&
85
+
data.message?.includes("Could not find repo")
86
+
) {
87
return undefined; // cache โnot foundโ
88
}
89
try {
90
if (!res.ok) throw new Error("Failed to fetch post");
91
+
return data as {
92
uri: string;
93
cid: string;
94
value: any;
···
103
return failureCount < 2;
104
},
105
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
106
+
gcTime: /*0//*/ 5 * 60 * 1000,
107
});
108
}
109
export function useQueryPost(uri: string): UseQueryResult<
···
114
},
115
Error
116
>;
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
+
>;
127
export function useQueryPost(uri?: string) {
128
+
const [slingshoturl] = useAtom(slingshotURLAtom);
129
return useQuery(constructPostQuery(uri, slingshoturl));
130
}
131
···
133
return queryOptions({
134
queryKey: ["profile", uri],
135
queryFn: async () => {
136
+
if (!uri) return undefined as undefined;
137
const res = await fetch(
138
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
139
);
···
144
return undefined;
145
}
146
if (res.status === 400) return undefined;
147
+
if (
148
+
data?.error === "InvalidRequest" &&
149
+
data.message?.includes("Could not find repo")
150
+
) {
151
return undefined; // cache โnot foundโ
152
}
153
try {
154
if (!res.ok) throw new Error("Failed to fetch post");
155
+
return data as {
156
uri: string;
157
cid: string;
158
value: any;
···
167
return failureCount < 2;
168
},
169
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
170
+
gcTime: /*0//*/ 5 * 60 * 1000,
171
});
172
}
173
export function useQueryProfile(uri: string): UseQueryResult<
···
178
},
179
Error
180
>;
181
+
export function useQueryProfile(): UseQueryResult<undefined, Error>;
182
+
export function useQueryProfile(uri?: string): UseQueryResult<
183
+
| {
184
uri: string;
185
cid: string;
186
value: ATPAPI.AppBskyActorProfile.Record;
187
+
}
188
+
| undefined,
189
+
Error
190
+
>;
191
export function useQueryProfile(uri?: string) {
192
+
const [slingshoturl] = useAtom(slingshotURLAtom);
193
return useQuery(constructProfileQuery(uri, slingshoturl));
194
}
195
···
225
// method: "/links/all",
226
// target: string
227
// ): QueryOptions<linksAllResponse, Error>;
228
+
export function constructConstellationQuery(query?: {
229
+
constellation: string;
230
method:
231
| "/links"
232
| "/links/distinct-dids"
233
| "/links/count"
234
| "/links/count/distinct-dids"
235
| "/links/all"
236
+
| "undefined";
237
+
target: string;
238
+
collection?: string;
239
+
path?: string;
240
+
cursor?: string;
241
+
dids?: string[];
242
+
}) {
243
// : QueryOptions<
244
// | linksRecordsResponse
245
// | linksDidsResponse
···
249
// Error
250
// >
251
return queryOptions({
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,
261
queryFn: async () => {
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;
269
const res = await fetch(
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("") : ""}`
271
);
···
291
},
292
// enforce short lifespan
293
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
294
+
gcTime: /*0//*/ 5 * 60 * 1000,
295
});
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
+
318
export function useQueryConstellation(query: {
319
method: "/links";
320
target: string;
···
377
>
378
| undefined {
379
//if (!query) return;
380
+
const [constellationurl] = useAtom(constellationURLAtom);
381
return useQuery(
382
+
constructConstellationQuery(
383
+
query && { constellation: constellationurl, ...query }
384
+
)
385
);
386
}
387
···
425
}) {
426
return queryOptions({
427
// The query key includes all dependencies to ensure it refetches when they change
428
+
queryKey: [
429
+
"feedSkeleton",
430
+
options?.feedUri,
431
+
{ isAuthed: options?.isAuthed, did: options?.agent?.did },
432
+
],
433
queryFn: async () => {
434
+
if (!options) return undefined as undefined;
435
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
436
if (isAuthed) {
437
// Authenticated flow
438
if (!agent || !pdsUrl || !feedServiceDid) {
439
+
throw new Error(
440
+
"Missing required info for authenticated feed fetch."
441
+
);
442
}
443
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
444
const res = await agent.fetchHandler(url, {
···
448
"Content-Type": "application/json",
449
},
450
});
451
+
if (!res.ok)
452
+
throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
453
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
454
} else {
455
// Unauthenticated flow (using a public PDS/AppView)
456
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
457
const res = await fetch(url);
458
+
if (!res.ok)
459
+
throw new Error(`Public feed fetch failed: ${res.statusText}`);
460
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
461
}
462
},
···
474
return useQuery(constructFeedSkeletonQuery(options));
475
}
476
477
+
export function constructPreferencesQuery(
478
+
agent?: ATPAPI.Agent | undefined,
479
+
pdsUrl?: string | undefined
480
+
) {
481
return queryOptions({
482
+
queryKey: ["preferences", agent?.did],
483
queryFn: async () => {
484
if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available");
485
const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
···
490
});
491
}
492
export function useQueryPreferences(options: {
493
+
agent?: ATPAPI.Agent | undefined;
494
+
pdsUrl?: string | undefined;
495
}) {
496
return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl));
497
}
498
499
export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
500
return queryOptions({
501
queryKey: ["arbitrary", uri],
502
queryFn: async () => {
503
+
if (!uri) return undefined as undefined;
504
const res = await fetch(
505
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
506
);
···
511
return undefined;
512
}
513
if (res.status === 400) return undefined;
514
+
if (
515
+
data?.error === "InvalidRequest" &&
516
+
data.message?.includes("Could not find repo")
517
+
) {
518
return undefined; // cache โnot foundโ
519
}
520
try {
521
if (!res.ok) throw new Error("Failed to fetch post");
522
+
return data as {
523
uri: string;
524
cid: string;
525
value: any;
···
534
return failureCount < 2;
535
},
536
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
537
+
gcTime: /*0//*/ 5 * 60 * 1000,
538
});
539
}
540
export function useQueryArbitrary(uri: string): UseQueryResult<
···
545
},
546
Error
547
>;
548
+
export function useQueryArbitrary(): UseQueryResult<undefined, Error>;
549
export function useQueryArbitrary(uri?: string): UseQueryResult<
550
+
| {
551
+
uri: string;
552
+
cid: string;
553
+
value: any;
554
+
}
555
+
| undefined,
556
Error
557
>;
558
export function useQueryArbitrary(uri?: string) {
559
+
const [slingshoturl] = useAtom(slingshotURLAtom);
560
return useQuery(constructArbitraryQuery(uri, slingshoturl));
561
}
562
563
+
export function constructFallbackNothingQuery() {
564
return queryOptions({
565
queryKey: ["nothing"],
566
queryFn: async () => {
567
+
return undefined;
568
},
569
});
570
}
···
578
}[];
579
};
580
581
+
export function constructAuthorFeedQuery(
582
+
did: string,
583
+
pdsUrl: string,
584
+
collection: string = "app.bsky.feed.post"
585
+
) {
586
return queryOptions({
587
+
queryKey: ["authorFeed", did, collection],
588
queryFn: async ({ pageParam }: QueryFunctionContext) => {
589
const limit = 25;
590
+
591
const cursor = pageParam as string | undefined;
592
+
const cursorParam = cursor ? `&cursor=${cursor}` : "";
593
+
594
+
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`;
595
+
596
const res = await fetch(url);
597
if (!res.ok) throw new Error("Failed to fetch author's posts");
598
+
599
return res.json() as Promise<ListRecordsResponse>;
600
},
601
});
602
}
603
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
+
615
return useInfiniteQuery({
616
queryKey,
617
queryFn,
···
629
isAuthed: boolean;
630
pdsUrl?: string;
631
feedServiceDid?: string;
632
+
// todo the hell is a unauthedfeedurl
633
+
unauthedfeedurl?: string;
634
}) {
635
+
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } =
636
+
options;
637
+
638
return queryOptions({
639
queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
640
+
641
+
queryFn: async ({
642
+
pageParam,
643
+
}: QueryFunctionContext): Promise<FeedSkeletonPage> => {
644
const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
645
+
646
+
if (isAuthed && !unauthedfeedurl) {
647
if (!agent || !pdsUrl || !feedServiceDid) {
648
+
throw new Error(
649
+
"Missing required info for authenticated feed fetch."
650
+
);
651
}
652
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
653
const res = await agent.fetchHandler(url, {
···
657
"Content-Type": "application/json",
658
},
659
});
660
+
if (!res.ok)
661
+
throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
662
return (await res.json()) as FeedSkeletonPage;
663
} else {
664
+
const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
665
const res = await fetch(url);
666
+
if (!res.ok)
667
+
throw new Error(`Public feed fetch failed: ${res.statusText}`);
668
return (await res.json()) as FeedSkeletonPage;
669
}
670
},
···
677
isAuthed: boolean;
678
pdsUrl?: string;
679
feedServiceDid?: string;
680
+
unauthedfeedurl?: string;
681
}) {
682
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
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
}
703
704
export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
705
+
constellation: string;
706
+
method: "/links";
707
+
target?: string;
708
+
collection: string;
709
+
path: string;
710
+
staleMult?: number;
711
}) {
712
+
const safemult = query?.staleMult ?? 1;
713
// console.log(
714
// 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
715
// query,
···
718
return infiniteQueryOptions({
719
enabled: !!query?.target,
720
queryKey: [
721
+
"reddwarf_constellation",
722
query?.method,
723
query?.target,
724
query?.collection,
725
query?.path,
726
] as const,
727
728
+
queryFn: async ({ pageParam }: { pageParam?: string }) => {
729
+
if (!query || !query?.target) return undefined;
730
731
+
const method = query.method;
732
+
const target = query.target;
733
+
const collection = query.collection;
734
+
const path = query.path;
735
+
const cursor = pageParam;
736
737
const res = await fetch(
738
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
739
+
collection ? `&collection=${encodeURIComponent(collection)}` : ""
740
+
}${path ? `&path=${encodeURIComponent(path)}` : ""}${
741
+
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""
742
+
}`
743
+
);
744
745
+
if (!res.ok) throw new Error("Failed to fetch");
746
747
+
return (await res.json()) as linksRecordsResponse;
748
},
749
750
+
getNextPageParam: (lastPage) => {
751
+
return (lastPage as any)?.cursor ?? undefined;
752
},
753
initialPageParam: undefined,
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
const PROD_URL = "https://reddwarf.app"
14
const DEV_URL = "https://local3768forumtest.whey.party"
15
16
function shp(url: string): string {
17
return url.replace(/^https?:\/\//, '');
18
}
···
23
generateMetadataPlugin({
24
prod: PROD_URL,
25
dev: DEV_URL,
26
}),
27
TanStackRouterVite({ autoCodeSplitting: true }),
28
viteReact({
···
13
const PROD_URL = "https://reddwarf.app"
14
const DEV_URL = "https://local3768forumtest.whey.party"
15
16
+
const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party"
17
+
const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social"
18
+
19
function shp(url: string): string {
20
return url.replace(/^https?:\/\//, '');
21
}
···
26
generateMetadataPlugin({
27
prod: PROD_URL,
28
dev: DEV_URL,
29
+
prodResolver: PROD_HANDLE_RESOLVER_PDS,
30
+
devResolver: DEV_HANDLE_RESOLVER_PDS,
31
}),
32
TanStackRouterVite({ autoCodeSplitting: true }),
33
viteReact({