+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
+3
src/auto-imports.d.ts
+3
src/auto-imports.d.ts
···
20
const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default
21
const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default
22
const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default
23
const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default
24
const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default
25
}
···
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
}
+136
-27
src/components/UniversalPostRenderer.tsx
+136
-27
src/components/UniversalPostRenderer.tsx
···
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
imgCDNAtom,
14
} from "~/utils/atoms";
15
import { useHydratedEmbed } from "~/utils/useHydrated";
···
162
isQuote,
163
filterNoReplies,
164
filterMustHaveMedia,
165
-
filterMustBeReply
166
}: UniversalPostRendererATURILoaderProps) {
167
// todo remove this once tree rendering is implemented, use a prop like isTree
168
const TEMPLINEAR = true;
···
526
? true
527
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
528
? false
529
-
: (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine
530
}
531
topReplyLine={topReplyLine}
532
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
553
filterMustBeReply={filterMustBeReply}
554
/>
555
<>
556
-
{(maxReplies && maxReplies === 0 && replies && replies > 0) ? (
557
<>
558
-
{/* <div>hello</div> */}
559
-
<MoreReplies atUri={atUri} />
560
</>
561
-
) : (<></>)}
562
</>
563
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
564
<>
···
755
const hasImages = hasEmbed?.$type === "app.bsky.embed.images";
756
const hasVideo = hasEmbed?.$type === "app.bsky.embed.video";
757
const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia";
758
-
const isQuotewithImages = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === "app.bsky.embed.images";
759
-
const isQuotewithVideo = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === "app.bsky.embed.video";
760
761
-
const hasMedia = hasEmbed && (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo);
762
763
const {
764
data: hydratedEmbed,
···
854
// }, [fakepost, get, set]);
855
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
856
?.uri;
857
-
const feedviewpostreplydid = thereply&&!filterNoReplies ? new AtUri(thereply).host : undefined;
858
const replyhookvalue = useQueryIdentity(
859
feedviewpost ? feedviewpostreplydid : undefined
860
);
···
1237
1238
import defaultpfp from "~/../public/favicon.png";
1239
import { useAuth } from "~/providers/UnifiedAuthProvider";
1240
-
import { FeedItemRenderAturiLoader, FollowButton, Mutual } from "~/routes/profile.$did";
1241
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1242
import { useFastLike } from "~/utils/likeMutationQueue";
1243
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
···
1446
: undefined;
1447
1448
const emergencySalt = randomString();
1449
-
const fedi = (post.record as { bridgyOriginalText?: string })
1450
.bridgyOriginalText;
1451
1452
/* fuck you */
1453
const isMainItem = false;
···
1586
{post.author.displayName || post.author.handle}{" "}
1587
</div>
1588
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1589
-
<Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "}
1590
</div>
1591
</div>
1592
{uprrrsauthor?.description && (
···
1834
</div>
1835
</>
1836
)}
1837
-
<div style={{ paddingTop: post.embed && !concise && depth < 1 ? 4 : 0 }}>
1838
<>
1839
{expanded && (
1840
<div
···
1952
"/post/" +
1953
post.uri.split("/").pop()
1954
);
1955
} catch (_e) {
1956
// idk
1957
}
1958
}}
1959
style={{
···
1962
>
1963
<MdiShareVariant />
1964
</HitSlopButton>
1965
-
<span style={btnstyle}>
1966
-
<MdiMoreHoriz />
1967
-
</span>
1968
</div>
1969
</div>
1970
)}
···
2203
// <MaybeFeedCard view={embed.record} />
2204
// </div>
2205
// )
2206
-
} else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.feed.generator") {
2207
-
return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder/></div>
2208
}
2209
2210
// list embed
···
2216
// <MaybeListCard view={embed.record} />
2217
// </div>
2218
// )
2219
-
} else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.graph.list") {
2220
-
return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder listmode disablePropagation /></div>
2221
}
2222
2223
// starter pack embed
···
2229
// <StarterPackCard starterPack={embed.record} />
2230
// </div>
2231
// )
2232
-
} else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.graph.starterpack") {
2233
-
return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder listmode disablePropagation /></div>
2234
}
2235
2236
// quote post
···
2724
className="link"
2725
style={{
2726
textDecoration: "none",
2727
-
color: "rgb(29, 122, 242)",
2728
wordBreak: "break-all",
2729
}}
2730
target="_blank"
···
2744
result.push(
2745
<span
2746
key={start}
2747
-
style={{ color: "rgb(29, 122, 242)" }}
2748
className=" cursor-pointer"
2749
onClick={(e) => {
2750
e.stopPropagation();
···
2762
result.push(
2763
<span
2764
key={start}
2765
-
style={{ color: "rgb(29, 122, 242)" }}
2766
onClick={(e) => {
2767
e.stopPropagation();
2768
}}
···
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";
···
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}
···
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
<>
···
761
const hasImages = hasEmbed?.$type === "app.bsky.embed.images";
762
const hasVideo = hasEmbed?.$type === "app.bsky.embed.video";
763
const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia";
764
+
const isQuotewithImages =
765
+
isquotewithmedia &&
766
+
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
767
+
"app.bsky.embed.images";
768
+
const isQuotewithVideo =
769
+
isquotewithmedia &&
770
+
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
771
+
"app.bsky.embed.video";
772
773
+
const hasMedia =
774
+
hasEmbed &&
775
+
(hasImages || hasVideo || isQuotewithImages || isQuotewithVideo);
776
777
const {
778
data: hydratedEmbed,
···
868
// }, [fakepost, get, set]);
869
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
870
?.uri;
871
+
const feedviewpostreplydid =
872
+
thereply && !filterNoReplies ? new AtUri(thereply).host : undefined;
873
const replyhookvalue = useQueryIdentity(
874
feedviewpost ? feedviewpostreplydid : undefined
875
);
···
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";
···
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;
···
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
···
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
)}
···
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
···
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
}}
+6
src/providers/LikeMutationQueueProvider.tsx
+6
src/providers/LikeMutationQueueProvider.tsx
···
5
import React, { createContext, use, useCallback, useEffect, useRef } from "react";
6
7
import { useAuth } from "~/providers/UnifiedAuthProvider";
8
import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms";
9
import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery";
10
···
125
}
126
} catch (err) {
127
console.error("Like mutation failed, reverting:", err);
128
if (mutation.type === 'like') {
129
setFastState(mutation.target, null);
130
} else if (mutation.type === 'unlike') {
···
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
···
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') {
+21
src/routeTree.gen.ts
+21
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'
···
42
const NotificationsRoute = NotificationsRouteImport.update({
43
id: '/notifications',
44
path: '/notifications',
45
getParentRoute: () => rootRouteImport,
46
} as any)
47
const FeedsRoute = FeedsRouteImport.update({
···
133
export interface FileRoutesByFullPath {
134
'/': typeof IndexRoute
135
'/feeds': typeof FeedsRoute
136
'/notifications': typeof NotificationsRoute
137
'/search': typeof SearchRoute
138
'/settings': typeof SettingsRoute
···
152
export interface FileRoutesByTo {
153
'/': typeof IndexRoute
154
'/feeds': typeof FeedsRoute
155
'/notifications': typeof NotificationsRoute
156
'/search': typeof SearchRoute
157
'/settings': typeof SettingsRoute
···
173
'/': typeof IndexRoute
174
'/_pathlessLayout': typeof PathlessLayoutRouteWithChildren
175
'/feeds': typeof FeedsRoute
176
'/notifications': typeof NotificationsRoute
177
'/search': typeof SearchRoute
178
'/settings': typeof SettingsRoute
···
195
fullPaths:
196
| '/'
197
| '/feeds'
198
| '/notifications'
199
| '/search'
200
| '/settings'
···
214
to:
215
| '/'
216
| '/feeds'
217
| '/notifications'
218
| '/search'
219
| '/settings'
···
234
| '/'
235
| '/_pathlessLayout'
236
| '/feeds'
237
| '/notifications'
238
| '/search'
239
| '/settings'
···
256
IndexRoute: typeof IndexRoute
257
PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren
258
FeedsRoute: typeof FeedsRoute
259
NotificationsRoute: typeof NotificationsRoute
260
SearchRoute: typeof SearchRoute
261
SettingsRoute: typeof SettingsRoute
···
288
path: '/notifications'
289
fullPath: '/notifications'
290
preLoaderRoute: typeof NotificationsRouteImport
291
parentRoute: typeof rootRouteImport
292
}
293
'/feeds': {
···
456
IndexRoute: IndexRoute,
457
PathlessLayoutRoute: PathlessLayoutRouteWithChildren,
458
FeedsRoute: FeedsRoute,
459
NotificationsRoute: NotificationsRoute,
460
SearchRoute: SearchRoute,
461
SettingsRoute: SettingsRoute,
···
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'
···
43
const NotificationsRoute = NotificationsRouteImport.update({
44
id: '/notifications',
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({
···
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
···
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
···
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
···
204
fullPaths:
205
| '/'
206
| '/feeds'
207
+
| '/moderation'
208
| '/notifications'
209
| '/search'
210
| '/settings'
···
224
to:
225
| '/'
226
| '/feeds'
227
+
| '/moderation'
228
| '/notifications'
229
| '/search'
230
| '/settings'
···
245
| '/'
246
| '/_pathlessLayout'
247
| '/feeds'
248
+
| '/moderation'
249
| '/notifications'
250
| '/search'
251
| '/settings'
···
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
···
301
path: '/notifications'
302
fullPath: '/notifications'
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': {
···
476
IndexRoute: IndexRoute,
477
PathlessLayoutRoute: PathlessLayoutRouteWithChildren,
478
FeedsRoute: FeedsRoute,
479
+
ModerationRoute: ModerationRoute,
480
NotificationsRoute: NotificationsRoute,
481
SearchRoute: SearchRoute,
482
SettingsRoute: SettingsRoute,
+170
-11
src/routes/__root.tsx
+170
-11
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";
···
83
<LikeMutationQueueProvider>
84
<RootDocument>
85
<KeepAliveProvider>
86
<KeepAliveOutlet />
87
</KeepAliveProvider>
88
</RootDocument>
···
91
);
92
}
93
94
function RootDocument({ children }: { children: React.ReactNode }) {
95
useAtomCssVar(hueAtom, "--tw-gray-hue");
96
const location = useLocation();
···
106
const isSettings = location.pathname.startsWith("/settings");
107
const isSearch = location.pathname.startsWith("/search");
108
const isFeeds = location.pathname.startsWith("/feeds");
109
110
const locationEnum:
111
| "feeds"
···
113
| "settings"
114
| "notifications"
115
| "profile"
116
| "home" = isFeeds
117
? "feeds"
118
: isSearch
···
123
? "notifications"
124
: isProfile
125
? "profile"
126
-
: "home";
127
128
const [, setComposerPost] = useAtom(composerAtom);
129
···
134
<div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950">
135
<nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start">
136
<div className="flex items-center gap-3 mb-4">
137
-
<FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
138
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
139
Red Dwarf{" "}
140
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
198
text="Feeds"
199
/>
200
<MaterialNavItem
201
InactiveIcon={
202
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
203
}
···
235
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
236
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
237
//active={true}
238
-
onClickCallbback={() => setComposerPost({ kind: 'root' })}
239
text="Post"
240
/>
241
</div>
···
373
374
<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">
375
<div className="flex items-center gap-3 mb-4">
376
-
<FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
377
</div>
378
<MaterialNavItem
379
small
···
436
/>
437
<MaterialNavItem
438
small
439
InactiveIcon={
440
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
441
}
···
475
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
476
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
477
//active={true}
478
-
onClickCallbback={() => setComposerPost({ kind: 'root' })}
479
text="Post"
480
/>
481
</div>
···
485
<button
486
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"
487
style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }}
488
-
onClick={() => setComposerPost({ kind: 'root' })}
489
type="button"
490
aria-label="Create Post"
491
>
···
502
</main>
503
504
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
505
-
<div className="px-4 pt-4"><Import /></div>
506
<Login />
507
508
<div className="flex-1"></div>
509
<p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4">
510
-
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)
511
</p>
512
</aside>
513
</div>
···
654
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
655
}
656
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
657
-
active={locationEnum === "settings"}
658
onClickCallbback={() =>
659
navigate({
660
to: "/settings",
···
683
) : (
684
<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">
685
<div className="flex items-center gap-2">
686
-
<FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
687
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
688
Red Dwarf{" "}
689
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
703
);
704
}
705
706
-
function MaterialNavItem({
707
InactiveIcon,
708
ActiveIcon,
709
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";
···
85
<LikeMutationQueueProvider>
86
<RootDocument>
87
<KeepAliveProvider>
88
+
<AppToaster />
89
<KeepAliveOutlet />
90
</KeepAliveProvider>
91
</RootDocument>
···
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">
···
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={
331
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
332
}
···
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
}
+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
+
}
+2
-2
src/routes/notifications.tsx
+2
-2
src/routes/notifications.tsx
···
572
);
573
}
574
575
-
export function NotificationItem({ notification }: { notification: string }) {
576
const aturi = new AtUri(notification);
577
const bite = aturi.collection === "net.wafrn.feed.bite";
578
const navigate = useNavigate();
···
618
<img
619
src={avatar || defaultpfp}
620
alt={identity?.handle}
621
-
className="w-10 h-10 rounded-full"
622
/>
623
) : (
624
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
···
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();
···
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" />
+218
-48
src/routes/profile.$did/index.tsx
+218
-48
src/routes/profile.$did/index.tsx
···
32
useQueryIdentity,
33
useQueryProfile,
34
} from "~/utils/useQuery";
35
36
import { Chip } from "../notifications";
37
38
export const Route = createFileRoute("/profile/$did/")({
···
51
error: identityError,
52
} = useQueryIdentity(did);
53
54
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
55
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
56
const pdsUrl = identity?.pds;
···
81
82
const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord;
83
84
-
const resultwhateversure = useQueryConstellationLinksCountDistinctDids(resolvedDid ? {
85
-
method: "/links/count/distinct-dids",
86
-
collection: "app.bsky.graph.follow",
87
-
target: resolvedDid,
88
-
path: ".subject"
89
-
} : undefined)
90
91
const followercount = resultwhateversure?.data?.total;
92
···
136
137
{/* Avatar (PFP) */}
138
<div className="absolute left-[16px] top-[100px] ">
139
-
<img
140
-
src={getAvatarUrl(profile) || "/favicon.png"}
141
-
alt="avatar"
142
-
className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700"
143
-
/>
144
</div>
145
146
<div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5">
···
152
also save it persistently
153
*/}
154
<FollowButton targetdidorhandle={did} />
155
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
156
... {/* todo: icon */}
157
</button>
158
</div>
···
165
{handle}
166
</div>
167
<div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2">
168
-
<Link to="/profile/$did/followers" params={{did: did}}>{followercount && (<span className="mr-1 text-gray-900 dark:text-gray-200 font-medium">{followercount}</span>)}Followers</Link>
169
-
170
-
<Link to="/profile/$did/follows" params={{did: did}}>Follows</Link>
171
</div>
172
{description && (
173
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
···
183
<ReusableTabRoute
184
route={`Profile` + did}
185
tabs={{
186
-
Posts: <PostsTab did={did} />,
187
-
Reposts: <RepostsTab did={did} />,
188
-
Feeds: <FeedsTab did={did} />,
189
-
Lists: <ListsTab did={did} />,
190
...(identity?.did === agent?.did
191
? { Likes: <SelfLikesTab did={did} /> }
192
: {}),
···
212
}
213
214
export type ProfilePostsFilter = {
215
-
posts: boolean,
216
-
replies: boolean,
217
-
mediaOnly: boolean,
218
-
}
219
export const defaultProfilePostsFilter: ProfilePostsFilter = {
220
posts: true,
221
replies: true,
222
mediaOnly: false,
223
-
}
224
225
-
function ProfilePostsFilterChipBar({filters, toggle}:{filters: ProfilePostsFilter | null, toggle: (key: keyof ProfilePostsFilter) => void}) {
226
-
const empty = (!filters?.replies && !filters?.posts);
227
-
const almostEmpty = (!filters?.replies && filters?.posts);
228
229
useEffect(() => {
230
if (empty) {
231
-
toggle("posts")
232
}
233
}, [empty, toggle]);
234
···
237
<Chip
238
state={filters?.posts ?? true}
239
text="Posts"
240
-
onClick={() => almostEmpty ? null : toggle("posts")}
241
/>
242
<Chip
243
state={filters?.replies ?? true}
···
258
const [filterses, setFilterses] = useAtom(profileChipsAtom);
259
const filters = filterses?.[did];
260
const setFilters = (obj: ProfilePostsFilter) => {
261
-
setFilterses((prev)=>{
262
-
return{
263
...prev,
264
-
[did]: obj
265
-
}
266
-
})
267
-
}
268
-
useEffect(()=>{
269
if (!filters) {
270
setFilters(defaultProfilePostsFilter);
271
}
272
-
})
273
useReusableTabScrollRestore(`Profile` + did);
274
const queryClient = useQueryClient();
275
const {
···
306
);
307
308
const toggle = (key: keyof ProfilePostsFilter) => {
309
-
setFilterses(prev => {
310
-
const existing = prev[did] ?? { posts: false, replies: false, mediaOnly: false }; // default
311
312
return {
313
...prev,
···
500
);
501
}
502
503
export function FeedItemRenderAturiLoader({
504
aturi,
505
listmode,
···
805
)}
806
</>
807
) : (
808
-
<button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]">
809
Edit Profile
810
</button>
811
)}
···
822
const { data: identity } = useQueryIdentity(targetdidorhandle);
823
const [show] = useAtom(enableBitesAtom);
824
825
-
if (!show) return
826
827
return (
828
<>
829
<button
830
-
onClick={(e) => {
831
e.stopPropagation();
832
-
sendBite({
833
agent: agent || undefined,
834
targetDid: identity?.did,
835
});
···
842
);
843
}
844
845
-
function sendBite({
846
agent,
847
targetDid,
848
}: {
849
agent?: Agent;
850
targetDid?: string;
851
}) {
852
-
if (!agent?.did || !targetDid) return;
853
const newRecord = {
854
repo: agent.did,
855
collection: "net.wafrn.feed.bite",
856
rkey: TID.next().toString(),
857
record: {
858
$type: "net.wafrn.feed.bite",
859
-
subject: "at://"+targetDid,
860
createdAt: new Date().toISOString(),
861
},
862
};
863
864
-
agent.com.atproto.repo.createRecord(newRecord).catch((err) => {
865
console.error("Bite failed:", err);
866
-
});
867
}
868
-
869
870
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
871
const { agent } = useAuth();
···
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/")({
···
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;
···
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
···
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">
···
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>
···
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]">
···
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
: {}),
···
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
···
296
<Chip
297
state={filters?.posts ?? true}
298
text="Posts"
299
+
onClick={() => (almostEmpty ? null : toggle("posts"))}
300
/>
301
<Chip
302
state={filters?.replies ?? true}
···
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 {
···
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,
···
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,
···
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
)}
···
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
});
···
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 }) {
1041
const { agent } = useAuth();
+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
+
}
+148
-30
src/routes/settings.tsx
+148
-30
src/routes/settings.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
2
import { useAtom, useAtomValue, useSetAtom } from "jotai";
3
import { Slider, Switch } from "radix-ui";
4
-
import { useEffect,useState } from "react";
5
6
import { Header } from "~/components/Header";
7
import Login from "~/components/Login";
···
10
defaultconstellationURL,
11
defaulthue,
12
defaultImgCDN,
13
defaultslingshotURL,
14
defaultVideoCDN,
15
enableBitesAtom,
16
hueAtom,
17
imgCDNAtom,
18
slingshotURLAtom,
19
videoCDNAtom,
20
} from "~/utils/atoms";
21
22
export const Route = createFileRoute("/settings")({
23
component: Settings,
24
});
25
26
export function Settings() {
27
return (
28
<>
29
<Header
···
39
<div className="lg:hidden">
40
<Login />
41
</div>
42
<div className="h-4" />
43
<TextInputSetting
44
atom={constellationURLAtom}
45
title={"Constellation"}
···
68
description={"Customize the Slingshot instance to be used by Red Dwarf"}
69
init={defaultVideoCDN}
70
/>
71
72
-
<Hue />
73
<SwitchSetting
74
atom={enableBitesAtom}
75
title={"Bites"}
76
-
description={"Enable Wafrn Bites"}
77
//init={false}
78
-
/>
79
-
<p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm">
80
-
please restart/refresh the app if changes arent applying correctly
81
</p>
82
</>
83
);
84
}
85
86
export function SwitchSetting({
87
atom,
88
title,
···
105
}
106
107
return (
108
-
<div className="flex items-center gap-4 px-4 py-2">
109
-
<div className="flex flex-col">
110
-
<label htmlFor="switch-demo" className="text-lg">
111
-
{title}
112
-
</label>
113
-
<span className="text-sm">{description}</span>
114
-
</div>
115
116
<Switch.Root
117
-
id="switch-demo"
118
checked={value}
119
onCheckedChange={(v) => setValue(v)}
120
-
className="w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-blue-500 transition-colors"
121
>
122
-
<Switch.Thumb
123
-
className="block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]"
124
-
/>
125
</Switch.Root>
126
</div>
127
);
···
130
function Hue() {
131
const [hue, setHue] = useAtom(hueAtom);
132
return (
133
-
<div className="flex flex-col px-4 mt-4 ">
134
-
<span className="z-10">Hue</span>
135
-
<div className="flex flex-row items-center gap-4">
136
-
<SliderComponent
137
-
atom={hueAtom}
138
-
max={360}
139
-
/>
140
<button
141
onClick={() => setHue(defaulthue ?? 28)}
142
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
···
207
);
208
}
209
210
-
211
interface SliderProps {
212
atom: typeof hueAtom;
213
min?: number;
···
221
max = 100,
222
step = 1,
223
}) => {
224
-
225
-
const [value, setValue] = useAtom(atom)
226
227
return (
228
<Slider.Root
···
239
<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" />
240
</Slider.Root>
241
);
242
-
};
···
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,
···
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
);
···
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
}
+16
src/utils/atoms.ts
+16
src/utils/atoms.ts
···
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;
102
export const hueAtom = atomWithStorage<number>("hue", defaulthue);
103
···
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
+
);
+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
+384
-157
src/utils/useQuery.ts
+384
-157
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
// todo do more of these instead of overloads since overloads sucks so much apparently
···
293
cursor?: string;
294
}): UseQueryResult<linksCountResponse, Error> | undefined {
295
//if (!query) return;
296
-
const [constellationurl] = useAtom(constellationURLAtom)
297
const queryres = useQuery(
298
-
constructConstellationQuery(query && {constellation: constellationurl, ...query})
299
) as unknown as UseQueryResult<linksCountResponse, Error>;
300
if (!query) {
301
-
return undefined as undefined;
302
}
303
return queryres as UseQueryResult<linksCountResponse, Error>;
304
}
···
365
>
366
| undefined {
367
//if (!query) return;
368
-
const [constellationurl] = useAtom(constellationURLAtom)
369
return useQuery(
370
-
constructConstellationQuery(query && {constellation: constellationurl, ...query})
371
);
372
}
373
···
411
}) {
412
return queryOptions({
413
// The query key includes all dependencies to ensure it refetches when they change
414
-
queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }],
415
queryFn: async () => {
416
-
if (!options) return undefined as undefined
417
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
418
if (isAuthed) {
419
// Authenticated flow
420
if (!agent || !pdsUrl || !feedServiceDid) {
421
-
throw new Error("Missing required info for authenticated feed fetch.");
422
}
423
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
424
const res = await agent.fetchHandler(url, {
···
428
"Content-Type": "application/json",
429
},
430
});
431
-
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
432
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
433
} else {
434
// Unauthenticated flow (using a public PDS/AppView)
435
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
436
const res = await fetch(url);
437
-
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
438
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
439
}
440
},
···
452
return useQuery(constructFeedSkeletonQuery(options));
453
}
454
455
-
export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) {
456
return queryOptions({
457
-
queryKey: ['preferences', agent?.did],
458
queryFn: async () => {
459
if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available");
460
const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
···
465
});
466
}
467
export function useQueryPreferences(options: {
468
-
agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined
469
}) {
470
return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl));
471
}
472
473
-
474
-
475
export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
476
return queryOptions({
477
queryKey: ["arbitrary", uri],
478
queryFn: async () => {
479
-
if (!uri) return undefined as undefined
480
const res = await fetch(
481
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
482
);
···
487
return undefined;
488
}
489
if (res.status === 400) return undefined;
490
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
491
return undefined; // cache โnot foundโ
492
}
493
try {
494
if (!res.ok) throw new Error("Failed to fetch post");
495
-
return (data) as {
496
uri: string;
497
cid: string;
498
value: any;
···
507
return failureCount < 2;
508
},
509
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
510
-
gcTime: /*0//*/5 * 60 * 1000,
511
});
512
}
513
export function useQueryArbitrary(uri: string): UseQueryResult<
···
518
},
519
Error
520
>;
521
-
export function useQueryArbitrary(): UseQueryResult<
522
-
undefined,
523
-
Error
524
-
>;
525
export function useQueryArbitrary(uri?: string): UseQueryResult<
526
-
{
527
-
uri: string;
528
-
cid: string;
529
-
value: any;
530
-
} | undefined,
531
Error
532
>;
533
export function useQueryArbitrary(uri?: string) {
534
-
const [slingshoturl] = useAtom(slingshotURLAtom)
535
return useQuery(constructArbitraryQuery(uri, slingshoturl));
536
}
537
538
-
export function constructFallbackNothingQuery(){
539
return queryOptions({
540
queryKey: ["nothing"],
541
queryFn: async () => {
542
-
return undefined
543
},
544
});
545
}
···
553
}[];
554
};
555
556
-
export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") {
557
return queryOptions({
558
-
queryKey: ['authorFeed', did, collection],
559
queryFn: async ({ pageParam }: QueryFunctionContext) => {
560
const limit = 25;
561
-
562
const cursor = pageParam as string | undefined;
563
-
const cursorParam = cursor ? `&cursor=${cursor}` : '';
564
-
565
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`;
566
-
567
const res = await fetch(url);
568
if (!res.ok) throw new Error("Failed to fetch author's posts");
569
-
570
return res.json() as Promise<ListRecordsResponse>;
571
},
572
});
573
}
574
575
-
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) {
576
-
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection);
577
-
578
return useInfiniteQuery({
579
queryKey,
580
queryFn,
···
595
// todo the hell is a unauthedfeedurl
596
unauthedfeedurl?: string;
597
}) {
598
-
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = options;
599
-
600
return queryOptions({
601
queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
602
-
603
-
queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
604
const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
605
-
606
if (isAuthed && !unauthedfeedurl) {
607
if (!agent || !pdsUrl || !feedServiceDid) {
608
-
throw new Error("Missing required info for authenticated feed fetch.");
609
}
610
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
611
const res = await agent.fetchHandler(url, {
···
615
"Content-Type": "application/json",
616
},
617
});
618
-
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
619
return (await res.json()) as FeedSkeletonPage;
620
} else {
621
const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
622
const res = await fetch(url);
623
-
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
624
return (await res.json()) as FeedSkeletonPage;
625
}
626
},
···
636
unauthedfeedurl?: string;
637
}) {
638
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
639
-
640
-
return {...useInfiniteQuery({
641
-
queryKey,
642
-
queryFn,
643
-
initialPageParam: undefined as never,
644
-
getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
645
-
staleTime: Infinity,
646
-
refetchOnWindowFocus: false,
647
-
enabled: !!options.feedUri && (options.isAuthed ? (!!options.agent && !!options.pdsUrl || !!options.unauthedfeedurl) && !!options.feedServiceDid : true),
648
-
}), queryKey: queryKey};
649
-
}
650
651
652
export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
653
-
constellation: string,
654
-
method: '/links'
655
-
target?: string
656
-
collection: string
657
-
path: string,
658
-
staleMult?: number
659
}) {
660
const safemult = query?.staleMult ?? 1;
661
// console.log(
···
666
return infiniteQueryOptions({
667
enabled: !!query?.target,
668
queryKey: [
669
-
'reddwarf_constellation',
670
query?.method,
671
query?.target,
672
query?.collection,
673
query?.path,
674
] as const,
675
676
-
queryFn: async ({pageParam}: {pageParam?: string}) => {
677
-
if (!query || !query?.target) return undefined
678
679
-
const method = query.method
680
-
const target = query.target
681
-
const collection = query.collection
682
-
const path = query.path
683
-
const cursor = pageParam
684
685
const res = await fetch(
686
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
687
-
collection ? `&collection=${encodeURIComponent(collection)}` : ''
688
-
}${path ? `&path=${encodeURIComponent(path)}` : ''}${
689
-
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
690
-
}`,
691
-
)
692
693
-
if (!res.ok) throw new Error('Failed to fetch')
694
695
-
return (await res.json()) as linksRecordsResponse
696
},
697
698
-
getNextPageParam: lastPage => {
699
-
return (lastPage as any)?.cursor ?? undefined
700
},
701
initialPageParam: undefined,
702
staleTime: 5 * 60 * 1000 * safemult,
703
gcTime: 5 * 60 * 1000 * safemult,
704
-
})
705
-
}
···
5
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
···
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
}
···
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,
···
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
},
···
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(
···
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({