+2
-1
.gitignore
+2
-1
.gitignore
+9
README.md
+9
README.md
···
15
15
16
16
run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder)
17
17
18
+
19
+
20
+
you probably dont need to change these
21
+
```ts
22
+
const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party"
23
+
const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social"
24
+
```
25
+
if you do want to change these, i recommend changing both of these to your own PDS url. i separate the prod and dev urls so that you can change it as needed. here i separated it because if the prod resolver and prod url shares the same domain itll error and prevent logins
26
+
18
27
## useQuery
19
28
Red Dwarf has been upgraded from its original bespoke caching system to Tanstack Query (react query). this migration was done to achieve a more robust and maintainable approach to data fetching and caching and state synchronization. ive seen serious performance gains from this switch!
20
29
+62
-30
oauthdev.mts
+62
-30
oauthdev.mts
···
1
-
import fs from 'fs';
2
-
import path from 'path';
1
+
import fs from "fs";
2
+
import path from "path";
3
3
//import { generateClientMetadata } from './src/helpers/oauthClient'
4
4
export const generateClientMetadata = (appOrigin: string) => {
5
-
const callbackPath = '/callback';
5
+
const callbackPath = "/callback";
6
6
7
7
return {
8
-
"client_id": `${appOrigin}/client-metadata.json`,
9
-
"client_name": "ForumTest",
10
-
"client_uri": appOrigin,
11
-
"logo_uri": `${appOrigin}/logo192.png`,
12
-
"tos_uri": `${appOrigin}/terms-of-service`,
13
-
"policy_uri": `${appOrigin}/privacy-policy`,
14
-
"redirect_uris": [`${appOrigin}${callbackPath}`] as [string, ...string[]],
15
-
"scope": "atproto transition:generic",
16
-
"grant_types": ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"],
17
-
"response_types": ["code"] as ["code"],
18
-
"token_endpoint_auth_method": "none" as "none",
19
-
"application_type": "web" as "web",
20
-
"dpop_bound_access_tokens": true
21
-
};
22
-
}
23
-
8
+
client_id: `${appOrigin}/client-metadata.json`,
9
+
client_name: "ForumTest",
10
+
client_uri: appOrigin,
11
+
logo_uri: `${appOrigin}/logo192.png`,
12
+
tos_uri: `${appOrigin}/terms-of-service`,
13
+
policy_uri: `${appOrigin}/privacy-policy`,
14
+
redirect_uris: [`${appOrigin}${callbackPath}`] as [string, ...string[]],
15
+
scope: "atproto transition:generic",
16
+
grant_types: ["authorization_code", "refresh_token"] as [
17
+
"authorization_code",
18
+
"refresh_token",
19
+
],
20
+
response_types: ["code"] as ["code"],
21
+
token_endpoint_auth_method: "none" as "none",
22
+
application_type: "web" as "web",
23
+
dpop_bound_access_tokens: true,
24
+
};
25
+
};
24
26
25
-
export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) {
27
+
export function generateMetadataPlugin({
28
+
prod,
29
+
dev,
30
+
prodResolver = "https://bsky.social",
31
+
devResolver = prodResolver,
32
+
}: {
33
+
prod: string;
34
+
dev: string;
35
+
prodResolver?: string;
36
+
devResolver?: string;
37
+
}) {
26
38
return {
27
-
name: 'vite-plugin-generate-metadata',
39
+
name: "vite-plugin-generate-metadata",
28
40
config(_config: any, { mode }: any) {
29
-
let appOrigin;
30
-
if (mode === 'production') {
31
-
appOrigin = prod
32
-
if (!appOrigin || !appOrigin.startsWith('https://')) {
33
-
throw new Error('VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build.');
41
+
console.log('๐ก vite mode =', mode)
42
+
let appOrigin, resolver;
43
+
if (mode === "production") {
44
+
appOrigin = prod;
45
+
resolver = prodResolver;
46
+
if (!appOrigin || !appOrigin.startsWith("https://")) {
47
+
throw new Error(
48
+
"VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build."
49
+
);
34
50
}
35
51
} else {
36
52
appOrigin = dev;
53
+
resolver = devResolver;
37
54
}
38
-
39
-
55
+
40
56
const metadata = generateClientMetadata(appOrigin);
41
-
const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json');
57
+
const outputPath = path.resolve(
58
+
process.cwd(),
59
+
"public",
60
+
"client-metadata.json"
61
+
);
42
62
43
63
fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2));
44
64
65
+
const resolvers = {
66
+
resolver: resolver,
67
+
};
68
+
const resolverOutPath = path.resolve(
69
+
process.cwd(),
70
+
"public",
71
+
"resolvers.json"
72
+
);
73
+
74
+
fs.writeFileSync(resolverOutPath, JSON.stringify(resolvers, null, 2));
75
+
76
+
45
77
// /*mass comment*/ console.log(`โ
Generated client-metadata.json for ${appOrigin}`);
46
78
},
47
79
};
48
-
}
80
+
}
+10
package-lock.json
+10
package-lock.json
···
29
29
"react": "^19.0.0",
30
30
"react-dom": "^19.0.0",
31
31
"react-player": "^3.3.2",
32
+
"sonner": "^2.0.7",
32
33
"tailwindcss": "^4.0.6",
33
34
"tanstack-router-keepalive": "^1.0.0"
34
35
},
···
12543
12544
"csstype": "^3.1.0",
12544
12545
"seroval": "~1.3.0",
12545
12546
"seroval-plugins": "~1.3.0"
12547
+
}
12548
+
},
12549
+
"node_modules/sonner": {
12550
+
"version": "2.0.7",
12551
+
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
12552
+
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
12553
+
"peerDependencies": {
12554
+
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
12555
+
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
12546
12556
}
12547
12557
},
12548
12558
"node_modules/source-map": {
+1
package.json
+1
package.json
+3
src/auto-imports.d.ts
+3
src/auto-imports.d.ts
···
20
20
const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default
21
21
const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default
22
22
const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default
23
+
const IconMdiClose: typeof import('~icons/mdi/close.jsx').default
23
24
const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default
24
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
25
28
}
+37
-3
src/components/Import.tsx
+37
-3
src/components/Import.tsx
···
1
1
import { AtUri } from "@atproto/api";
2
2
import { useNavigate, type UseNavigateResult } from "@tanstack/react-router";
3
+
import { useAtom } from "jotai";
3
4
import { useState } from "react";
5
+
6
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
+
import { lycanURLAtom } from "~/utils/atoms";
8
+
import { useQueryLycanStatus } from "~/utils/useQuery";
4
9
5
10
/**
6
11
* Basically the best equivalent to Search that i can do
7
12
*/
8
-
export function Import() {
9
-
const [textInput, setTextInput] = useState<string | undefined>();
13
+
export function Import({
14
+
optionaltextstring,
15
+
}: {
16
+
optionaltextstring?: string;
17
+
}) {
18
+
const [textInput, setTextInput] = useState<string | undefined>(
19
+
optionaltextstring
20
+
);
10
21
const navigate = useNavigate();
11
22
23
+
const { status } = useAuth();
24
+
const [lycandomain] = useAtom(lycanURLAtom);
25
+
const lycanExists = lycandomain !== "";
26
+
const { data: lycanstatusdata } = useQueryLycanStatus();
27
+
const lycanIndexed = lycanstatusdata?.status === "finished" || false;
28
+
const lycanIndexing = lycanstatusdata?.status === "in_progress" || false;
29
+
const lycanIndexingProgress = lycanIndexing
30
+
? lycanstatusdata?.progress
31
+
: undefined;
32
+
const authed = status === "signedIn";
33
+
34
+
const lycanReady = lycanExists && lycanIndexed && authed;
35
+
12
36
const handleEnter = () => {
13
37
if (!textInput) return;
14
38
handleImport({
15
39
text: textInput,
16
40
navigate,
41
+
lycanReady:
42
+
lycanReady || (!!lycanIndexingProgress && lycanIndexingProgress > 0),
17
43
});
18
44
};
45
+
46
+
const placeholder = lycanReady ? "Search..." : "Import...";
19
47
20
48
return (
21
49
<div className="w-full relative">
···
23
51
24
52
<input
25
53
type="text"
26
-
placeholder="Import..."
54
+
placeholder={placeholder}
27
55
value={textInput}
28
56
onChange={(e) => setTextInput(e.target.value)}
29
57
onKeyDown={(e) => {
···
38
66
function handleImport({
39
67
text,
40
68
navigate,
69
+
lycanReady,
41
70
}: {
42
71
text: string;
43
72
navigate: UseNavigateResult<string>;
73
+
lycanReady?: boolean;
44
74
}) {
45
75
const trimmed = text.trim();
46
76
// parse text
···
147
177
// } catch {
148
178
// // continue
149
179
// }
180
+
181
+
if (lycanReady) {
182
+
navigate({ to: "/search", search: { q: text } });
183
+
}
150
184
}
+136
-27
src/components/UniversalPostRenderer.tsx
+136
-27
src/components/UniversalPostRenderer.tsx
···
1
-
import * as ATPAPI from "@atproto/api"
1
+
import * as ATPAPI from "@atproto/api";
2
2
import { useNavigate } from "@tanstack/react-router";
3
3
import DOMPurify from "dompurify";
4
4
import { useAtom } from "jotai";
···
10
10
import {
11
11
composerAtom,
12
12
constellationURLAtom,
13
+
enableBridgyTextAtom,
14
+
enableWafrnTextAtom,
13
15
imgCDNAtom,
14
16
} from "~/utils/atoms";
15
17
import { useHydratedEmbed } from "~/utils/useHydrated";
···
162
164
isQuote,
163
165
filterNoReplies,
164
166
filterMustHaveMedia,
165
-
filterMustBeReply
167
+
filterMustBeReply,
166
168
}: UniversalPostRendererATURILoaderProps) {
167
169
// todo remove this once tree rendering is implemented, use a prop like isTree
168
170
const TEMPLINEAR = true;
···
526
528
? true
527
529
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
528
530
? false
529
-
: (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine
531
+
: maxReplies === 0 && (!replies || (!!replies && replies === 0))
532
+
? false
533
+
: bottomReplyLine
530
534
}
531
535
topReplyLine={topReplyLine}
532
536
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
553
557
filterMustBeReply={filterMustBeReply}
554
558
/>
555
559
<>
556
-
{(maxReplies && maxReplies === 0 && replies && replies > 0) ? (
560
+
{maxReplies && maxReplies === 0 && replies && replies > 0 ? (
557
561
<>
558
-
{/* <div>hello</div> */}
559
-
<MoreReplies atUri={atUri} />
562
+
{/* <div>hello</div> */}
563
+
<MoreReplies atUri={atUri} />
560
564
</>
561
-
) : (<></>)}
565
+
) : (
566
+
<></>
567
+
)}
562
568
</>
563
569
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
564
570
<>
···
755
761
const hasImages = hasEmbed?.$type === "app.bsky.embed.images";
756
762
const hasVideo = hasEmbed?.$type === "app.bsky.embed.video";
757
763
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";
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";
760
772
761
-
const hasMedia = hasEmbed && (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo);
773
+
const hasMedia =
774
+
hasEmbed &&
775
+
(hasImages || hasVideo || isQuotewithImages || isQuotewithVideo);
762
776
763
777
const {
764
778
data: hydratedEmbed,
···
854
868
// }, [fakepost, get, set]);
855
869
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
856
870
?.uri;
857
-
const feedviewpostreplydid = thereply&&!filterNoReplies ? new AtUri(thereply).host : undefined;
871
+
const feedviewpostreplydid =
872
+
thereply && !filterNoReplies ? new AtUri(thereply).host : undefined;
858
873
const replyhookvalue = useQueryIdentity(
859
874
feedviewpost ? feedviewpostreplydid : undefined
860
875
);
···
1237
1252
1238
1253
import defaultpfp from "~/../public/favicon.png";
1239
1254
import { useAuth } from "~/providers/UnifiedAuthProvider";
1240
-
import { FeedItemRenderAturiLoader, FollowButton, Mutual } from "~/routes/profile.$did";
1255
+
import { renderSnack } from "~/routes/__root";
1256
+
import {
1257
+
FeedItemRenderAturiLoader,
1258
+
FollowButton,
1259
+
Mutual,
1260
+
} from "~/routes/profile.$did";
1241
1261
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1242
1262
import { useFastLike } from "~/utils/likeMutationQueue";
1243
1263
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
···
1446
1466
: undefined;
1447
1467
1448
1468
const emergencySalt = randomString();
1449
-
const fedi = (post.record as { bridgyOriginalText?: string })
1469
+
1470
+
const [showBridgyText] = useAtom(enableBridgyTextAtom);
1471
+
const [showWafrnText] = useAtom(enableWafrnTextAtom);
1472
+
1473
+
const unfedibridgy = (post.record as { bridgyOriginalText?: string })
1450
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);
1451
1507
1452
1508
/* fuck you */
1453
1509
const isMainItem = false;
···
1586
1642
{post.author.displayName || post.author.handle}{" "}
1587
1643
</div>
1588
1644
<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}{" "}
1645
+
<Mutual targetdidorhandle={post.author.did} />@
1646
+
{post.author.handle}{" "}
1590
1647
</div>
1591
1648
</div>
1592
1649
{uprrrsauthor?.description && (
···
1834
1891
</div>
1835
1892
</>
1836
1893
)}
1837
-
<div style={{ paddingTop: post.embed && !concise && depth < 1 ? 4 : 0 }}>
1894
+
<div
1895
+
style={{
1896
+
paddingTop: post.embed && !concise && depth < 1 ? 4 : 0,
1897
+
}}
1898
+
>
1838
1899
<>
1839
1900
{expanded && (
1840
1901
<div
···
1952
2013
"/post/" +
1953
2014
post.uri.split("/").pop()
1954
2015
);
2016
+
renderSnack({
2017
+
title: "Copied to clipboard!",
2018
+
});
1955
2019
} catch (_e) {
1956
2020
// idk
2021
+
renderSnack({
2022
+
title: "Failed to copy link",
2023
+
});
1957
2024
}
1958
2025
}}
1959
2026
style={{
···
1962
2029
>
1963
2030
<MdiShareVariant />
1964
2031
</HitSlopButton>
1965
-
<span style={btnstyle}>
1966
-
<MdiMoreHoriz />
1967
-
</span>
2032
+
<HitSlopButton
2033
+
onClick={() => {
2034
+
renderSnack({
2035
+
title: "Not implemented yet...",
2036
+
});
2037
+
}}
2038
+
>
2039
+
<span style={btnstyle}>
2040
+
<MdiMoreHoriz />
2041
+
</span>
2042
+
</HitSlopButton>
1968
2043
</div>
1969
2044
</div>
1970
2045
)}
···
2203
2278
// <MaybeFeedCard view={embed.record} />
2204
2279
// </div>
2205
2280
// )
2206
-
} else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.feed.generator") {
2207
-
return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder/></div>
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
+
);
2208
2291
}
2209
2292
2210
2293
// list embed
···
2216
2299
// <MaybeListCard view={embed.record} />
2217
2300
// </div>
2218
2301
// )
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>
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
+
);
2221
2317
}
2222
2318
2223
2319
// starter pack embed
···
2229
2325
// <StarterPackCard starterPack={embed.record} />
2230
2326
// </div>
2231
2327
// )
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>
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
+
);
2234
2343
}
2235
2344
2236
2345
// quote post
···
2724
2833
className="link"
2725
2834
style={{
2726
2835
textDecoration: "none",
2727
-
color: "rgb(29, 122, 242)",
2836
+
color: "var(--link-text-color)",
2728
2837
wordBreak: "break-all",
2729
2838
}}
2730
2839
target="_blank"
···
2744
2853
result.push(
2745
2854
<span
2746
2855
key={start}
2747
-
style={{ color: "rgb(29, 122, 242)" }}
2856
+
style={{ color: "var(--link-text-color)" }}
2748
2857
className=" cursor-pointer"
2749
2858
onClick={(e) => {
2750
2859
e.stopPropagation();
···
2762
2871
result.push(
2763
2872
<span
2764
2873
key={start}
2765
-
style={{ color: "rgb(29, 122, 242)" }}
2874
+
style={{ color: "var(--link-text-color)" }}
2766
2875
onClick={(e) => {
2767
2876
e.stopPropagation();
2768
2877
}}
+6
src/providers/LikeMutationQueueProvider.tsx
+6
src/providers/LikeMutationQueueProvider.tsx
···
5
5
import React, { createContext, use, useCallback, useEffect, useRef } from "react";
6
6
7
7
import { useAuth } from "~/providers/UnifiedAuthProvider";
8
+
import { renderSnack } from "~/routes/__root";
8
9
import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms";
9
10
import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery";
10
11
···
125
126
}
126
127
} catch (err) {
127
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
+
})
128
134
if (mutation.type === 'like') {
129
135
setFastState(mutation.target, null);
130
136
} else if (mutation.type === 'unlike') {
+21
src/routeTree.gen.ts
+21
src/routeTree.gen.ts
···
12
12
import { Route as SettingsRouteImport } from './routes/settings'
13
13
import { Route as SearchRouteImport } from './routes/search'
14
14
import { Route as NotificationsRouteImport } from './routes/notifications'
15
+
import { Route as ModerationRouteImport } from './routes/moderation'
15
16
import { Route as FeedsRouteImport } from './routes/feeds'
16
17
import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout'
17
18
import { Route as IndexRouteImport } from './routes/index'
···
42
43
const NotificationsRoute = NotificationsRouteImport.update({
43
44
id: '/notifications',
44
45
path: '/notifications',
46
+
getParentRoute: () => rootRouteImport,
47
+
} as any)
48
+
const ModerationRoute = ModerationRouteImport.update({
49
+
id: '/moderation',
50
+
path: '/moderation',
45
51
getParentRoute: () => rootRouteImport,
46
52
} as any)
47
53
const FeedsRoute = FeedsRouteImport.update({
···
133
139
export interface FileRoutesByFullPath {
134
140
'/': typeof IndexRoute
135
141
'/feeds': typeof FeedsRoute
142
+
'/moderation': typeof ModerationRoute
136
143
'/notifications': typeof NotificationsRoute
137
144
'/search': typeof SearchRoute
138
145
'/settings': typeof SettingsRoute
···
152
159
export interface FileRoutesByTo {
153
160
'/': typeof IndexRoute
154
161
'/feeds': typeof FeedsRoute
162
+
'/moderation': typeof ModerationRoute
155
163
'/notifications': typeof NotificationsRoute
156
164
'/search': typeof SearchRoute
157
165
'/settings': typeof SettingsRoute
···
173
181
'/': typeof IndexRoute
174
182
'/_pathlessLayout': typeof PathlessLayoutRouteWithChildren
175
183
'/feeds': typeof FeedsRoute
184
+
'/moderation': typeof ModerationRoute
176
185
'/notifications': typeof NotificationsRoute
177
186
'/search': typeof SearchRoute
178
187
'/settings': typeof SettingsRoute
···
195
204
fullPaths:
196
205
| '/'
197
206
| '/feeds'
207
+
| '/moderation'
198
208
| '/notifications'
199
209
| '/search'
200
210
| '/settings'
···
214
224
to:
215
225
| '/'
216
226
| '/feeds'
227
+
| '/moderation'
217
228
| '/notifications'
218
229
| '/search'
219
230
| '/settings'
···
234
245
| '/'
235
246
| '/_pathlessLayout'
236
247
| '/feeds'
248
+
| '/moderation'
237
249
| '/notifications'
238
250
| '/search'
239
251
| '/settings'
···
256
268
IndexRoute: typeof IndexRoute
257
269
PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren
258
270
FeedsRoute: typeof FeedsRoute
271
+
ModerationRoute: typeof ModerationRoute
259
272
NotificationsRoute: typeof NotificationsRoute
260
273
SearchRoute: typeof SearchRoute
261
274
SettingsRoute: typeof SettingsRoute
···
288
301
path: '/notifications'
289
302
fullPath: '/notifications'
290
303
preLoaderRoute: typeof NotificationsRouteImport
304
+
parentRoute: typeof rootRouteImport
305
+
}
306
+
'/moderation': {
307
+
id: '/moderation'
308
+
path: '/moderation'
309
+
fullPath: '/moderation'
310
+
preLoaderRoute: typeof ModerationRouteImport
291
311
parentRoute: typeof rootRouteImport
292
312
}
293
313
'/feeds': {
···
456
476
IndexRoute: IndexRoute,
457
477
PathlessLayoutRoute: PathlessLayoutRouteWithChildren,
458
478
FeedsRoute: FeedsRoute,
479
+
ModerationRoute: ModerationRoute,
459
480
NotificationsRoute: NotificationsRoute,
460
481
SearchRoute: SearchRoute,
461
482
SettingsRoute: SettingsRoute,
+170
-11
src/routes/__root.tsx
+170
-11
src/routes/__root.tsx
···
14
14
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
15
15
import { useAtom } from "jotai";
16
16
import * as React from "react";
17
+
import { toast as sonnerToast } from "sonner";
18
+
import { Toaster } from "sonner";
17
19
import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive";
18
20
19
21
import { Composer } from "~/components/Composer";
···
83
85
<LikeMutationQueueProvider>
84
86
<RootDocument>
85
87
<KeepAliveProvider>
88
+
<AppToaster />
86
89
<KeepAliveOutlet />
87
90
</KeepAliveProvider>
88
91
</RootDocument>
···
91
94
);
92
95
}
93
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
+
94
201
function RootDocument({ children }: { children: React.ReactNode }) {
95
202
useAtomCssVar(hueAtom, "--tw-gray-hue");
96
203
const location = useLocation();
···
106
213
const isSettings = location.pathname.startsWith("/settings");
107
214
const isSearch = location.pathname.startsWith("/search");
108
215
const isFeeds = location.pathname.startsWith("/feeds");
216
+
const isModeration = location.pathname.startsWith("/moderation");
109
217
110
218
const locationEnum:
111
219
| "feeds"
···
113
221
| "settings"
114
222
| "notifications"
115
223
| "profile"
224
+
| "moderation"
116
225
| "home" = isFeeds
117
226
? "feeds"
118
227
: isSearch
···
123
232
? "notifications"
124
233
: isProfile
125
234
? "profile"
126
-
: "home";
235
+
: isModeration
236
+
? "moderation"
237
+
: "home";
127
238
128
239
const [, setComposerPost] = useAtom(composerAtom);
129
240
···
134
245
<div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950">
135
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">
136
247
<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))"}} />
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
+
/>
138
255
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
139
256
Red Dwarf{" "}
140
257
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
198
315
text="Feeds"
199
316
/>
200
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
201
330
InactiveIcon={
202
331
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
203
332
}
···
235
364
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
236
365
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
237
366
//active={true}
238
-
onClickCallbback={() => setComposerPost({ kind: 'root' })}
367
+
onClickCallbback={() => setComposerPost({ kind: "root" })}
239
368
text="Post"
240
369
/>
241
370
</div>
···
373
502
374
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">
375
504
<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))"}} />
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
+
/>
377
512
</div>
378
513
<MaterialNavItem
379
514
small
···
436
571
/>
437
572
<MaterialNavItem
438
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
439
587
InactiveIcon={
440
588
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
441
589
}
···
475
623
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
476
624
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
477
625
//active={true}
478
-
onClickCallbback={() => setComposerPost({ kind: 'root' })}
626
+
onClickCallbback={() => setComposerPost({ kind: "root" })}
479
627
text="Post"
480
628
/>
481
629
</div>
···
485
633
<button
486
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"
487
635
style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }}
488
-
onClick={() => setComposerPost({ kind: 'root' })}
636
+
onClick={() => setComposerPost({ kind: "root" })}
489
637
type="button"
490
638
aria-label="Create Post"
491
639
>
···
502
650
</main>
503
651
504
652
<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>
653
+
<div className="px-4 pt-4">
654
+
<Import />
655
+
</div>
506
656
<Login />
507
657
508
658
<div className="flex-1"></div>
509
659
<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)
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)
511
664
</p>
512
665
</aside>
513
666
</div>
···
654
807
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
655
808
}
656
809
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
657
-
active={locationEnum === "settings"}
810
+
active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"}
658
811
onClickCallbback={() =>
659
812
navigate({
660
813
to: "/settings",
···
683
836
) : (
684
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">
685
838
<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))"}} />
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
+
/>
687
846
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
688
847
Red Dwarf{" "}
689
848
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
703
862
);
704
863
}
705
864
706
-
function MaterialNavItem({
865
+
export function MaterialNavItem({
707
866
InactiveIcon,
708
867
ActiveIcon,
709
868
text,
+18
-1
src/routes/feeds.tsx
+18
-1
src/routes/feeds.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
2
3
+
import { Header } from "~/components/Header";
4
+
3
5
export const Route = createFileRoute("/feeds")({
4
6
component: Feeds,
5
7
});
6
8
7
9
export function Feeds() {
8
-
return <div className="p-6">Feeds page (coming soon)</div>;
10
+
return (
11
+
<div className="">
12
+
<Header
13
+
title={`Feeds`}
14
+
backButtonCallback={() => {
15
+
if (window.history.length > 1) {
16
+
window.history.back();
17
+
} else {
18
+
window.location.assign("/");
19
+
}
20
+
}}
21
+
bottomBorderDisabled={true}
22
+
/>
23
+
Feeds page (coming soon)
24
+
</div>
25
+
);
9
26
}
+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
+
}
+78
-2
src/routes/notifications.tsx
+78
-2
src/routes/notifications.tsx
···
19
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
20
20
import {
21
21
constellationURLAtom,
22
+
enableBitesAtom,
22
23
imgCDNAtom,
23
24
postInteractionsFiltersAtom,
24
25
} from "~/utils/atoms";
···
56
57
});
57
58
58
59
export default function NotificationsTabs() {
60
+
const [bitesEnabled] = useAtom(enableBitesAtom);
59
61
return (
60
62
<ReusableTabRoute
61
63
route={`Notifications`}
···
63
65
Mentions: <MentionsTab />,
64
66
Follows: <FollowsTab />,
65
67
"Post Interactions": <PostInteractionsTab />,
68
+
...bitesEnabled ? {
69
+
Bites: <BitesTab />,
70
+
} : {}
66
71
}}
67
72
/>
68
73
);
···
180
185
if (isError) return <ErrorState error={error} />;
181
186
182
187
if (!followsAturis?.length) return <EmptyState text="No follows yet." />;
188
+
189
+
return (
190
+
<>
191
+
{followsAturis.map((m) => (
192
+
<NotificationItem key={m} notification={m} />
193
+
))}
194
+
195
+
{hasNextPage && (
196
+
<button
197
+
onClick={() => fetchNextPage()}
198
+
disabled={isFetchingNextPage}
199
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
200
+
>
201
+
{isFetchingNextPage ? "Loading..." : "Load More"}
202
+
</button>
203
+
)}
204
+
</>
205
+
);
206
+
}
207
+
208
+
209
+
export function BitesTab({did}:{did?:string}) {
210
+
const { agent } = useAuth();
211
+
const userdidunsafe = did ?? agent?.did;
212
+
const { data: identity} = useQueryIdentity(userdidunsafe);
213
+
const userdid = identity?.did;
214
+
215
+
const [constellationurl] = useAtom(constellationURLAtom);
216
+
const infinitequeryresults = useInfiniteQuery({
217
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
218
+
{
219
+
constellation: constellationurl,
220
+
method: "/links",
221
+
target: "at://"+userdid,
222
+
collection: "net.wafrn.feed.bite",
223
+
path: ".subject",
224
+
staleMult: 0 // safe fun
225
+
}
226
+
),
227
+
enabled: !!userdid,
228
+
});
229
+
230
+
const {
231
+
data: infiniteFollowsData,
232
+
fetchNextPage,
233
+
hasNextPage,
234
+
isFetchingNextPage,
235
+
isLoading,
236
+
isError,
237
+
error,
238
+
} = infinitequeryresults;
239
+
240
+
const followsAturis = React.useMemo(() => {
241
+
// Get all replies from the standard infinite query
242
+
return (
243
+
infiniteFollowsData?.pages.flatMap(
244
+
(page) =>
245
+
page?.linking_records.map(
246
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
247
+
) ?? []
248
+
) ?? []
249
+
);
250
+
}, [infiniteFollowsData]);
251
+
252
+
useReusableTabScrollRestore("Notifications");
253
+
254
+
if (isLoading) return <LoadingState text="Loading bites..." />;
255
+
if (isError) return <ErrorState error={error} />;
256
+
257
+
if (!followsAturis?.length) return <EmptyState text="No bites yet." />;
183
258
184
259
return (
185
260
<>
···
497
572
);
498
573
}
499
574
500
-
export function NotificationItem({ notification }: { notification: string }) {
575
+
export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) {
501
576
const aturi = new AtUri(notification);
577
+
const bite = aturi.collection === "net.wafrn.feed.bite";
502
578
const navigate = useNavigate();
503
579
const { data: identity } = useQueryIdentity(aturi.host);
504
580
const resolvedDid = identity?.did;
···
542
618
<img
543
619
src={avatar || defaultpfp}
544
620
alt={identity?.handle}
545
-
className="w-10 h-10 rounded-full"
621
+
className={`w-10 h-10 ${labeler ? "rounded-md" : "rounded-full"}`}
546
622
/>
547
623
) : (
548
624
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
+267
-41
src/routes/profile.$did/index.tsx
+267
-41
src/routes/profile.$did/index.tsx
···
1
-
import { RichText } from "@atproto/api";
1
+
import { Agent, RichText } from "@atproto/api";
2
2
import * as ATPAPI from "@atproto/api";
3
+
import { TID } from "@atproto/common-web";
3
4
import { useQueryClient } from "@tanstack/react-query";
4
5
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
5
6
import { useAtom } from "jotai";
···
16
17
UniversalPostRendererATURILoader,
17
18
} from "~/components/UniversalPostRenderer";
18
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
19
-
import { imgCDNAtom, profileChipsAtom } from "~/utils/atoms";
20
+
import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms";
20
21
import {
21
22
toggleFollow,
22
23
useGetFollowState,
···
31
32
useQueryIdentity,
32
33
useQueryProfile,
33
34
} from "~/utils/useQuery";
35
+
import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx";
34
36
37
+
import { renderSnack } from "../__root";
35
38
import { Chip } from "../notifications";
36
39
37
40
export const Route = createFileRoute("/profile/$did/")({
···
50
53
error: identityError,
51
54
} = useQueryIdentity(did);
52
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
+
53
70
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
54
71
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
55
72
const pdsUrl = identity?.pds;
···
80
97
81
98
const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord;
82
99
83
-
const resultwhateversure = useQueryConstellationLinksCountDistinctDids(resolvedDid ? {
84
-
method: "/links/count/distinct-dids",
85
-
collection: "app.bsky.graph.follow",
86
-
target: resolvedDid,
87
-
path: ".subject"
88
-
} : undefined)
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
+
);
89
110
90
111
const followercount = resultwhateversure?.data?.total;
91
112
···
135
156
136
157
{/* Avatar (PFP) */}
137
158
<div className="absolute left-[16px] top-[100px] ">
138
-
<img
139
-
src={getAvatarUrl(profile) || "/favicon.png"}
140
-
alt="avatar"
141
-
className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700"
142
-
/>
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
+
)}
143
172
</div>
144
173
145
174
<div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5">
175
+
<BiteButton targetdidorhandle={did} />
146
176
{/*
147
177
todo: full follow and unfollow backfill (along with partial likes backfill,
148
178
just enough for it to be useful)
···
150
180
also save it persistently
151
181
*/}
152
182
<FollowButton targetdidorhandle={did} />
153
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
183
+
<button
184
+
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
185
+
onClick={(e) => {
186
+
renderSnack({
187
+
title: "Not Implemented Yet",
188
+
description: "Sorry...",
189
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
190
+
});
191
+
}}
192
+
>
154
193
... {/* todo: icon */}
155
194
</button>
156
195
</div>
···
163
202
{handle}
164
203
</div>
165
204
<div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2">
166
-
<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>
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>
167
213
-
168
-
<Link to="/profile/$did/follows" params={{did: did}}>Follows</Link>
214
+
<Link to="/profile/$did/follows" params={{ did: did }}>
215
+
Follows
216
+
</Link>
169
217
</div>
170
218
{description && (
171
219
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
···
181
229
<ReusableTabRoute
182
230
route={`Profile` + did}
183
231
tabs={{
184
-
Posts: <PostsTab did={did} />,
185
-
Reposts: <RepostsTab did={did} />,
186
-
Feeds: <FeedsTab did={did} />,
187
-
Lists: <ListsTab did={did} />,
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
+
},
188
243
...(identity?.did === agent?.did
189
244
? { Likes: <SelfLikesTab did={did} /> }
190
245
: {}),
···
210
265
}
211
266
212
267
export type ProfilePostsFilter = {
213
-
posts: boolean,
214
-
replies: boolean,
215
-
mediaOnly: boolean,
216
-
}
268
+
posts: boolean;
269
+
replies: boolean;
270
+
mediaOnly: boolean;
271
+
};
217
272
export const defaultProfilePostsFilter: ProfilePostsFilter = {
218
273
posts: true,
219
274
replies: true,
220
275
mediaOnly: false,
221
-
}
276
+
};
222
277
223
-
function ProfilePostsFilterChipBar({filters, toggle}:{filters: ProfilePostsFilter | null, toggle: (key: keyof ProfilePostsFilter) => void}) {
224
-
const empty = (!filters?.replies && !filters?.posts);
225
-
const almostEmpty = (!filters?.replies && filters?.posts);
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;
226
287
227
288
useEffect(() => {
228
289
if (empty) {
229
-
toggle("posts")
290
+
toggle("posts");
230
291
}
231
292
}, [empty, toggle]);
232
293
···
235
296
<Chip
236
297
state={filters?.posts ?? true}
237
298
text="Posts"
238
-
onClick={() => almostEmpty ? null : toggle("posts")}
299
+
onClick={() => (almostEmpty ? null : toggle("posts"))}
239
300
/>
240
301
<Chip
241
302
state={filters?.replies ?? true}
···
256
317
const [filterses, setFilterses] = useAtom(profileChipsAtom);
257
318
const filters = filterses?.[did];
258
319
const setFilters = (obj: ProfilePostsFilter) => {
259
-
setFilterses((prev)=>{
260
-
return{
320
+
setFilterses((prev) => {
321
+
return {
261
322
...prev,
262
-
[did]: obj
263
-
}
264
-
})
265
-
}
266
-
useEffect(()=>{
323
+
[did]: obj,
324
+
};
325
+
});
326
+
};
327
+
useEffect(() => {
267
328
if (!filters) {
268
329
setFilters(defaultProfilePostsFilter);
269
330
}
270
-
})
331
+
});
271
332
useReusableTabScrollRestore(`Profile` + did);
272
333
const queryClient = useQueryClient();
273
334
const {
···
304
365
);
305
366
306
367
const toggle = (key: keyof ProfilePostsFilter) => {
307
-
setFilterses(prev => {
308
-
const existing = prev[did] ?? { posts: false, replies: false, mediaOnly: false }; // default
368
+
setFilterses((prev) => {
369
+
const existing = prev[did] ?? {
370
+
posts: false,
371
+
replies: false,
372
+
mediaOnly: false,
373
+
}; // default
309
374
310
375
return {
311
376
...prev,
···
498
563
);
499
564
}
500
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
+
501
646
export function FeedItemRenderAturiLoader({
502
647
aturi,
503
648
listmode,
···
803
948
)}
804
949
</>
805
950
) : (
806
-
<button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]">
951
+
<button
952
+
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
953
+
onClick={(e) => {
954
+
renderSnack({
955
+
title: "Not Implemented Yet",
956
+
description: "Sorry...",
957
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
958
+
});
959
+
}}
960
+
>
807
961
Edit Profile
808
962
</button>
809
963
)}
810
964
</>
811
965
);
966
+
}
967
+
968
+
export function BiteButton({
969
+
targetdidorhandle,
970
+
}: {
971
+
targetdidorhandle: string;
972
+
}) {
973
+
const { agent } = useAuth();
974
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
975
+
const [show] = useAtom(enableBitesAtom);
976
+
977
+
if (!show) return;
978
+
979
+
return (
980
+
<>
981
+
<button
982
+
onClick={async (e) => {
983
+
e.stopPropagation();
984
+
await sendBite({
985
+
agent: agent || undefined,
986
+
targetDid: identity?.did,
987
+
});
988
+
}}
989
+
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
990
+
>
991
+
Bite
992
+
</button>
993
+
</>
994
+
);
995
+
}
996
+
997
+
async function sendBite({
998
+
agent,
999
+
targetDid,
1000
+
}: {
1001
+
agent?: Agent;
1002
+
targetDid?: string;
1003
+
}) {
1004
+
if (!agent?.did || !targetDid) {
1005
+
renderSnack({
1006
+
title: "Bite Failed",
1007
+
description: "You must be logged-in to bite someone.",
1008
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
1009
+
});
1010
+
return;
1011
+
}
1012
+
const newRecord = {
1013
+
repo: agent.did,
1014
+
collection: "net.wafrn.feed.bite",
1015
+
rkey: TID.next().toString(),
1016
+
record: {
1017
+
$type: "net.wafrn.feed.bite",
1018
+
subject: "at://" + targetDid,
1019
+
createdAt: new Date().toISOString(),
1020
+
},
1021
+
};
1022
+
1023
+
try {
1024
+
await agent.com.atproto.repo.createRecord(newRecord);
1025
+
renderSnack({
1026
+
title: "Bite Sent",
1027
+
description: "Your bite was delivered.",
1028
+
//button: { label: 'Undo', onClick: () => console.log('Undo clicked') },
1029
+
});
1030
+
} catch (err) {
1031
+
console.error("Bite failed:", err);
1032
+
renderSnack({
1033
+
title: "Bite Failed",
1034
+
description: "Your bite failed to be delivered.",
1035
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
1036
+
});
1037
+
}
812
1038
}
813
1039
814
1040
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
+217
-9
src/routes/search.tsx
+217
-9
src/routes/search.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
1
+
import type { Agent } from "@atproto/api";
2
+
import { useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, useSearch } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
5
+
import { useEffect,useMemo } from "react";
2
6
3
7
import { Header } from "~/components/Header";
4
8
import { Import } from "~/components/Import";
9
+
import {
10
+
ReusableTabRoute,
11
+
useReusableTabScrollRestore,
12
+
} from "~/components/ReusableTabRoute";
13
+
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
14
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
15
+
import { lycanURLAtom } from "~/utils/atoms";
16
+
import {
17
+
constructLycanRequestIndexQuery,
18
+
useInfiniteQueryLycanSearch,
19
+
useQueryIdentity,
20
+
useQueryLycanStatus,
21
+
} from "~/utils/useQuery";
22
+
23
+
import { renderSnack } from "./__root";
24
+
import { SliderPrimitive } from "./settings";
5
25
6
26
export const Route = createFileRoute("/search")({
7
27
component: Search,
8
28
});
9
29
10
30
export function Search() {
31
+
const queryClient = useQueryClient();
32
+
const { agent, status } = useAuth();
33
+
const { data: identity } = useQueryIdentity(agent?.did);
34
+
const [lycandomain] = useAtom(lycanURLAtom);
35
+
const lycanExists = lycandomain !== "";
36
+
const { data: lycanstatusdata, refetch } = useQueryLycanStatus();
37
+
const lycanIndexed = lycanstatusdata?.status === "finished" || false;
38
+
const lycanIndexing = lycanstatusdata?.status === "in_progress" || false;
39
+
const lycanIndexingProgress = lycanIndexing
40
+
? lycanstatusdata?.progress
41
+
: undefined;
42
+
43
+
const authed = status === "signedIn";
44
+
45
+
const lycanReady = lycanExists && lycanIndexed && authed;
46
+
47
+
const { q }: { q: string } = useSearch({ from: "/search" });
48
+
49
+
// auto-refetch Lycan status until ready
50
+
useEffect(() => {
51
+
if (!lycanExists || !authed) return;
52
+
if (lycanReady) return;
53
+
54
+
const interval = setInterval(() => {
55
+
refetch();
56
+
}, 3000);
57
+
58
+
return () => clearInterval(interval);
59
+
}, [lycanExists, authed, lycanReady, refetch]);
60
+
61
+
const maintext = !lycanExists
62
+
? "Sorry we dont have search. But instead, you can load some of these types of content into Red Dwarf:"
63
+
: authed
64
+
? lycanReady
65
+
? "Lycan Search is enabled and ready! Type to search posts you've interacted with in the past. You can also load some of these types of content into Red Dwarf:"
66
+
: "Sorry, while Lycan Search is enabled, you are not indexed. Index below please. You can load some of these types of content into Red Dwarf:"
67
+
: "Sorry, while Lycan Search is enabled, you are unauthed. Please log in to use Lycan. You can load some of these types of content into Red Dwarf:";
68
+
69
+
async function index(opts: {
70
+
agent?: Agent;
71
+
isAuthed: boolean;
72
+
pdsUrl?: string;
73
+
feedServiceDid?: string;
74
+
}) {
75
+
renderSnack({
76
+
title: "Registering account...",
77
+
});
78
+
try {
79
+
const response = await queryClient.fetchQuery(
80
+
constructLycanRequestIndexQuery(opts)
81
+
);
82
+
if (
83
+
response?.message !== "Import has already started" &&
84
+
response?.message !== "Import has been scheduled"
85
+
) {
86
+
renderSnack({
87
+
title: "Registration failed!",
88
+
description: "Unknown server error (2)",
89
+
});
90
+
} else {
91
+
renderSnack({
92
+
title: "Succesfully sent registration request!",
93
+
description: "Please wait for the server to index your account",
94
+
});
95
+
refetch();
96
+
}
97
+
} catch {
98
+
renderSnack({
99
+
title: "Registration failed!",
100
+
description: "Unknown server error (1)",
101
+
});
102
+
}
103
+
}
104
+
11
105
return (
12
106
<>
13
107
<Header
···
21
115
}}
22
116
/>
23
117
<div className=" flex flex-col items-center mt-4 mx-4 gap-4">
24
-
<Import />
118
+
<Import optionaltextstring={q} />
25
119
<div className="flex flex-col">
26
-
<p className="text-gray-600 dark:text-gray-400">
27
-
Sorry we dont have search. But instead, you can load some of these
28
-
types of content into Red Dwarf:
29
-
</p>
120
+
<p className="text-gray-600 dark:text-gray-400">{maintext}</p>
30
121
<ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400">
31
122
<li>
32
-
Bluesky URLs from supported clients (like{" "}
123
+
Bluesky URLs (from supported clients) (like{" "}
33
124
<code className="text-sm">bsky.app</code> or{" "}
34
125
<code className="text-sm">deer.social</code>).
35
126
</li>
···
39
130
).
40
131
</li>
41
132
<li>
42
-
Plain handles (like{" "}
133
+
User Handles (like{" "}
43
134
<code className="text-sm">@username.bsky.social</code>).
44
135
</li>
45
136
<li>
46
-
Direct DIDs (Decentralized Identifiers, starting with{" "}
137
+
DIDs (Decentralized Identifiers, starting with{" "}
47
138
<code className="text-sm">did:</code>).
48
139
</li>
49
140
</ul>
···
51
142
Simply paste one of these into the import field above and press
52
143
Enter to load the content.
53
144
</p>
145
+
146
+
{lycanExists && authed && !lycanReady ? (
147
+
!lycanIndexing ? (
148
+
<div className="mt-4 mx-auto">
149
+
<button
150
+
onClick={() =>
151
+
index({
152
+
agent: agent || undefined,
153
+
isAuthed: status === "signedIn",
154
+
pdsUrl: identity?.pds,
155
+
feedServiceDid: "did:web:" + lycandomain,
156
+
})
157
+
}
158
+
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
159
+
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
160
+
>
161
+
Index my Account
162
+
</button>
163
+
</div>
164
+
) : (
165
+
<div className="mt-4 gap-2 flex flex-col">
166
+
<span>indexing...</span>
167
+
<SliderPrimitive
168
+
value={lycanIndexingProgress || 0}
169
+
min={0}
170
+
max={1}
171
+
/>
172
+
</div>
173
+
)
174
+
) : (
175
+
<></>
176
+
)}
54
177
</div>
55
178
</div>
179
+
{q ? <SearchTabs query={q} /> : <></>}
56
180
</>
57
181
);
58
182
}
183
+
184
+
function SearchTabs({ query }: { query: string }) {
185
+
return (
186
+
<div>
187
+
<ReusableTabRoute
188
+
route={`search` + query}
189
+
tabs={{
190
+
Likes: <LycanTab query={query} type={"likes"} key={"likes"} />,
191
+
Reposts: <LycanTab query={query} type={"reposts"} key={"reposts"} />,
192
+
Quotes: <LycanTab query={query} type={"quotes"} key={"quotes"} />,
193
+
Pins: <LycanTab query={query} type={"pins"} key={"pins"} />,
194
+
}}
195
+
/>
196
+
</div>
197
+
);
198
+
}
199
+
200
+
function LycanTab({
201
+
query,
202
+
type,
203
+
}: {
204
+
query: string;
205
+
type: "likes" | "pins" | "reposts" | "quotes";
206
+
}) {
207
+
useReusableTabScrollRestore("search" + query);
208
+
209
+
const {
210
+
data: postsData,
211
+
fetchNextPage,
212
+
hasNextPage,
213
+
isFetchingNextPage,
214
+
isLoading: arePostsLoading,
215
+
} = useInfiniteQueryLycanSearch({ query: query, type: type });
216
+
217
+
const posts = useMemo(
218
+
() =>
219
+
postsData?.pages.flatMap((page) => {
220
+
if (page) {
221
+
return page.posts;
222
+
} else {
223
+
return [];
224
+
}
225
+
}) ?? [],
226
+
[postsData]
227
+
);
228
+
229
+
return (
230
+
<>
231
+
{/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
232
+
Posts
233
+
</div> */}
234
+
<div>
235
+
{posts.map((post) => (
236
+
<UniversalPostRendererATURILoader
237
+
key={post}
238
+
atUri={post}
239
+
feedviewpost={true}
240
+
/>
241
+
))}
242
+
</div>
243
+
244
+
{/* Loading and "Load More" states */}
245
+
{arePostsLoading && posts.length === 0 && (
246
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
247
+
)}
248
+
{isFetchingNextPage && (
249
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
250
+
)}
251
+
{hasNextPage && !isFetchingNextPage && (
252
+
<button
253
+
onClick={() => fetchNextPage()}
254
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
255
+
>
256
+
Load More Posts
257
+
</button>
258
+
)}
259
+
{posts.length === 0 && !arePostsLoading && (
260
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
261
+
)}
262
+
</>
263
+
);
264
+
265
+
return <></>;
266
+
}
+188
-17
src/routes/settings.tsx
+188
-17
src/routes/settings.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
2
-
import { useAtom } from "jotai";
3
-
import { Slider } from "radix-ui";
1
+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
2
+
import { useAtom, useAtomValue, useSetAtom } from "jotai";
3
+
import { Slider, Switch } from "radix-ui";
4
+
import { useEffect, useState } from "react";
4
5
5
6
import { Header } from "~/components/Header";
6
7
import Login from "~/components/Login";
···
9
10
defaultconstellationURL,
10
11
defaulthue,
11
12
defaultImgCDN,
13
+
defaultLycanURL,
12
14
defaultslingshotURL,
13
15
defaultVideoCDN,
16
+
enableBitesAtom,
17
+
enableBridgyTextAtom,
18
+
enableWafrnTextAtom,
14
19
hueAtom,
15
20
imgCDNAtom,
21
+
lycanURLAtom,
16
22
slingshotURLAtom,
17
23
videoCDNAtom,
18
24
} from "~/utils/atoms";
19
25
26
+
import { MaterialNavItem } from "./__root";
27
+
20
28
export const Route = createFileRoute("/settings")({
21
29
component: Settings,
22
30
});
23
31
24
32
export function Settings() {
33
+
const navigate = useNavigate();
25
34
return (
26
35
<>
27
36
<Header
···
37
46
<div className="lg:hidden">
38
47
<Login />
39
48
</div>
49
+
<div className="sm:hidden flex flex-col justify-around mt-4">
50
+
<SettingHeading title="Other Pages" top />
51
+
<MaterialNavItem
52
+
InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
53
+
ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
54
+
active={false}
55
+
onClickCallbback={() =>
56
+
navigate({
57
+
to: "/feeds",
58
+
//params: { did: agent.assertDid },
59
+
})
60
+
}
61
+
text="Feeds"
62
+
/>
63
+
<MaterialNavItem
64
+
InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />}
65
+
ActiveIcon={<IconMdiShield className="w-6 h-6" />}
66
+
active={false}
67
+
onClickCallbback={() =>
68
+
navigate({
69
+
to: "/moderation",
70
+
//params: { did: agent.assertDid },
71
+
})
72
+
}
73
+
text="Moderation"
74
+
/>
75
+
</div>
40
76
<div className="h-4" />
77
+
78
+
<SettingHeading title="Personalization" top />
79
+
<Hue />
80
+
81
+
<SettingHeading title="Network Configuration" />
82
+
<div className="flex flex-col px-4 pb-2">
83
+
<span className="text-md">Service Endpoints</span>
84
+
<span className="text-sm text-gray-500 dark:text-gray-400">
85
+
Customize the servers to be used by the app
86
+
</span>
87
+
</div>
41
88
<TextInputSetting
42
89
atom={constellationURLAtom}
43
90
title={"Constellation"}
···
66
113
description={"Customize the Slingshot instance to be used by Red Dwarf"}
67
114
init={defaultVideoCDN}
68
115
/>
116
+
<TextInputSetting
117
+
atom={lycanURLAtom}
118
+
title={"Lycan Search"}
119
+
description={"Enable text search across posts you've interacted with"}
120
+
init={defaultLycanURL}
121
+
/>
69
122
70
-
<Hue />
71
-
<p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm">
72
-
please restart/refresh the app if changes arent applying correctly
123
+
<SettingHeading title="Experimental" />
124
+
<SwitchSetting
125
+
atom={enableBitesAtom}
126
+
title={"Bites"}
127
+
description={"Enable Wafrn Bites to bite and be bitten by other people"}
128
+
//init={false}
129
+
/>
130
+
<div className="h-4" />
131
+
<SwitchSetting
132
+
atom={enableBridgyTextAtom}
133
+
title={"Bridgy Text"}
134
+
description={
135
+
"Show the original text of posts bridged from the Fediverse"
136
+
}
137
+
//init={false}
138
+
/>
139
+
<div className="h-4" />
140
+
<SwitchSetting
141
+
atom={enableWafrnTextAtom}
142
+
title={"Wafrn Text"}
143
+
description={"Show the original text of posts from Wafrn instances"}
144
+
//init={false}
145
+
/>
146
+
<p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4">
147
+
Notice: Please restart/refresh the app if changes arent applying
148
+
correctly
73
149
</p>
74
150
</>
75
151
);
76
152
}
153
+
154
+
export function SettingHeading({
155
+
title,
156
+
top,
157
+
}: {
158
+
title: string;
159
+
top?: boolean;
160
+
}) {
161
+
return (
162
+
<div
163
+
className="px-4"
164
+
style={{ marginTop: top ? 0 : 18, paddingBottom: 12 }}
165
+
>
166
+
<span className=" text-sm font-medium text-gray-500 dark:text-gray-400">
167
+
{title}
168
+
</span>
169
+
</div>
170
+
);
171
+
}
172
+
173
+
export function SwitchSetting({
174
+
atom,
175
+
title,
176
+
description,
177
+
}: {
178
+
atom: typeof enableBitesAtom;
179
+
title?: string;
180
+
description?: string;
181
+
}) {
182
+
const value = useAtomValue(atom);
183
+
const setValue = useSetAtom(atom);
184
+
185
+
const [hydrated, setHydrated] = useState(false);
186
+
// eslint-disable-next-line react-hooks/set-state-in-effect
187
+
useEffect(() => setHydrated(true), []);
188
+
189
+
if (!hydrated) {
190
+
// Avoid rendering Switch until we know storage is loaded
191
+
return null;
192
+
}
193
+
194
+
return (
195
+
<div className="flex items-center gap-4 px-4 ">
196
+
<label htmlFor={`switch-${title}`} className="flex flex-row flex-1">
197
+
<div className="flex flex-col">
198
+
<span className="text-md">{title}</span>
199
+
<span className="text-sm text-gray-500 dark:text-gray-400">
200
+
{description}
201
+
</span>
202
+
</div>
203
+
</label>
204
+
205
+
<Switch.Root
206
+
id={`switch-${title}`}
207
+
checked={value}
208
+
onCheckedChange={(v) => setValue(v)}
209
+
className="m3switch root"
210
+
>
211
+
<Switch.Thumb className="m3switch thumb " />
212
+
</Switch.Root>
213
+
</div>
214
+
);
215
+
}
216
+
77
217
function Hue() {
78
218
const [hue, setHue] = useAtom(hueAtom);
79
219
return (
80
-
<div className="flex flex-col px-4 mt-4 ">
81
-
<span className="z-10">Hue</span>
82
-
<div className="flex flex-row items-center gap-4">
83
-
<SliderComponent
84
-
atom={hueAtom}
85
-
max={360}
86
-
/>
220
+
<div className="flex flex-col px-4">
221
+
<span className="z-[2] text-md">Hue</span>
222
+
<span className="z-[2] text-sm text-gray-500 dark:text-gray-400">
223
+
Change the colors of the app
224
+
</span>
225
+
<div className="z-[1] flex flex-row items-center gap-4">
226
+
<SliderComponent atom={hueAtom} max={360} />
87
227
<button
88
228
onClick={() => setHue(defaulthue ?? 28)}
89
229
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
···
154
294
);
155
295
}
156
296
157
-
158
297
interface SliderProps {
159
298
atom: typeof hueAtom;
160
299
min?: number;
···
168
307
max = 100,
169
308
step = 1,
170
309
}) => {
171
-
172
-
const [value, setValue] = useAtom(atom)
310
+
const [value, setValue] = useAtom(atom);
173
311
174
312
return (
175
313
<Slider.Root
···
186
324
<Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" />
187
325
</Slider.Root>
188
326
);
189
-
};
327
+
};
328
+
329
+
330
+
interface SliderPProps {
331
+
value: number;
332
+
min?: number;
333
+
max?: number;
334
+
step?: number;
335
+
}
336
+
337
+
338
+
export const SliderPrimitive: React.FC<SliderPProps> = ({
339
+
value,
340
+
min = 0,
341
+
max = 100,
342
+
step = 1,
343
+
}) => {
344
+
345
+
return (
346
+
<Slider.Root
347
+
className="relative flex items-center w-full h-4"
348
+
value={[value]}
349
+
min={min}
350
+
max={max}
351
+
step={step}
352
+
onValueChange={(v: number[]) => {}}
353
+
>
354
+
<Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full">
355
+
<Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" />
356
+
</Slider.Track>
357
+
<Slider.Thumb className=" hidden shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" />
358
+
</Slider.Root>
359
+
);
360
+
};
+98
-1
src/styles/app.css
+98
-1
src/styles/app.css
···
33
33
--color-gray-950: oklch(0.129 0.055 var(--safe-hue));
34
34
}
35
35
36
+
:root {
37
+
--link-text-color: oklch(0.5962 0.1987 var(--safe-hue));
38
+
/* max chroma!!! use fallback*/
39
+
/*--link-text-color: oklch(0.6 0.37 var(--safe-hue));*/
40
+
}
41
+
36
42
@layer base {
37
43
html {
38
44
color-scheme: light dark;
···
84
90
.dangerousFediContent {
85
91
& a[href]{
86
92
text-decoration: none;
87
-
color: rgb(29, 122, 242);
93
+
color: var(--link-text-color);
88
94
word-break: break-all;
89
95
}
90
96
}
···
275
281
&::before{
276
282
background-color: var(--color-gray-500);
277
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);
278
375
}
279
376
}
280
377
}
+25
src/utils/atoms.ts
+25
src/utils/atoms.ts
···
92
92
defaultVideoCDN
93
93
);
94
94
95
+
export const defaultLycanURL = "";
96
+
export const lycanURLAtom = atomWithStorage<string>(
97
+
"lycanURL",
98
+
defaultLycanURL
99
+
);
100
+
95
101
export const defaulthue = 28;
96
102
export const hueAtom = atomWithStorage<number>("hue", defaulthue);
97
103
···
128
134
// console.log("atom get ", initial);
129
135
// document.documentElement.style.setProperty(cssVar, initial.toString());
130
136
// }
137
+
138
+
139
+
140
+
// fun stuff
141
+
142
+
export const enableBitesAtom = atomWithStorage<boolean>(
143
+
"enableBitesAtom",
144
+
false
145
+
);
146
+
147
+
export const enableBridgyTextAtom = atomWithStorage<boolean>(
148
+
"enableBridgyTextAtom",
149
+
false
150
+
);
151
+
152
+
export const enableWafrnTextAtom = atomWithStorage<boolean>(
153
+
"enableWafrnTextAtom",
154
+
false
155
+
);
+2
-2
src/utils/oauthClient.ts
+2
-2
src/utils/oauthClient.ts
···
1
1
import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser';
2
2
3
-
// i tried making this https://pds-nd.whey.party but cors is annoying as fuck
4
-
const handleResolverPDS = 'https://bsky.social';
3
+
import resolvers from '../../public/resolvers.json' with { type: 'json' };
4
+
const handleResolverPDS = resolvers.resolver || 'https://bsky.social';
5
5
6
6
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
7
7
// @ts-ignore this should be fine ? the vite plugin should generate this before errors
+387
-158
src/utils/useQuery.ts
+387
-158
src/utils/useQuery.ts
···
5
5
queryOptions,
6
6
useInfiniteQuery,
7
7
useQuery,
8
-
type UseQueryResult} from "@tanstack/react-query";
8
+
type UseQueryResult,
9
+
} from "@tanstack/react-query";
9
10
import { useAtom } from "jotai";
10
11
11
-
import { constellationURLAtom, slingshotURLAtom } from "./atoms";
12
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
13
+
14
+
import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms";
12
15
13
-
export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) {
16
+
export function constructIdentityQuery(
17
+
didorhandle?: string,
18
+
slingshoturl?: string
19
+
) {
14
20
return queryOptions({
15
21
queryKey: ["identity", didorhandle],
16
22
queryFn: async () => {
17
-
if (!didorhandle) return undefined as undefined
23
+
if (!didorhandle) return undefined as undefined;
18
24
const res = await fetch(
19
25
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
20
26
);
···
31
37
}
32
38
},
33
39
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
34
-
gcTime: /*0//*/5 * 60 * 1000,
40
+
gcTime: /*0//*/ 5 * 60 * 1000,
35
41
});
36
42
}
37
43
export function useQueryIdentity(didorhandle: string): UseQueryResult<
···
43
49
},
44
50
Error
45
51
>;
46
-
export function useQueryIdentity(): UseQueryResult<
47
-
undefined,
48
-
Error
49
-
>
50
-
export function useQueryIdentity(didorhandle?: string):
51
-
UseQueryResult<
52
-
{
53
-
did: string;
54
-
handle: string;
55
-
pds: string;
56
-
signing_key: string;
57
-
} | undefined,
58
-
Error
59
-
>
52
+
export function useQueryIdentity(): UseQueryResult<undefined, Error>;
53
+
export function useQueryIdentity(didorhandle?: string): UseQueryResult<
54
+
| {
55
+
did: string;
56
+
handle: string;
57
+
pds: string;
58
+
signing_key: string;
59
+
}
60
+
| undefined,
61
+
Error
62
+
>;
60
63
export function useQueryIdentity(didorhandle?: string) {
61
-
const [slingshoturl] = useAtom(slingshotURLAtom)
64
+
const [slingshoturl] = useAtom(slingshotURLAtom);
62
65
return useQuery(constructIdentityQuery(didorhandle, slingshoturl));
63
66
}
64
67
···
66
69
return queryOptions({
67
70
queryKey: ["post", uri],
68
71
queryFn: async () => {
69
-
if (!uri) return undefined as undefined
72
+
if (!uri) return undefined as undefined;
70
73
const res = await fetch(
71
74
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
72
75
);
···
77
80
return undefined;
78
81
}
79
82
if (res.status === 400) return undefined;
80
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
83
+
if (
84
+
data?.error === "InvalidRequest" &&
85
+
data.message?.includes("Could not find repo")
86
+
) {
81
87
return undefined; // cache โnot foundโ
82
88
}
83
89
try {
84
90
if (!res.ok) throw new Error("Failed to fetch post");
85
-
return (data) as {
91
+
return data as {
86
92
uri: string;
87
93
cid: string;
88
94
value: any;
···
97
103
return failureCount < 2;
98
104
},
99
105
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
100
-
gcTime: /*0//*/5 * 60 * 1000,
106
+
gcTime: /*0//*/ 5 * 60 * 1000,
101
107
});
102
108
}
103
109
export function useQueryPost(uri: string): UseQueryResult<
···
108
114
},
109
115
Error
110
116
>;
111
-
export function useQueryPost(): UseQueryResult<
112
-
undefined,
113
-
Error
114
-
>
115
-
export function useQueryPost(uri?: string):
116
-
UseQueryResult<
117
-
{
118
-
uri: string;
119
-
cid: string;
120
-
value: ATPAPI.AppBskyFeedPost.Record;
121
-
} | undefined,
122
-
Error
123
-
>
117
+
export function useQueryPost(): UseQueryResult<undefined, Error>;
118
+
export function useQueryPost(uri?: string): UseQueryResult<
119
+
| {
120
+
uri: string;
121
+
cid: string;
122
+
value: ATPAPI.AppBskyFeedPost.Record;
123
+
}
124
+
| undefined,
125
+
Error
126
+
>;
124
127
export function useQueryPost(uri?: string) {
125
-
const [slingshoturl] = useAtom(slingshotURLAtom)
128
+
const [slingshoturl] = useAtom(slingshotURLAtom);
126
129
return useQuery(constructPostQuery(uri, slingshoturl));
127
130
}
128
131
···
130
133
return queryOptions({
131
134
queryKey: ["profile", uri],
132
135
queryFn: async () => {
133
-
if (!uri) return undefined as undefined
136
+
if (!uri) return undefined as undefined;
134
137
const res = await fetch(
135
138
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
136
139
);
···
141
144
return undefined;
142
145
}
143
146
if (res.status === 400) return undefined;
144
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
147
+
if (
148
+
data?.error === "InvalidRequest" &&
149
+
data.message?.includes("Could not find repo")
150
+
) {
145
151
return undefined; // cache โnot foundโ
146
152
}
147
153
try {
148
154
if (!res.ok) throw new Error("Failed to fetch post");
149
-
return (data) as {
155
+
return data as {
150
156
uri: string;
151
157
cid: string;
152
158
value: any;
···
161
167
return failureCount < 2;
162
168
},
163
169
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
164
-
gcTime: /*0//*/5 * 60 * 1000,
170
+
gcTime: /*0//*/ 5 * 60 * 1000,
165
171
});
166
172
}
167
173
export function useQueryProfile(uri: string): UseQueryResult<
···
172
178
},
173
179
Error
174
180
>;
175
-
export function useQueryProfile(): UseQueryResult<
176
-
undefined,
177
-
Error
178
-
>;
179
-
export function useQueryProfile(uri?: string):
180
-
UseQueryResult<
181
-
{
181
+
export function useQueryProfile(): UseQueryResult<undefined, Error>;
182
+
export function useQueryProfile(uri?: string): UseQueryResult<
183
+
| {
182
184
uri: string;
183
185
cid: string;
184
186
value: ATPAPI.AppBskyActorProfile.Record;
185
-
} | undefined,
186
-
Error
187
-
>
187
+
}
188
+
| undefined,
189
+
Error
190
+
>;
188
191
export function useQueryProfile(uri?: string) {
189
-
const [slingshoturl] = useAtom(slingshotURLAtom)
192
+
const [slingshoturl] = useAtom(slingshotURLAtom);
190
193
return useQuery(constructProfileQuery(uri, slingshoturl));
191
194
}
192
195
···
222
225
// method: "/links/all",
223
226
// target: string
224
227
// ): QueryOptions<linksAllResponse, Error>;
225
-
export function constructConstellationQuery(query?:{
226
-
constellation: string,
228
+
export function constructConstellationQuery(query?: {
229
+
constellation: string;
227
230
method:
228
231
| "/links"
229
232
| "/links/distinct-dids"
230
233
| "/links/count"
231
234
| "/links/count/distinct-dids"
232
235
| "/links/all"
233
-
| "undefined",
234
-
target: string,
235
-
collection?: string,
236
-
path?: string,
237
-
cursor?: string,
238
-
dids?: string[]
239
-
}
240
-
) {
236
+
| "undefined";
237
+
target: string;
238
+
collection?: string;
239
+
path?: string;
240
+
cursor?: string;
241
+
dids?: string[];
242
+
}) {
241
243
// : QueryOptions<
242
244
// | linksRecordsResponse
243
245
// | linksDidsResponse
···
247
249
// Error
248
250
// >
249
251
return queryOptions({
250
-
queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const,
252
+
queryKey: [
253
+
"constellation",
254
+
query?.method,
255
+
query?.target,
256
+
query?.collection,
257
+
query?.path,
258
+
query?.cursor,
259
+
query?.dids,
260
+
] as const,
251
261
queryFn: async () => {
252
-
if (!query || query.method === "undefined") return undefined as undefined
253
-
const method = query.method
254
-
const target = query.target
255
-
const collection = query?.collection
256
-
const path = query?.path
257
-
const cursor = query.cursor
258
-
const dids = query?.dids
262
+
if (!query || query.method === "undefined") return undefined as undefined;
263
+
const method = query.method;
264
+
const target = query.target;
265
+
const collection = query?.collection;
266
+
const path = query?.path;
267
+
const cursor = query.cursor;
268
+
const dids = query?.dids;
259
269
const res = await fetch(
260
270
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
261
271
);
···
281
291
},
282
292
// enforce short lifespan
283
293
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
284
-
gcTime: /*0//*/5 * 60 * 1000,
294
+
gcTime: /*0//*/ 5 * 60 * 1000,
285
295
});
286
296
}
287
297
// todo do more of these instead of overloads since overloads sucks so much apparently
···
293
303
cursor?: string;
294
304
}): UseQueryResult<linksCountResponse, Error> | undefined {
295
305
//if (!query) return;
296
-
const [constellationurl] = useAtom(constellationURLAtom)
306
+
const [constellationurl] = useAtom(constellationURLAtom);
297
307
const queryres = useQuery(
298
-
constructConstellationQuery(query && {constellation: constellationurl, ...query})
308
+
constructConstellationQuery(
309
+
query && { constellation: constellationurl, ...query }
310
+
)
299
311
) as unknown as UseQueryResult<linksCountResponse, Error>;
300
312
if (!query) {
301
-
return undefined as undefined;
313
+
return undefined as undefined;
302
314
}
303
315
return queryres as UseQueryResult<linksCountResponse, Error>;
304
316
}
···
365
377
>
366
378
| undefined {
367
379
//if (!query) return;
368
-
const [constellationurl] = useAtom(constellationURLAtom)
380
+
const [constellationurl] = useAtom(constellationURLAtom);
369
381
return useQuery(
370
-
constructConstellationQuery(query && {constellation: constellationurl, ...query})
382
+
constructConstellationQuery(
383
+
query && { constellation: constellationurl, ...query }
384
+
)
371
385
);
372
386
}
373
387
···
411
425
}) {
412
426
return queryOptions({
413
427
// 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 }],
428
+
queryKey: [
429
+
"feedSkeleton",
430
+
options?.feedUri,
431
+
{ isAuthed: options?.isAuthed, did: options?.agent?.did },
432
+
],
415
433
queryFn: async () => {
416
-
if (!options) return undefined as undefined
434
+
if (!options) return undefined as undefined;
417
435
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
418
436
if (isAuthed) {
419
437
// Authenticated flow
420
438
if (!agent || !pdsUrl || !feedServiceDid) {
421
-
throw new Error("Missing required info for authenticated feed fetch.");
439
+
throw new Error(
440
+
"Missing required info for authenticated feed fetch."
441
+
);
422
442
}
423
443
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
424
444
const res = await agent.fetchHandler(url, {
···
428
448
"Content-Type": "application/json",
429
449
},
430
450
});
431
-
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
451
+
if (!res.ok)
452
+
throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
432
453
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
433
454
} else {
434
455
// Unauthenticated flow (using a public PDS/AppView)
435
456
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
436
457
const res = await fetch(url);
437
-
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
458
+
if (!res.ok)
459
+
throw new Error(`Public feed fetch failed: ${res.statusText}`);
438
460
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
439
461
}
440
462
},
···
452
474
return useQuery(constructFeedSkeletonQuery(options));
453
475
}
454
476
455
-
export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) {
477
+
export function constructPreferencesQuery(
478
+
agent?: ATPAPI.Agent | undefined,
479
+
pdsUrl?: string | undefined
480
+
) {
456
481
return queryOptions({
457
-
queryKey: ['preferences', agent?.did],
482
+
queryKey: ["preferences", agent?.did],
458
483
queryFn: async () => {
459
484
if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available");
460
485
const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
···
465
490
});
466
491
}
467
492
export function useQueryPreferences(options: {
468
-
agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined
493
+
agent?: ATPAPI.Agent | undefined;
494
+
pdsUrl?: string | undefined;
469
495
}) {
470
496
return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl));
471
497
}
472
498
473
-
474
-
475
499
export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
476
500
return queryOptions({
477
501
queryKey: ["arbitrary", uri],
478
502
queryFn: async () => {
479
-
if (!uri) return undefined as undefined
503
+
if (!uri) return undefined as undefined;
480
504
const res = await fetch(
481
505
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
482
506
);
···
487
511
return undefined;
488
512
}
489
513
if (res.status === 400) return undefined;
490
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
514
+
if (
515
+
data?.error === "InvalidRequest" &&
516
+
data.message?.includes("Could not find repo")
517
+
) {
491
518
return undefined; // cache โnot foundโ
492
519
}
493
520
try {
494
521
if (!res.ok) throw new Error("Failed to fetch post");
495
-
return (data) as {
522
+
return data as {
496
523
uri: string;
497
524
cid: string;
498
525
value: any;
···
507
534
return failureCount < 2;
508
535
},
509
536
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
510
-
gcTime: /*0//*/5 * 60 * 1000,
537
+
gcTime: /*0//*/ 5 * 60 * 1000,
511
538
});
512
539
}
513
540
export function useQueryArbitrary(uri: string): UseQueryResult<
···
518
545
},
519
546
Error
520
547
>;
521
-
export function useQueryArbitrary(): UseQueryResult<
522
-
undefined,
523
-
Error
524
-
>;
548
+
export function useQueryArbitrary(): UseQueryResult<undefined, Error>;
525
549
export function useQueryArbitrary(uri?: string): UseQueryResult<
526
-
{
527
-
uri: string;
528
-
cid: string;
529
-
value: any;
530
-
} | undefined,
550
+
| {
551
+
uri: string;
552
+
cid: string;
553
+
value: any;
554
+
}
555
+
| undefined,
531
556
Error
532
557
>;
533
558
export function useQueryArbitrary(uri?: string) {
534
-
const [slingshoturl] = useAtom(slingshotURLAtom)
559
+
const [slingshoturl] = useAtom(slingshotURLAtom);
535
560
return useQuery(constructArbitraryQuery(uri, slingshoturl));
536
561
}
537
562
538
-
export function constructFallbackNothingQuery(){
563
+
export function constructFallbackNothingQuery() {
539
564
return queryOptions({
540
565
queryKey: ["nothing"],
541
566
queryFn: async () => {
542
-
return undefined
567
+
return undefined;
543
568
},
544
569
});
545
570
}
···
553
578
}[];
554
579
};
555
580
556
-
export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") {
581
+
export function constructAuthorFeedQuery(
582
+
did: string,
583
+
pdsUrl: string,
584
+
collection: string = "app.bsky.feed.post"
585
+
) {
557
586
return queryOptions({
558
-
queryKey: ['authorFeed', did, collection],
587
+
queryKey: ["authorFeed", did, collection],
559
588
queryFn: async ({ pageParam }: QueryFunctionContext) => {
560
589
const limit = 25;
561
-
590
+
562
591
const cursor = pageParam as string | undefined;
563
-
const cursorParam = cursor ? `&cursor=${cursor}` : '';
564
-
592
+
const cursorParam = cursor ? `&cursor=${cursor}` : "";
593
+
565
594
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`;
566
-
595
+
567
596
const res = await fetch(url);
568
597
if (!res.ok) throw new Error("Failed to fetch author's posts");
569
-
598
+
570
599
return res.json() as Promise<ListRecordsResponse>;
571
600
},
572
601
});
573
602
}
574
603
575
-
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) {
576
-
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection);
577
-
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
+
578
615
return useInfiniteQuery({
579
616
queryKey,
580
617
queryFn,
···
595
632
// todo the hell is a unauthedfeedurl
596
633
unauthedfeedurl?: string;
597
634
}) {
598
-
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = options;
599
-
635
+
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } =
636
+
options;
637
+
600
638
return queryOptions({
601
639
queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
602
-
603
-
queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
640
+
641
+
queryFn: async ({
642
+
pageParam,
643
+
}: QueryFunctionContext): Promise<FeedSkeletonPage> => {
604
644
const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
605
-
645
+
606
646
if (isAuthed && !unauthedfeedurl) {
607
647
if (!agent || !pdsUrl || !feedServiceDid) {
608
-
throw new Error("Missing required info for authenticated feed fetch.");
648
+
throw new Error(
649
+
"Missing required info for authenticated feed fetch."
650
+
);
609
651
}
610
652
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
611
653
const res = await agent.fetchHandler(url, {
···
615
657
"Content-Type": "application/json",
616
658
},
617
659
});
618
-
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
660
+
if (!res.ok)
661
+
throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
619
662
return (await res.json()) as FeedSkeletonPage;
620
663
} else {
621
664
const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
622
665
const res = await fetch(url);
623
-
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
666
+
if (!res.ok)
667
+
throw new Error(`Public feed fetch failed: ${res.statusText}`);
624
668
return (await res.json()) as FeedSkeletonPage;
625
669
}
626
670
},
···
636
680
unauthedfeedurl?: string;
637
681
}) {
638
682
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
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
+
}
651
703
652
704
export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
653
-
constellation: string,
654
-
method: '/links'
655
-
target?: string
656
-
collection: string
657
-
path: string
705
+
constellation: string;
706
+
method: "/links";
707
+
target?: string;
708
+
collection: string;
709
+
path: string;
710
+
staleMult?: number;
658
711
}) {
712
+
const safemult = query?.staleMult ?? 1;
659
713
// console.log(
660
714
// 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
661
715
// query,
···
664
718
return infiniteQueryOptions({
665
719
enabled: !!query?.target,
666
720
queryKey: [
667
-
'reddwarf_constellation',
721
+
"reddwarf_constellation",
668
722
query?.method,
669
723
query?.target,
670
724
query?.collection,
671
725
query?.path,
672
726
] as const,
673
727
674
-
queryFn: async ({pageParam}: {pageParam?: string}) => {
675
-
if (!query || !query?.target) return undefined
728
+
queryFn: async ({ pageParam }: { pageParam?: string }) => {
729
+
if (!query || !query?.target) return undefined;
676
730
677
-
const method = query.method
678
-
const target = query.target
679
-
const collection = query.collection
680
-
const path = query.path
681
-
const cursor = pageParam
731
+
const method = query.method;
732
+
const target = query.target;
733
+
const collection = query.collection;
734
+
const path = query.path;
735
+
const cursor = pageParam;
682
736
683
737
const res = await fetch(
684
738
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
685
-
collection ? `&collection=${encodeURIComponent(collection)}` : ''
686
-
}${path ? `&path=${encodeURIComponent(path)}` : ''}${
687
-
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
688
-
}`,
689
-
)
739
+
collection ? `&collection=${encodeURIComponent(collection)}` : ""
740
+
}${path ? `&path=${encodeURIComponent(path)}` : ""}${
741
+
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""
742
+
}`
743
+
);
690
744
691
-
if (!res.ok) throw new Error('Failed to fetch')
745
+
if (!res.ok) throw new Error("Failed to fetch");
692
746
693
-
return (await res.json()) as linksRecordsResponse
747
+
return (await res.json()) as linksRecordsResponse;
694
748
},
695
749
696
-
getNextPageParam: lastPage => {
697
-
return (lastPage as any)?.cursor ?? undefined
750
+
getNextPageParam: (lastPage) => {
751
+
return (lastPage as any)?.cursor ?? undefined;
698
752
},
699
753
initialPageParam: undefined,
700
-
staleTime: 5 * 60 * 1000,
701
-
gcTime: 5 * 60 * 1000,
702
-
})
703
-
}
754
+
staleTime: 5 * 60 * 1000 * safemult,
755
+
gcTime: 5 * 60 * 1000 * safemult,
756
+
});
757
+
}
758
+
759
+
export function useQueryLycanStatus() {
760
+
const [lycanurl] = useAtom(lycanURLAtom);
761
+
const { agent, status } = useAuth();
762
+
const { data: identity } = useQueryIdentity(agent?.did);
763
+
return useQuery(
764
+
constructLycanStatusCheckQuery({
765
+
agent: agent || undefined,
766
+
isAuthed: status === "signedIn",
767
+
pdsUrl: identity?.pds,
768
+
feedServiceDid: "did:web:"+lycanurl,
769
+
})
770
+
);
771
+
}
772
+
773
+
export function constructLycanStatusCheckQuery(options: {
774
+
agent?: ATPAPI.Agent;
775
+
isAuthed: boolean;
776
+
pdsUrl?: string;
777
+
feedServiceDid?: string;
778
+
}) {
779
+
const { agent, isAuthed, pdsUrl, feedServiceDid } = options;
780
+
781
+
return queryOptions({
782
+
queryKey: ["lycanStatus", { isAuthed, did: agent?.did }],
783
+
784
+
queryFn: async () => {
785
+
if (isAuthed && agent && pdsUrl && feedServiceDid) {
786
+
const url = `${pdsUrl}/xrpc/blue.feeds.lycan.getImportStatus`;
787
+
const res = await agent.fetchHandler(url, {
788
+
method: "GET",
789
+
headers: {
790
+
"atproto-proxy": `${feedServiceDid}#lycan`,
791
+
"Content-Type": "application/json",
792
+
},
793
+
});
794
+
if (!res.ok)
795
+
throw new Error(
796
+
`Authenticated lycan status fetch failed: ${res.statusText}`
797
+
);
798
+
return (await res.json()) as statuschek;
799
+
}
800
+
return undefined;
801
+
},
802
+
});
803
+
}
804
+
805
+
type statuschek = {
806
+
[key: string]: unknown;
807
+
error?: "MethodNotImplemented";
808
+
message?: "Method Not Implemented";
809
+
status?: "finished" | "in_progress";
810
+
position?: string,
811
+
progress?: number,
812
+
813
+
};
814
+
815
+
//{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268}
816
+
type importtype = {
817
+
message?: "Import has already started" | "Import has been scheduled"
818
+
}
819
+
820
+
export function constructLycanRequestIndexQuery(options: {
821
+
agent?: ATPAPI.Agent;
822
+
isAuthed: boolean;
823
+
pdsUrl?: string;
824
+
feedServiceDid?: string;
825
+
}) {
826
+
const { agent, isAuthed, pdsUrl, feedServiceDid } = options;
827
+
828
+
return queryOptions({
829
+
queryKey: ["lycanIndex", { isAuthed, did: agent?.did }],
830
+
831
+
queryFn: async () => {
832
+
if (isAuthed && agent && pdsUrl && feedServiceDid) {
833
+
const url = `${pdsUrl}/xrpc/blue.feeds.lycan.startImport`;
834
+
const res = await agent.fetchHandler(url, {
835
+
method: "POST",
836
+
headers: {
837
+
"atproto-proxy": `${feedServiceDid}#lycan`,
838
+
"Content-Type": "application/json",
839
+
},
840
+
});
841
+
if (!res.ok)
842
+
throw new Error(
843
+
`Authenticated lycan status fetch failed: ${res.statusText}`
844
+
);
845
+
return await res.json() as importtype;
846
+
}
847
+
return undefined;
848
+
},
849
+
});
850
+
}
851
+
852
+
type LycanSearchPage = {
853
+
terms: string[];
854
+
posts: string[];
855
+
cursor?: string;
856
+
};
857
+
858
+
859
+
export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) {
860
+
861
+
862
+
const [lycanurl] = useAtom(lycanURLAtom);
863
+
const { agent, status } = useAuth();
864
+
const { data: identity } = useQueryIdentity(agent?.did);
865
+
866
+
const { queryKey, queryFn } = constructLycanSearchQuery({
867
+
agent: agent || undefined,
868
+
isAuthed: status === "signedIn",
869
+
pdsUrl: identity?.pds,
870
+
feedServiceDid: "did:web:"+lycanurl,
871
+
query: options.query,
872
+
type: options.type,
873
+
})
874
+
875
+
return {
876
+
...useInfiniteQuery({
877
+
queryKey,
878
+
queryFn,
879
+
initialPageParam: undefined as never,
880
+
getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined,
881
+
//staleTime: Infinity,
882
+
refetchOnWindowFocus: false,
883
+
// enabled:
884
+
// !!options.feedUri &&
885
+
// (options.isAuthed
886
+
// ? ((!!options.agent && !!options.pdsUrl) ||
887
+
// !!options.unauthedfeedurl) &&
888
+
// !!options.feedServiceDid
889
+
// : true),
890
+
}),
891
+
queryKey: queryKey,
892
+
};
893
+
}
894
+
895
+
896
+
export function constructLycanSearchQuery(options: {
897
+
agent?: ATPAPI.Agent;
898
+
isAuthed: boolean;
899
+
pdsUrl?: string;
900
+
feedServiceDid?: string;
901
+
type: "likes" | "pins" | "reposts" | "quotes";
902
+
query: string;
903
+
}) {
904
+
const { agent, isAuthed, pdsUrl, feedServiceDid, type, query } = options;
905
+
906
+
return infiniteQueryOptions({
907
+
queryKey: ["lycanSearch", query, type, { isAuthed, did: agent?.did }],
908
+
909
+
queryFn: async ({
910
+
pageParam,
911
+
}: QueryFunctionContext): Promise<LycanSearchPage | undefined> => {
912
+
if (isAuthed && agent && pdsUrl && feedServiceDid) {
913
+
const url = `${pdsUrl}/xrpc/blue.feeds.lycan.searchPosts?query=${query}&collection=${type}${pageParam ? `&cursor=${pageParam}` : ""}`;
914
+
const res = await agent.fetchHandler(url, {
915
+
method: "GET",
916
+
headers: {
917
+
"atproto-proxy": `${feedServiceDid}#lycan`,
918
+
"Content-Type": "application/json",
919
+
},
920
+
});
921
+
if (!res.ok)
922
+
throw new Error(
923
+
`Authenticated lycan status fetch failed: ${res.statusText}`
924
+
);
925
+
return (await res.json()) as LycanSearchPage;
926
+
}
927
+
return undefined;
928
+
},
929
+
initialPageParam: undefined as never,
930
+
getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined,
931
+
});
932
+
}
+5
vite.config.ts
+5
vite.config.ts
···
13
13
const PROD_URL = "https://reddwarf.app"
14
14
const DEV_URL = "https://local3768forumtest.whey.party"
15
15
16
+
const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party"
17
+
const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social"
18
+
16
19
function shp(url: string): string {
17
20
return url.replace(/^https?:\/\//, '');
18
21
}
···
23
26
generateMetadataPlugin({
24
27
prod: PROD_URL,
25
28
dev: DEV_URL,
29
+
prodResolver: PROD_HANDLE_RESOLVER_PDS,
30
+
devResolver: DEV_HANDLE_RESOLVER_PDS,
26
31
}),
27
32
TanStackRouterVite({ autoCodeSplitting: true }),
28
33
viteReact({