+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
}
+181
-61
src/components/UniversalPostRenderer.tsx
+181
-61
src/components/UniversalPostRenderer.tsx
···
1
+
import * as ATPAPI from "@atproto/api";
1
2
import { useNavigate } from "@tanstack/react-router";
2
3
import DOMPurify from "dompurify";
3
4
import { useAtom } from "jotai";
···
9
10
import {
10
11
composerAtom,
11
12
constellationURLAtom,
13
+
enableBridgyTextAtom,
14
+
enableWafrnTextAtom,
12
15
imgCDNAtom,
13
-
likedPostsAtom,
14
16
} from "~/utils/atoms";
15
17
import { useHydratedEmbed } from "~/utils/useHydrated";
16
18
import {
···
38
40
feedviewpost?: boolean;
39
41
repostedby?: string;
40
42
style?: React.CSSProperties;
41
-
ref?: React.Ref<HTMLDivElement>;
43
+
ref?: React.RefObject<HTMLDivElement>;
42
44
dataIndexPropPass?: number;
43
45
nopics?: boolean;
44
46
concise?: boolean;
45
47
lightboxCallback?: (d: LightboxProps) => void;
46
48
maxReplies?: number;
47
49
isQuote?: boolean;
50
+
filterNoReplies?: boolean;
51
+
filterMustHaveMedia?: boolean;
52
+
filterMustBeReply?: boolean;
48
53
}
49
54
50
55
// export async function cachedGetRecord({
···
157
162
lightboxCallback,
158
163
maxReplies,
159
164
isQuote,
165
+
filterNoReplies,
166
+
filterMustHaveMedia,
167
+
filterMustBeReply,
160
168
}: UniversalPostRendererATURILoaderProps) {
161
169
// todo remove this once tree rendering is implemented, use a prop like isTree
162
170
const TEMPLINEAR = true;
···
520
528
? true
521
529
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
522
530
? false
523
-
: (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine
531
+
: maxReplies === 0 && (!replies || (!!replies && replies === 0))
532
+
? false
533
+
: bottomReplyLine
524
534
}
525
535
topReplyLine={topReplyLine}
526
536
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
542
552
lightboxCallback={lightboxCallback}
543
553
maxReplies={maxReplies}
544
554
isQuote={isQuote}
555
+
filterNoReplies={filterNoReplies}
556
+
filterMustHaveMedia={filterMustHaveMedia}
557
+
filterMustBeReply={filterMustBeReply}
545
558
/>
546
559
<>
547
-
{(maxReplies && maxReplies === 0 && replies && replies > 0) ? (
560
+
{maxReplies && maxReplies === 0 && replies && replies > 0 ? (
548
561
<>
549
-
{/* <div>hello</div> */}
550
-
<MoreReplies atUri={atUri} />
562
+
{/* <div>hello</div> */}
563
+
<MoreReplies atUri={atUri} />
551
564
</>
552
-
) : (<></>)}
565
+
) : (
566
+
<></>
567
+
)}
553
568
</>
554
569
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
555
570
<>
···
644
659
lightboxCallback,
645
660
maxReplies,
646
661
isQuote,
662
+
filterNoReplies,
663
+
filterMustHaveMedia,
664
+
filterMustBeReply,
647
665
}: {
648
666
postRecord: any;
649
667
profileRecord: any;
···
659
677
feedviewpost?: boolean;
660
678
repostedby?: string;
661
679
style?: React.CSSProperties;
662
-
ref?: React.Ref<HTMLDivElement>;
680
+
ref?: React.RefObject<HTMLDivElement>;
663
681
dataIndexPropPass?: number;
664
682
nopics?: boolean;
665
683
concise?: boolean;
666
684
lightboxCallback?: (d: LightboxProps) => void;
667
685
maxReplies?: number;
668
686
isQuote?: boolean;
687
+
filterNoReplies?: boolean;
688
+
filterMustHaveMedia?: boolean;
689
+
filterMustBeReply?: boolean;
669
690
}) {
670
691
// /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`);
671
692
const navigate = useNavigate();
···
736
757
// run();
737
758
// }, [postRecord, resolved?.did]);
738
759
760
+
const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed;
761
+
const hasImages = hasEmbed?.$type === "app.bsky.embed.images";
762
+
const hasVideo = hasEmbed?.$type === "app.bsky.embed.video";
763
+
const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia";
764
+
const isQuotewithImages =
765
+
isquotewithmedia &&
766
+
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
767
+
"app.bsky.embed.images";
768
+
const isQuotewithVideo =
769
+
isquotewithmedia &&
770
+
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
771
+
"app.bsky.embed.video";
772
+
773
+
const hasMedia =
774
+
hasEmbed &&
775
+
(hasImages || hasVideo || isQuotewithImages || isQuotewithVideo);
776
+
739
777
const {
740
778
data: hydratedEmbed,
741
779
isLoading: isEmbedLoading,
···
830
868
// }, [fakepost, get, set]);
831
869
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
832
870
?.uri;
833
-
const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
871
+
const feedviewpostreplydid =
872
+
thereply && !filterNoReplies ? new AtUri(thereply).host : undefined;
834
873
const replyhookvalue = useQueryIdentity(
835
874
feedviewpost ? feedviewpostreplydid : undefined
836
875
);
···
841
880
repostedby ? aturirepostbydid : undefined
842
881
);
843
882
const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle;
883
+
884
+
if (filterNoReplies && thereply) return null;
885
+
886
+
if (filterMustHaveMedia && !hasMedia) return null;
887
+
888
+
if (filterMustBeReply && !thereply) return null;
889
+
844
890
return (
845
891
<>
846
892
{/* <p>
847
893
{postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)}
848
894
</p> */}
895
+
{/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span>
896
+
<span>thereply is {thereply ? "true" : "false"}</span> */}
849
897
<UniversalPostRenderer
850
898
expanded={detailed}
851
899
onPostClick={() =>
···
1204
1252
1205
1253
import defaultpfp from "~/../public/favicon.png";
1206
1254
import { useAuth } from "~/providers/UnifiedAuthProvider";
1207
-
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";
1208
1261
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1262
+
import { useFastLike } from "~/utils/likeMutationQueue";
1209
1263
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1210
1264
// import type {
1211
1265
// ViewRecord,
···
1358
1412
depth?: number;
1359
1413
repostedby?: string;
1360
1414
style?: React.CSSProperties;
1361
-
ref?: React.Ref<HTMLDivElement>;
1415
+
ref?: React.RefObject<HTMLDivElement>;
1362
1416
dataIndexPropPass?: number;
1363
1417
nopics?: boolean;
1364
1418
concise?: boolean;
···
1367
1421
}) {
1368
1422
const parsed = new AtUri(post.uri);
1369
1423
const navigate = useNavigate();
1370
-
const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom);
1371
1424
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
1372
1425
post.viewer?.repost ? true : false
1373
-
);
1374
-
const [hasLiked, setHasLiked] = useState<boolean>(
1375
-
post.uri in likedPosts || post.viewer?.like ? true : false
1376
1426
);
1377
1427
const [, setComposerPost] = useAtom(composerAtom);
1378
1428
const { agent } = useAuth();
1379
-
const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like);
1380
1429
const [retweetUri, setRetweetUri] = useState<string | undefined>(
1381
1430
post.viewer?.repost
1382
1431
);
1383
-
1384
-
const likeOrUnlikePost = async () => {
1385
-
const newLikedPosts = { ...likedPosts };
1386
-
if (!agent) {
1387
-
console.error("Agent is null or undefined");
1388
-
return;
1389
-
}
1390
-
if (hasLiked) {
1391
-
if (post.uri in likedPosts) {
1392
-
const likeUri = likedPosts[post.uri];
1393
-
setLikeUri(likeUri);
1394
-
}
1395
-
if (likeUri) {
1396
-
await agent.deleteLike(likeUri);
1397
-
setHasLiked(false);
1398
-
delete newLikedPosts[post.uri];
1399
-
}
1400
-
} else {
1401
-
const { uri } = await agent.like(post.uri, post.cid);
1402
-
setLikeUri(uri);
1403
-
setHasLiked(true);
1404
-
newLikedPosts[post.uri] = uri;
1405
-
}
1406
-
setLikedPosts(newLikedPosts);
1407
-
};
1432
+
const { liked, toggle, backfill } = useFastLike(post.uri, post.cid);
1433
+
// const bovref = useBackfillOnView(post.uri, post.cid);
1434
+
// React.useLayoutEffect(()=>{
1435
+
// if (expanded && !isQuote) {
1436
+
// backfill();
1437
+
// }
1438
+
// },[backfill, expanded, isQuote])
1408
1439
1409
1440
const repostOrUnrepostPost = async () => {
1410
1441
if (!agent) {
···
1435
1466
: undefined;
1436
1467
1437
1468
const emergencySalt = randomString();
1438
-
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 })
1439
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);
1440
1507
1441
1508
/* fuck you */
1442
1509
const isMainItem = false;
1443
1510
const setMainItem = (any: any) => {};
1444
1511
// eslint-disable-next-line react-hooks/refs
1445
-
console.log("Received ref in UniversalPostRenderer:", ref);
1512
+
//console.log("Received ref in UniversalPostRenderer:", usedref);
1446
1513
return (
1447
1514
<div ref={ref} style={style} data-index={dataIndexPropPass}>
1448
1515
<div
···
1575
1642
{post.author.displayName || post.author.handle}{" "}
1576
1643
</div>
1577
1644
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1578
-
<Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "}
1645
+
<Mutual targetdidorhandle={post.author.did} />@
1646
+
{post.author.handle}{" "}
1579
1647
</div>
1580
1648
</div>
1581
1649
{uprrrsauthor?.description && (
···
1823
1891
</div>
1824
1892
</>
1825
1893
)}
1826
-
<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
+
>
1827
1899
<>
1828
1900
{expanded && (
1829
1901
<div
···
1919
1991
</DropdownMenu.Root>
1920
1992
<HitSlopButton
1921
1993
onClick={() => {
1922
-
likeOrUnlikePost();
1994
+
toggle();
1923
1995
}}
1924
1996
style={{
1925
1997
...btnstyle,
1926
-
...(hasLiked ? { color: "#EC4899" } : {}),
1998
+
...(liked ? { color: "#EC4899" } : {}),
1927
1999
}}
1928
2000
>
1929
-
{hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
1930
-
{(post.likeCount || 0) + (hasLiked ? 1 : 0)}
2001
+
{liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
2002
+
{(post.likeCount || 0) + (liked ? 1 : 0)}
1931
2003
</HitSlopButton>
1932
2004
<div style={{ display: "flex", gap: 8 }}>
1933
2005
<HitSlopButton
···
1941
2013
"/post/" +
1942
2014
post.uri.split("/").pop()
1943
2015
);
2016
+
renderSnack({
2017
+
title: "Copied to clipboard!",
2018
+
});
1944
2019
} catch (_e) {
1945
2020
// idk
2021
+
renderSnack({
2022
+
title: "Failed to copy link",
2023
+
});
1946
2024
}
1947
2025
}}
1948
2026
style={{
···
1951
2029
>
1952
2030
<MdiShareVariant />
1953
2031
</HitSlopButton>
1954
-
<span style={btnstyle}>
1955
-
<MdiMoreHoriz />
1956
-
</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>
1957
2043
</div>
1958
2044
</div>
1959
2045
)}
···
2192
2278
// <MaybeFeedCard view={embed.record} />
2193
2279
// </div>
2194
2280
// )
2195
-
} else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.feed.generator") {
2196
-
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
+
);
2197
2291
}
2198
2292
2199
2293
// list embed
···
2205
2299
// <MaybeListCard view={embed.record} />
2206
2300
// </div>
2207
2301
// )
2208
-
} else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.graph.list") {
2209
-
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
+
);
2210
2317
}
2211
2318
2212
2319
// starter pack embed
···
2218
2325
// <StarterPackCard starterPack={embed.record} />
2219
2326
// </div>
2220
2327
// )
2221
-
} else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.graph.starterpack") {
2222
-
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
+
);
2223
2343
}
2224
2344
2225
2345
// quote post
···
2713
2833
className="link"
2714
2834
style={{
2715
2835
textDecoration: "none",
2716
-
color: "rgb(29, 122, 242)",
2836
+
color: "var(--link-text-color)",
2717
2837
wordBreak: "break-all",
2718
2838
}}
2719
2839
target="_blank"
···
2733
2853
result.push(
2734
2854
<span
2735
2855
key={start}
2736
-
style={{ color: "rgb(29, 122, 242)" }}
2856
+
style={{ color: "var(--link-text-color)" }}
2737
2857
className=" cursor-pointer"
2738
2858
onClick={(e) => {
2739
2859
e.stopPropagation();
···
2751
2871
result.push(
2752
2872
<span
2753
2873
key={start}
2754
-
style={{ color: "rgb(29, 122, 242)" }}
2874
+
style={{ color: "var(--link-text-color)" }}
2755
2875
onClick={(e) => {
2756
2876
e.stopPropagation();
2757
2877
}}
+163
src/providers/LikeMutationQueueProvider.tsx
+163
src/providers/LikeMutationQueueProvider.tsx
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { TID } from "@atproto/common-web";
3
+
import { useQueryClient } from "@tanstack/react-query";
4
+
import { useAtom } from "jotai";
5
+
import React, { createContext, use, useCallback, useEffect, useRef } from "react";
6
+
7
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
8
+
import { renderSnack } from "~/routes/__root";
9
+
import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms";
10
+
import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery";
11
+
12
+
export type LikeRecord = { uri: string; target: string; cid: string };
13
+
export type LikeMutation = { type: 'like'; target: string; cid: string };
14
+
export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord };
15
+
export type Mutation = LikeMutation | UnlikeMutation;
16
+
17
+
interface LikeMutationQueueContextType {
18
+
fastState: (target: string) => LikeRecord | null | undefined;
19
+
fastToggle: (target:string, cid:string) => void;
20
+
backfillState: (target: string, user: string) => Promise<void>;
21
+
}
22
+
23
+
const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined);
24
+
25
+
export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) {
26
+
const { agent } = useAuth();
27
+
const queryClient = useQueryClient();
28
+
const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom);
29
+
const [constellationurl] = useAtom(constellationURLAtom);
30
+
31
+
const likedPostsRef = useRef(likedPosts);
32
+
useEffect(() => {
33
+
likedPostsRef.current = likedPosts;
34
+
}, [likedPosts]);
35
+
36
+
const queueRef = useRef<Mutation[]>([]);
37
+
const runningRef = useRef(false);
38
+
39
+
const fastState = (target: string) => likedPosts[target];
40
+
41
+
const setFastState = useCallback(
42
+
(target: string, record: LikeRecord | null) =>
43
+
setLikedPosts((prev) => ({ ...prev, [target]: record })),
44
+
[setLikedPosts]
45
+
);
46
+
47
+
const enqueue = (mutation: Mutation) => queueRef.current.push(mutation);
48
+
49
+
const fastToggle = useCallback((target: string, cid: string) => {
50
+
const likedRecord = likedPostsRef.current[target];
51
+
52
+
if (likedRecord) {
53
+
setFastState(target, null);
54
+
if (likedRecord.uri !== 'pending') {
55
+
enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord });
56
+
}
57
+
} else {
58
+
setFastState(target, { uri: "pending", target, cid });
59
+
enqueue({ type: "like", target, cid });
60
+
}
61
+
}, [setFastState]);
62
+
63
+
/**
64
+
*
65
+
* @deprecated dont use it yet, will cause infinite rerenders
66
+
*/
67
+
const backfillState = async (target: string, user: string) => {
68
+
const query = constructConstellationQuery({
69
+
constellation: constellationurl,
70
+
method: "/links",
71
+
target,
72
+
collection: "app.bsky.feed.like",
73
+
path: ".subject.uri",
74
+
dids: [user],
75
+
});
76
+
const data = await queryClient.fetchQuery(query);
77
+
const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? [];
78
+
const found = likes.find((r) => r.did === user);
79
+
if (found) {
80
+
const uri = `at://${found.did}/${found.collection}/${found.rkey}`;
81
+
const ciddata = await queryClient.fetchQuery(
82
+
constructArbitraryQuery(uri)
83
+
);
84
+
if (ciddata?.cid)
85
+
setFastState(target, { uri, target, cid: ciddata?.cid });
86
+
} else {
87
+
setFastState(target, null);
88
+
}
89
+
};
90
+
91
+
92
+
useEffect(() => {
93
+
if (!agent?.did) return;
94
+
95
+
const processQueue = async () => {
96
+
if (runningRef.current || queueRef.current.length === 0) return;
97
+
runningRef.current = true;
98
+
99
+
while (queueRef.current.length > 0) {
100
+
const mutation = queueRef.current.shift()!;
101
+
try {
102
+
if (mutation.type === "like") {
103
+
const newRecord = {
104
+
repo: agent.did!,
105
+
collection: "app.bsky.feed.like",
106
+
rkey: TID.next().toString(),
107
+
record: {
108
+
$type: "app.bsky.feed.like",
109
+
subject: { uri: mutation.target, cid: mutation.cid },
110
+
createdAt: new Date().toISOString(),
111
+
},
112
+
};
113
+
const response = await agent.com.atproto.repo.createRecord(newRecord);
114
+
if (!response.success) throw new Error("createRecord failed");
115
+
116
+
const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`;
117
+
setFastState(mutation.target, {
118
+
uri,
119
+
target: mutation.target,
120
+
cid: mutation.cid,
121
+
});
122
+
} else if (mutation.type === "unlike") {
123
+
const aturi = new AtUri(mutation.likeRecordUri);
124
+
await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey });
125
+
setFastState(mutation.target, null);
126
+
}
127
+
} catch (err) {
128
+
console.error("Like mutation failed, reverting:", err);
129
+
renderSnack({
130
+
title: 'Like Mutation Failed',
131
+
description: 'Please try again.',
132
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
133
+
})
134
+
if (mutation.type === 'like') {
135
+
setFastState(mutation.target, null);
136
+
} else if (mutation.type === 'unlike') {
137
+
setFastState(mutation.target, mutation.originalRecord);
138
+
}
139
+
}
140
+
}
141
+
runningRef.current = false;
142
+
};
143
+
144
+
const interval = setInterval(processQueue, 1000);
145
+
return () => clearInterval(interval);
146
+
}, [agent, setFastState]);
147
+
148
+
const value = { fastState, fastToggle, backfillState };
149
+
150
+
return (
151
+
<LikeMutationQueueContext value={value}>
152
+
{children}
153
+
</LikeMutationQueueContext>
154
+
);
155
+
}
156
+
157
+
export function useLikeMutationQueue() {
158
+
const context = use(LikeMutationQueueContext);
159
+
if (context === undefined) {
160
+
throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider');
161
+
}
162
+
return context;
163
+
}
+63
src/routeTree.gen.ts
+63
src/routeTree.gen.ts
···
12
12
import { Route as SettingsRouteImport } from './routes/settings'
13
13
import { Route as SearchRouteImport } from './routes/search'
14
14
import { Route as NotificationsRouteImport } from './routes/notifications'
15
+
import { Route as ModerationRouteImport } from './routes/moderation'
15
16
import { Route as FeedsRouteImport } from './routes/feeds'
16
17
import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout'
17
18
import { Route as IndexRouteImport } from './routes/index'
18
19
import { Route as CallbackIndexRouteImport } from './routes/callback/index'
19
20
import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout'
20
21
import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index'
22
+
import { Route as ProfileDidFollowsRouteImport } from './routes/profile.$did/follows'
23
+
import { Route as ProfileDidFollowersRouteImport } from './routes/profile.$did/followers'
21
24
import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b'
22
25
import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a'
23
26
import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey'
···
40
43
const NotificationsRoute = NotificationsRouteImport.update({
41
44
id: '/notifications',
42
45
path: '/notifications',
46
+
getParentRoute: () => rootRouteImport,
47
+
} as any)
48
+
const ModerationRoute = ModerationRouteImport.update({
49
+
id: '/moderation',
50
+
path: '/moderation',
43
51
getParentRoute: () => rootRouteImport,
44
52
} as any)
45
53
const FeedsRoute = FeedsRouteImport.update({
···
71
79
path: '/profile/$did/',
72
80
getParentRoute: () => rootRouteImport,
73
81
} as any)
82
+
const ProfileDidFollowsRoute = ProfileDidFollowsRouteImport.update({
83
+
id: '/profile/$did/follows',
84
+
path: '/profile/$did/follows',
85
+
getParentRoute: () => rootRouteImport,
86
+
} as any)
87
+
const ProfileDidFollowersRoute = ProfileDidFollowersRouteImport.update({
88
+
id: '/profile/$did/followers',
89
+
path: '/profile/$did/followers',
90
+
getParentRoute: () => rootRouteImport,
91
+
} as any)
74
92
const PathlessLayoutNestedLayoutRouteBRoute =
75
93
PathlessLayoutNestedLayoutRouteBRouteImport.update({
76
94
id: '/route-b',
···
121
139
export interface FileRoutesByFullPath {
122
140
'/': typeof IndexRoute
123
141
'/feeds': typeof FeedsRoute
142
+
'/moderation': typeof ModerationRoute
124
143
'/notifications': typeof NotificationsRoute
125
144
'/search': typeof SearchRoute
126
145
'/settings': typeof SettingsRoute
127
146
'/callback': typeof CallbackIndexRoute
128
147
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
129
148
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
149
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
150
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
130
151
'/profile/$did': typeof ProfileDidIndexRoute
131
152
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
132
153
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
···
138
159
export interface FileRoutesByTo {
139
160
'/': typeof IndexRoute
140
161
'/feeds': typeof FeedsRoute
162
+
'/moderation': typeof ModerationRoute
141
163
'/notifications': typeof NotificationsRoute
142
164
'/search': typeof SearchRoute
143
165
'/settings': typeof SettingsRoute
144
166
'/callback': typeof CallbackIndexRoute
145
167
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
146
168
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
169
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
170
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
147
171
'/profile/$did': typeof ProfileDidIndexRoute
148
172
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
149
173
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
···
157
181
'/': typeof IndexRoute
158
182
'/_pathlessLayout': typeof PathlessLayoutRouteWithChildren
159
183
'/feeds': typeof FeedsRoute
184
+
'/moderation': typeof ModerationRoute
160
185
'/notifications': typeof NotificationsRoute
161
186
'/search': typeof SearchRoute
162
187
'/settings': typeof SettingsRoute
···
164
189
'/callback/': typeof CallbackIndexRoute
165
190
'/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
166
191
'/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
192
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
193
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
167
194
'/profile/$did/': typeof ProfileDidIndexRoute
168
195
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
169
196
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
···
177
204
fullPaths:
178
205
| '/'
179
206
| '/feeds'
207
+
| '/moderation'
180
208
| '/notifications'
181
209
| '/search'
182
210
| '/settings'
183
211
| '/callback'
184
212
| '/route-a'
185
213
| '/route-b'
214
+
| '/profile/$did/followers'
215
+
| '/profile/$did/follows'
186
216
| '/profile/$did'
187
217
| '/profile/$did/feed/$rkey'
188
218
| '/profile/$did/post/$rkey'
···
194
224
to:
195
225
| '/'
196
226
| '/feeds'
227
+
| '/moderation'
197
228
| '/notifications'
198
229
| '/search'
199
230
| '/settings'
200
231
| '/callback'
201
232
| '/route-a'
202
233
| '/route-b'
234
+
| '/profile/$did/followers'
235
+
| '/profile/$did/follows'
203
236
| '/profile/$did'
204
237
| '/profile/$did/feed/$rkey'
205
238
| '/profile/$did/post/$rkey'
···
212
245
| '/'
213
246
| '/_pathlessLayout'
214
247
| '/feeds'
248
+
| '/moderation'
215
249
| '/notifications'
216
250
| '/search'
217
251
| '/settings'
···
219
253
| '/callback/'
220
254
| '/_pathlessLayout/_nested-layout/route-a'
221
255
| '/_pathlessLayout/_nested-layout/route-b'
256
+
| '/profile/$did/followers'
257
+
| '/profile/$did/follows'
222
258
| '/profile/$did/'
223
259
| '/profile/$did/feed/$rkey'
224
260
| '/profile/$did/post/$rkey'
···
232
268
IndexRoute: typeof IndexRoute
233
269
PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren
234
270
FeedsRoute: typeof FeedsRoute
271
+
ModerationRoute: typeof ModerationRoute
235
272
NotificationsRoute: typeof NotificationsRoute
236
273
SearchRoute: typeof SearchRoute
237
274
SettingsRoute: typeof SettingsRoute
238
275
CallbackIndexRoute: typeof CallbackIndexRoute
276
+
ProfileDidFollowersRoute: typeof ProfileDidFollowersRoute
277
+
ProfileDidFollowsRoute: typeof ProfileDidFollowsRoute
239
278
ProfileDidIndexRoute: typeof ProfileDidIndexRoute
240
279
ProfileDidFeedRkeyRoute: typeof ProfileDidFeedRkeyRoute
241
280
ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren
···
264
303
preLoaderRoute: typeof NotificationsRouteImport
265
304
parentRoute: typeof rootRouteImport
266
305
}
306
+
'/moderation': {
307
+
id: '/moderation'
308
+
path: '/moderation'
309
+
fullPath: '/moderation'
310
+
preLoaderRoute: typeof ModerationRouteImport
311
+
parentRoute: typeof rootRouteImport
312
+
}
267
313
'/feeds': {
268
314
id: '/feeds'
269
315
path: '/feeds'
···
306
352
preLoaderRoute: typeof ProfileDidIndexRouteImport
307
353
parentRoute: typeof rootRouteImport
308
354
}
355
+
'/profile/$did/follows': {
356
+
id: '/profile/$did/follows'
357
+
path: '/profile/$did/follows'
358
+
fullPath: '/profile/$did/follows'
359
+
preLoaderRoute: typeof ProfileDidFollowsRouteImport
360
+
parentRoute: typeof rootRouteImport
361
+
}
362
+
'/profile/$did/followers': {
363
+
id: '/profile/$did/followers'
364
+
path: '/profile/$did/followers'
365
+
fullPath: '/profile/$did/followers'
366
+
preLoaderRoute: typeof ProfileDidFollowersRouteImport
367
+
parentRoute: typeof rootRouteImport
368
+
}
309
369
'/_pathlessLayout/_nested-layout/route-b': {
310
370
id: '/_pathlessLayout/_nested-layout/route-b'
311
371
path: '/route-b'
···
416
476
IndexRoute: IndexRoute,
417
477
PathlessLayoutRoute: PathlessLayoutRouteWithChildren,
418
478
FeedsRoute: FeedsRoute,
479
+
ModerationRoute: ModerationRoute,
419
480
NotificationsRoute: NotificationsRoute,
420
481
SearchRoute: SearchRoute,
421
482
SettingsRoute: SettingsRoute,
422
483
CallbackIndexRoute: CallbackIndexRoute,
484
+
ProfileDidFollowersRoute: ProfileDidFollowersRoute,
485
+
ProfileDidFollowsRoute: ProfileDidFollowsRoute,
423
486
ProfileDidIndexRoute: ProfileDidIndexRoute,
424
487
ProfileDidFeedRkeyRoute: ProfileDidFeedRkeyRoute,
425
488
ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren,
+178
-16
src/routes/__root.tsx
+178
-16
src/routes/__root.tsx
···
14
14
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
15
15
import { useAtom } from "jotai";
16
16
import * as React from "react";
17
+
import { toast as sonnerToast } from "sonner";
18
+
import { Toaster } from "sonner";
17
19
import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive";
18
20
19
21
import { Composer } from "~/components/Composer";
···
22
24
import Login from "~/components/Login";
23
25
import { NotFound } from "~/components/NotFound";
24
26
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
27
+
import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider";
25
28
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
26
29
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
27
30
import { seo } from "~/utils/seo";
···
79
82
function RootComponent() {
80
83
return (
81
84
<UnifiedAuthProvider>
82
-
<RootDocument>
83
-
<KeepAliveProvider>
84
-
<KeepAliveOutlet />
85
-
</KeepAliveProvider>
86
-
</RootDocument>
85
+
<LikeMutationQueueProvider>
86
+
<RootDocument>
87
+
<KeepAliveProvider>
88
+
<AppToaster />
89
+
<KeepAliveOutlet />
90
+
</KeepAliveProvider>
91
+
</RootDocument>
92
+
</LikeMutationQueueProvider>
87
93
</UnifiedAuthProvider>
88
94
);
89
95
}
90
96
97
+
export function AppToaster() {
98
+
return (
99
+
<Toaster
100
+
position="bottom-center"
101
+
toastOptions={{
102
+
duration: 4000,
103
+
}}
104
+
/>
105
+
);
106
+
}
107
+
108
+
export function renderSnack({
109
+
title,
110
+
description,
111
+
button,
112
+
}: Omit<ToastProps, "id">) {
113
+
return sonnerToast.custom((id) => (
114
+
<Snack
115
+
id={id}
116
+
title={title}
117
+
description={description}
118
+
button={
119
+
button?.label
120
+
? {
121
+
label: button?.label,
122
+
onClick: () => {
123
+
button?.onClick?.();
124
+
},
125
+
}
126
+
: undefined
127
+
}
128
+
/>
129
+
));
130
+
}
131
+
132
+
function Snack(props: ToastProps) {
133
+
const { title, description, button, id } = props;
134
+
135
+
return (
136
+
<div
137
+
role="status"
138
+
aria-live="polite"
139
+
className="
140
+
w-full md:max-w-[520px]
141
+
flex items-center justify-between
142
+
rounded-md
143
+
px-4 py-3
144
+
shadow-sm
145
+
dark:bg-gray-300 dark:text-gray-900
146
+
bg-gray-700 text-gray-100
147
+
ring-1 dark:ring-gray-200 ring-gray-800
148
+
"
149
+
>
150
+
<div className="flex-1 min-w-0">
151
+
<p className="text-sm font-medium truncate">{title}</p>
152
+
{description ? (
153
+
<p className="mt-1 text-sm dark:text-gray-600 text-gray-300 truncate">
154
+
{description}
155
+
</p>
156
+
) : null}
157
+
</div>
158
+
159
+
{button ? (
160
+
<div className="ml-4 flex-shrink-0">
161
+
<button
162
+
className="
163
+
text-sm font-medium
164
+
px-3 py-1 rounded-md
165
+
bg-gray-200 text-gray-900
166
+
hover:bg-gray-300
167
+
dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700
168
+
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-300 dark:focus:ring-gray-700
169
+
"
170
+
onClick={() => {
171
+
button.onClick();
172
+
sonnerToast.dismiss(id);
173
+
}}
174
+
>
175
+
{button.label}
176
+
</button>
177
+
</div>
178
+
) : null}
179
+
<button className=" ml-4"
180
+
onClick={() => {
181
+
sonnerToast.dismiss(id);
182
+
}}
183
+
>
184
+
<IconMdiClose />
185
+
</button>
186
+
</div>
187
+
);
188
+
}
189
+
190
+
/* Types */
191
+
interface ToastProps {
192
+
id: string | number;
193
+
title: string;
194
+
description?: string;
195
+
button?: {
196
+
label: string;
197
+
onClick: () => void;
198
+
};
199
+
}
200
+
91
201
function RootDocument({ children }: { children: React.ReactNode }) {
92
202
useAtomCssVar(hueAtom, "--tw-gray-hue");
93
203
const location = useLocation();
···
103
213
const isSettings = location.pathname.startsWith("/settings");
104
214
const isSearch = location.pathname.startsWith("/search");
105
215
const isFeeds = location.pathname.startsWith("/feeds");
216
+
const isModeration = location.pathname.startsWith("/moderation");
106
217
107
218
const locationEnum:
108
219
| "feeds"
···
110
221
| "settings"
111
222
| "notifications"
112
223
| "profile"
224
+
| "moderation"
113
225
| "home" = isFeeds
114
226
? "feeds"
115
227
: isSearch
···
120
232
? "notifications"
121
233
: isProfile
122
234
? "profile"
123
-
: "home";
235
+
: isModeration
236
+
? "moderation"
237
+
: "home";
124
238
125
239
const [, setComposerPost] = useAtom(composerAtom);
126
240
···
131
245
<div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950">
132
246
<nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start">
133
247
<div className="flex items-center gap-3 mb-4">
134
-
<FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
248
+
<FluentEmojiHighContrastGlowingStar
249
+
className="h-8 w-8"
250
+
style={{
251
+
color:
252
+
"oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))",
253
+
}}
254
+
/>
135
255
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
136
256
Red Dwarf{" "}
137
257
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
193
313
})
194
314
}
195
315
text="Feeds"
316
+
/>
317
+
<MaterialNavItem
318
+
InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />}
319
+
ActiveIcon={<IconMdiShield className="w-6 h-6" />}
320
+
active={locationEnum === "moderation"}
321
+
onClickCallbback={() =>
322
+
navigate({
323
+
to: "/moderation",
324
+
//params: { did: agent.assertDid },
325
+
})
326
+
}
327
+
text="Moderation"
196
328
/>
197
329
<MaterialNavItem
198
330
InactiveIcon={
···
232
364
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
233
365
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
234
366
//active={true}
235
-
onClickCallbback={() => setComposerPost({ kind: 'root' })}
367
+
onClickCallbback={() => setComposerPost({ kind: "root" })}
236
368
text="Post"
237
369
/>
238
370
</div>
···
370
502
371
503
<nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start">
372
504
<div className="flex items-center gap-3 mb-4">
373
-
<FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
505
+
<FluentEmojiHighContrastGlowingStar
506
+
className="h-8 w-8"
507
+
style={{
508
+
color:
509
+
"oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))",
510
+
}}
511
+
/>
374
512
</div>
375
513
<MaterialNavItem
376
514
small
···
433
571
/>
434
572
<MaterialNavItem
435
573
small
574
+
InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />}
575
+
ActiveIcon={<IconMdiShield className="w-6 h-6" />}
576
+
active={locationEnum === "moderation"}
577
+
onClickCallbback={() =>
578
+
navigate({
579
+
to: "/moderation",
580
+
//params: { did: agent.assertDid },
581
+
})
582
+
}
583
+
text="Moderation"
584
+
/>
585
+
<MaterialNavItem
586
+
small
436
587
InactiveIcon={
437
588
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
438
589
}
···
472
623
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
473
624
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
474
625
//active={true}
475
-
onClickCallbback={() => setComposerPost({ kind: 'root' })}
626
+
onClickCallbback={() => setComposerPost({ kind: "root" })}
476
627
text="Post"
477
628
/>
478
629
</div>
···
482
633
<button
483
634
className="lg:hidden fixed bottom-22 right-4 z-50 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-2xl w-14 h-14 flex items-center justify-center transition-all"
484
635
style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }}
485
-
onClick={() => setComposerPost({ kind: 'root' })}
636
+
onClick={() => setComposerPost({ kind: "root" })}
486
637
type="button"
487
638
aria-label="Create Post"
488
639
>
···
499
650
</main>
500
651
501
652
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
502
-
<div className="px-4 pt-4"><Import /></div>
653
+
<div className="px-4 pt-4">
654
+
<Import />
655
+
</div>
503
656
<Login />
504
657
505
658
<div className="flex-1"></div>
506
659
<p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4">
507
-
Red Dwarf is a Bluesky client that does not rely on any Bluesky API App Servers. Instead, it uses Microcosm to fetch records directly from each users' PDS (via Slingshot) and connect them using backlinks (via Constellation)
660
+
Red Dwarf is a Bluesky client that does not rely on any Bluesky API
661
+
App Servers. Instead, it uses Microcosm to fetch records directly
662
+
from each users' PDS (via Slingshot) and connect them using
663
+
backlinks (via Constellation)
508
664
</p>
509
665
</aside>
510
666
</div>
···
651
807
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
652
808
}
653
809
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
654
-
active={locationEnum === "settings"}
810
+
active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"}
655
811
onClickCallbback={() =>
656
812
navigate({
657
813
to: "/settings",
···
680
836
) : (
681
837
<div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10">
682
838
<div className="flex items-center gap-2">
683
-
<FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
839
+
<FluentEmojiHighContrastGlowingStar
840
+
className="h-6 w-6"
841
+
style={{
842
+
color:
843
+
"oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))",
844
+
}}
845
+
/>
684
846
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
685
847
Red Dwarf{" "}
686
848
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
700
862
);
701
863
}
702
864
703
-
function MaterialNavItem({
865
+
export function MaterialNavItem({
704
866
InactiveIcon,
705
867
ActiveIcon,
706
868
text,
+18
-1
src/routes/feeds.tsx
+18
-1
src/routes/feeds.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
2
3
+
import { Header } from "~/components/Header";
4
+
3
5
export const Route = createFileRoute("/feeds")({
4
6
component: Feeds,
5
7
});
6
8
7
9
export function Feeds() {
8
-
return <div className="p-6">Feeds page (coming soon)</div>;
10
+
return (
11
+
<div className="">
12
+
<Header
13
+
title={`Feeds`}
14
+
backButtonCallback={() => {
15
+
if (window.history.length > 1) {
16
+
window.history.back();
17
+
} else {
18
+
window.location.assign("/");
19
+
}
20
+
}}
21
+
bottomBorderDisabled={true}
22
+
/>
23
+
Feeds page (coming soon)
24
+
</div>
25
+
);
9
26
}
+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
+
}
+86
-6
src/routes/notifications.tsx
+86
-6
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
);
···
132
137
);
133
138
}
134
139
135
-
function FollowsTab() {
140
+
export function FollowsTab({did}:{did?:string}) {
136
141
const { agent } = useAuth();
142
+
const userdidunsafe = did ?? agent?.did;
143
+
const { data: identity} = useQueryIdentity(userdidunsafe);
144
+
const userdid = identity?.did;
145
+
137
146
const [constellationurl] = useAtom(constellationURLAtom);
138
147
const infinitequeryresults = useInfiniteQuery({
139
148
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
140
149
{
141
150
constellation: constellationurl,
142
151
method: "/links",
143
-
target: agent?.did,
152
+
target: userdid,
144
153
collection: "app.bsky.graph.follow",
145
154
path: ".subject",
146
155
}
147
156
),
148
-
enabled: !!agent?.did,
157
+
enabled: !!userdid,
149
158
});
150
159
151
160
const {
···
196
205
);
197
206
}
198
207
208
+
209
+
export function BitesTab({did}:{did?:string}) {
210
+
const { agent } = useAuth();
211
+
const userdidunsafe = did ?? agent?.did;
212
+
const { data: identity} = useQueryIdentity(userdidunsafe);
213
+
const userdid = identity?.did;
214
+
215
+
const [constellationurl] = useAtom(constellationURLAtom);
216
+
const infinitequeryresults = useInfiniteQuery({
217
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
218
+
{
219
+
constellation: constellationurl,
220
+
method: "/links",
221
+
target: "at://"+userdid,
222
+
collection: "net.wafrn.feed.bite",
223
+
path: ".subject",
224
+
staleMult: 0 // safe fun
225
+
}
226
+
),
227
+
enabled: !!userdid,
228
+
});
229
+
230
+
const {
231
+
data: infiniteFollowsData,
232
+
fetchNextPage,
233
+
hasNextPage,
234
+
isFetchingNextPage,
235
+
isLoading,
236
+
isError,
237
+
error,
238
+
} = infinitequeryresults;
239
+
240
+
const followsAturis = React.useMemo(() => {
241
+
// Get all replies from the standard infinite query
242
+
return (
243
+
infiniteFollowsData?.pages.flatMap(
244
+
(page) =>
245
+
page?.linking_records.map(
246
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
247
+
) ?? []
248
+
) ?? []
249
+
);
250
+
}, [infiniteFollowsData]);
251
+
252
+
useReusableTabScrollRestore("Notifications");
253
+
254
+
if (isLoading) return <LoadingState text="Loading bites..." />;
255
+
if (isError) return <ErrorState error={error} />;
256
+
257
+
if (!followsAturis?.length) return <EmptyState text="No bites yet." />;
258
+
259
+
return (
260
+
<>
261
+
{followsAturis.map((m) => (
262
+
<NotificationItem key={m} notification={m} />
263
+
))}
264
+
265
+
{hasNextPage && (
266
+
<button
267
+
onClick={() => fetchNextPage()}
268
+
disabled={isFetchingNextPage}
269
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
270
+
>
271
+
{isFetchingNextPage ? "Loading..." : "Load More"}
272
+
</button>
273
+
)}
274
+
</>
275
+
);
276
+
}
277
+
199
278
function PostInteractionsTab() {
200
279
const { agent } = useAuth();
201
280
const { data: identity } = useQueryIdentity(agent?.did);
···
304
383
);
305
384
}
306
385
307
-
function Chip({
386
+
export function Chip({
308
387
state,
309
388
text,
310
389
onClick,
···
493
572
);
494
573
}
495
574
496
-
export function NotificationItem({ notification }: { notification: string }) {
575
+
export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) {
497
576
const aturi = new AtUri(notification);
577
+
const bite = aturi.collection === "net.wafrn.feed.bite";
498
578
const navigate = useNavigate();
499
579
const { data: identity } = useQueryIdentity(aturi.host);
500
580
const resolvedDid = identity?.did;
···
538
618
<img
539
619
src={avatar || defaultpfp}
540
620
alt={identity?.handle}
541
-
className="w-10 h-10 rounded-full"
621
+
className={`w-10 h-10 ${labeler ? "rounded-md" : "rounded-full"}`}
542
622
/>
543
623
) : (
544
624
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
+1
src/routes/profile.$did/feed.$rkey.tsx
+1
src/routes/profile.$did/feed.$rkey.tsx
+30
src/routes/profile.$did/followers.tsx
+30
src/routes/profile.$did/followers.tsx
···
1
+
import { createFileRoute } from "@tanstack/react-router";
2
+
3
+
import { Header } from "~/components/Header";
4
+
5
+
import { FollowsTab } from "../notifications";
6
+
7
+
export const Route = createFileRoute("/profile/$did/followers")({
8
+
component: RouteComponent,
9
+
});
10
+
11
+
// todo: scroll restoration
12
+
function RouteComponent() {
13
+
const params = Route.useParams();
14
+
15
+
return (
16
+
<div>
17
+
<Header
18
+
title={"Followers"}
19
+
backButtonCallback={() => {
20
+
if (window.history.length > 1) {
21
+
window.history.back();
22
+
} else {
23
+
window.location.assign("/");
24
+
}
25
+
}}
26
+
/>
27
+
<FollowsTab did={params.did} />
28
+
</div>
29
+
);
30
+
}
+79
src/routes/profile.$did/follows.tsx
+79
src/routes/profile.$did/follows.tsx
···
1
+
import * as ATPAPI from "@atproto/api"
2
+
import { createFileRoute } from '@tanstack/react-router'
3
+
import React from 'react';
4
+
5
+
import { Header } from '~/components/Header';
6
+
import { useReusableTabScrollRestore } from '~/components/ReusableTabRoute';
7
+
import { useInfiniteQueryAuthorFeed, useQueryIdentity } from '~/utils/useQuery';
8
+
9
+
import { EmptyState, ErrorState, LoadingState, NotificationItem } from '../notifications';
10
+
11
+
export const Route = createFileRoute('/profile/$did/follows')({
12
+
component: RouteComponent,
13
+
})
14
+
15
+
// todo: scroll restoration
16
+
function RouteComponent() {
17
+
const params = Route.useParams();
18
+
return (
19
+
<div>
20
+
<Header
21
+
title={"Follows"}
22
+
backButtonCallback={() => {
23
+
if (window.history.length > 1) {
24
+
window.history.back();
25
+
} else {
26
+
window.location.assign("/");
27
+
}
28
+
}}
29
+
/>
30
+
<Follows did={params.did}/>
31
+
</div>
32
+
);
33
+
}
34
+
35
+
function Follows({did}:{did:string}) {
36
+
const {data: identity} = useQueryIdentity(did);
37
+
const infinitequeryresults = useInfiniteQueryAuthorFeed(identity?.did, identity?.pds, "app.bsky.graph.follow");
38
+
39
+
const {
40
+
data: infiniteFollowsData,
41
+
fetchNextPage,
42
+
hasNextPage,
43
+
isFetchingNextPage,
44
+
isLoading,
45
+
isError,
46
+
error,
47
+
} = infinitequeryresults;
48
+
49
+
const followsAturis = React.useMemo(
50
+
() => infiniteFollowsData?.pages.flatMap((page) => page.records) ?? [],
51
+
[infiniteFollowsData]
52
+
);
53
+
54
+
useReusableTabScrollRestore("Notifications");
55
+
56
+
if (isLoading) return <LoadingState text="Loading follows..." />;
57
+
if (isError) return <ErrorState error={error} />;
58
+
59
+
if (!followsAturis?.length) return <EmptyState text="No follows yet." />;
60
+
61
+
return (
62
+
<>
63
+
{followsAturis.map((m) => {
64
+
const record = m.value as unknown as ATPAPI.AppBskyGraphFollow.Record;
65
+
return <NotificationItem key={record.subject} notification={record.subject} />
66
+
})}
67
+
68
+
{hasNextPage && (
69
+
<button
70
+
onClick={() => fetchNextPage()}
71
+
disabled={isFetchingNextPage}
72
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
73
+
>
74
+
{isFetchingNextPage ? "Loading..." : "Load More"}
75
+
</button>
76
+
)}
77
+
</>
78
+
);
79
+
}
+374
-35
src/routes/profile.$did/index.tsx
+374
-35
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 } from "~/utils/atoms";
20
+
import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms";
20
21
import {
21
22
toggleFollow,
22
23
useGetFollowState,
23
24
useGetOneToOneState,
24
25
} from "~/utils/followState";
26
+
import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue";
25
27
import {
26
28
useInfiniteQueryAuthorFeed,
27
29
useQueryArbitrary,
28
30
useQueryConstellation,
31
+
useQueryConstellationLinksCountDistinctDids,
29
32
useQueryIdentity,
30
33
useQueryProfile,
31
34
} from "~/utils/useQuery";
35
+
import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx";
36
+
37
+
import { renderSnack } from "../__root";
38
+
import { Chip } from "../notifications";
32
39
33
40
export const Route = createFileRoute("/profile/$did/")({
34
41
component: ProfileComponent,
···
46
53
error: identityError,
47
54
} = useQueryIdentity(did);
48
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
+
49
70
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
50
71
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
51
72
const pdsUrl = identity?.pds;
···
76
97
77
98
const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord;
78
99
100
+
const resultwhateversure = useQueryConstellationLinksCountDistinctDids(
101
+
resolvedDid
102
+
? {
103
+
method: "/links/count/distinct-dids",
104
+
collection: "app.bsky.graph.follow",
105
+
target: resolvedDid,
106
+
path: ".subject",
107
+
}
108
+
: undefined
109
+
);
110
+
111
+
const followercount = resultwhateversure?.data?.total;
112
+
79
113
return (
80
114
<div className="">
81
115
<Header
···
122
156
123
157
{/* Avatar (PFP) */}
124
158
<div className="absolute left-[16px] top-[100px] ">
125
-
<img
126
-
src={getAvatarUrl(profile) || "/favicon.png"}
127
-
alt="avatar"
128
-
className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700"
129
-
/>
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
+
)}
130
172
</div>
131
173
132
174
<div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5">
175
+
<BiteButton targetdidorhandle={did} />
133
176
{/*
134
177
todo: full follow and unfollow backfill (along with partial likes backfill,
135
178
just enough for it to be useful)
···
137
180
also save it persistently
138
181
*/}
139
182
<FollowButton targetdidorhandle={did} />
140
-
<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
+
>
141
193
... {/* todo: icon */}
142
194
</button>
143
195
</div>
···
149
201
<Mutual targetdidorhandle={did} />
150
202
{handle}
151
203
</div>
204
+
<div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2">
205
+
<Link to="/profile/$did/followers" params={{ did: did }}>
206
+
{followercount && (
207
+
<span className="mr-1 text-gray-900 dark:text-gray-200 font-medium">
208
+
{followercount}
209
+
</span>
210
+
)}
211
+
Followers
212
+
</Link>
213
+
-
214
+
<Link to="/profile/$did/follows" params={{ did: did }}>
215
+
Follows
216
+
</Link>
217
+
</div>
152
218
{description && (
153
219
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
154
220
{/* {description} */}
···
163
229
<ReusableTabRoute
164
230
route={`Profile` + did}
165
231
tabs={{
166
-
Posts: <PostsTab did={did} />,
167
-
Reposts: <RepostsTab did={did} />,
168
-
Feeds: <FeedsTab did={did} />,
169
-
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
+
},
170
243
...(identity?.did === agent?.did
171
244
? { Likes: <SelfLikesTab did={did} /> }
172
245
: {}),
···
191
264
);
192
265
}
193
266
267
+
export type ProfilePostsFilter = {
268
+
posts: boolean;
269
+
replies: boolean;
270
+
mediaOnly: boolean;
271
+
};
272
+
export const defaultProfilePostsFilter: ProfilePostsFilter = {
273
+
posts: true,
274
+
replies: true,
275
+
mediaOnly: false,
276
+
};
277
+
278
+
function ProfilePostsFilterChipBar({
279
+
filters,
280
+
toggle,
281
+
}: {
282
+
filters: ProfilePostsFilter | null;
283
+
toggle: (key: keyof ProfilePostsFilter) => void;
284
+
}) {
285
+
const empty = !filters?.replies && !filters?.posts;
286
+
const almostEmpty = !filters?.replies && filters?.posts;
287
+
288
+
useEffect(() => {
289
+
if (empty) {
290
+
toggle("posts");
291
+
}
292
+
}, [empty, toggle]);
293
+
294
+
return (
295
+
<div className="flex flex-row flex-wrap gap-2 px-4 pt-4">
296
+
<Chip
297
+
state={filters?.posts ?? true}
298
+
text="Posts"
299
+
onClick={() => (almostEmpty ? null : toggle("posts"))}
300
+
/>
301
+
<Chip
302
+
state={filters?.replies ?? true}
303
+
text="Replies"
304
+
onClick={() => toggle("replies")}
305
+
/>
306
+
<Chip
307
+
state={filters?.mediaOnly ?? false}
308
+
text="Media Only"
309
+
onClick={() => toggle("mediaOnly")}
310
+
/>
311
+
</div>
312
+
);
313
+
}
314
+
194
315
function PostsTab({ did }: { did: string }) {
316
+
// todo: this needs to be a (non-persisted is fine) atom to survive navigation
317
+
const [filterses, setFilterses] = useAtom(profileChipsAtom);
318
+
const filters = filterses?.[did];
319
+
const setFilters = (obj: ProfilePostsFilter) => {
320
+
setFilterses((prev) => {
321
+
return {
322
+
...prev,
323
+
[did]: obj,
324
+
};
325
+
});
326
+
};
327
+
useEffect(() => {
328
+
if (!filters) {
329
+
setFilters(defaultProfilePostsFilter);
330
+
}
331
+
});
195
332
useReusableTabScrollRestore(`Profile` + did);
196
333
const queryClient = useQueryClient();
197
334
const {
···
227
364
[postsData]
228
365
);
229
366
367
+
const toggle = (key: keyof ProfilePostsFilter) => {
368
+
setFilterses((prev) => {
369
+
const existing = prev[did] ?? {
370
+
posts: false,
371
+
replies: false,
372
+
mediaOnly: false,
373
+
}; // default
374
+
375
+
return {
376
+
...prev,
377
+
[did]: {
378
+
...existing,
379
+
[key]: !existing[key], // safely negate
380
+
},
381
+
};
382
+
});
383
+
};
384
+
230
385
return (
231
386
<>
232
-
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
387
+
{/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
233
388
Posts
234
-
</div>
389
+
</div> */}
390
+
<ProfilePostsFilterChipBar filters={filters} toggle={toggle} />
235
391
<div>
236
392
{posts.map((post) => (
237
393
<UniversalPostRendererATURILoader
238
394
key={post.uri}
239
395
atUri={post.uri}
240
396
feedviewpost={true}
397
+
filterNoReplies={!filters?.replies}
398
+
filterMustHaveMedia={filters?.mediaOnly}
399
+
filterMustBeReply={!filters?.posts}
241
400
/>
242
401
))}
243
402
</div>
···
404
563
);
405
564
}
406
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
+
407
646
export function FeedItemRenderAturiLoader({
408
647
aturi,
409
648
listmode,
···
454
693
}
455
694
456
695
const { data: likes } = useQueryConstellation(
457
-
// @ts-expect-error overloads sucks
696
+
// @ts-expect-error overloads sucks
458
697
!listmode
459
698
? {
460
699
target: feed.uri,
···
470
709
className={`px-4 py-4 ${!disableBottomBorder && "border-b"} flex flex-col gap-1`}
471
710
to="/profile/$did/feed/$rkey"
472
711
params={{ did: aturi.host, rkey: aturi.rkey }}
473
-
onClick={(e)=>{e.stopPropagation();}}
712
+
onClick={(e) => {
713
+
e.stopPropagation();
714
+
}}
474
715
>
475
716
<div className="flex flex-row gap-3">
476
717
<div className="min-w-10 min-h-10">
···
574
815
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
575
816
576
817
const {
577
-
data: repostsData,
818
+
data: likesData,
578
819
fetchNextPage,
579
820
hasNextPage,
580
821
isFetchingNextPage,
···
585
826
"app.bsky.feed.like"
586
827
);
587
828
588
-
const reposts = React.useMemo(
589
-
() => repostsData?.pages.flatMap((page) => page.records) ?? [],
590
-
[repostsData]
829
+
const likes = React.useMemo(
830
+
() => likesData?.pages.flatMap((page) => page.records) ?? [],
831
+
[likesData]
591
832
);
592
833
834
+
const { setFastState } = useFastSetLikesFromFeed();
835
+
const seededRef = React.useRef(new Set<string>());
836
+
837
+
useEffect(() => {
838
+
for (const like of likes) {
839
+
if (!seededRef.current.has(like.uri)) {
840
+
seededRef.current.add(like.uri);
841
+
const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
842
+
setFastState(record.subject.uri, {
843
+
target: record.subject.uri,
844
+
uri: like.uri,
845
+
cid: like.cid,
846
+
});
847
+
}
848
+
}
849
+
}, [likes, setFastState]);
850
+
593
851
return (
594
852
<>
595
853
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
596
854
Likes
597
855
</div>
598
856
<div>
599
-
{reposts.map((repost) => {
857
+
{likes.map((like) => {
600
858
if (
601
-
!repost ||
602
-
!repost?.value ||
603
-
!repost?.value?.subject ||
859
+
!like ||
860
+
!like?.value ||
861
+
!like?.value?.subject ||
604
862
// @ts-expect-error blehhhhh
605
-
!repost?.value?.subject?.uri
863
+
!like?.value?.subject?.uri
606
864
)
607
865
return;
608
-
const repostRecord =
609
-
repost.value as unknown as ATPAPI.AppBskyFeedLike.Record;
866
+
const likeRecord =
867
+
like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
610
868
return (
611
869
<UniversalPostRendererATURILoader
612
-
key={repostRecord.subject.uri}
613
-
atUri={repostRecord.subject.uri}
870
+
key={likeRecord.subject.uri}
871
+
atUri={likeRecord.subject.uri}
614
872
feedviewpost={true}
615
873
/>
616
874
);
···
618
876
</div>
619
877
620
878
{/* Loading and "Load More" states */}
621
-
{arePostsLoading && reposts.length === 0 && (
622
-
<div className="p-4 text-center text-gray-500">Loading posts...</div>
879
+
{arePostsLoading && likes.length === 0 && (
880
+
<div className="p-4 text-center text-gray-500">Loading likes...</div>
623
881
)}
624
882
{isFetchingNextPage && (
625
883
<div className="p-4 text-center text-gray-500">Loading more...</div>
···
629
887
onClick={() => fetchNextPage()}
630
888
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
631
889
>
632
-
Load More Posts
890
+
Load More Likes
633
891
</button>
634
892
)}
635
-
{reposts.length === 0 && !arePostsLoading && (
636
-
<div className="p-4 text-center text-gray-500">No posts found.</div>
893
+
{likes.length === 0 && !arePostsLoading && (
894
+
<div className="p-4 text-center text-gray-500">No likes found.</div>
637
895
)}
638
896
</>
639
897
);
···
690
948
)}
691
949
</>
692
950
) : (
693
-
<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
+
>
694
961
Edit Profile
695
962
</button>
696
963
)}
697
964
</>
698
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
+
}
699
1038
}
700
1039
701
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
}
+40
src/utils/atoms.ts
+40
src/utils/atoms.ts
···
2
2
import { atomWithStorage } from "jotai/utils";
3
3
import { useEffect } from "react";
4
4
5
+
import { type ProfilePostsFilter } from "~/routes/profile.$did";
6
+
5
7
export const store = createStore();
6
8
7
9
export const quickAuthAtom = atomWithStorage<string | null>(
···
59
61
{}
60
62
);
61
63
64
+
export type LikeRecord = {
65
+
uri: string; // at://did/collection/rkey
66
+
target: string;
67
+
cid: string;
68
+
};
69
+
70
+
export const internalLikedPostsAtom = atomWithStorage<Record<string, LikeRecord | null>>(
71
+
"internal-liked-posts",
72
+
{}
73
+
);
74
+
75
+
export const profileChipsAtom = atom<Record<string, ProfilePostsFilter | null>>({})
76
+
62
77
export const defaultconstellationURL = "constellation.microcosm.blue";
63
78
export const constellationURLAtom = atomWithStorage<string>(
64
79
"constellationURL",
···
77
92
defaultVideoCDN
78
93
);
79
94
95
+
export const defaultLycanURL = "";
96
+
export const lycanURLAtom = atomWithStorage<string>(
97
+
"lycanURL",
98
+
defaultLycanURL
99
+
);
100
+
80
101
export const defaulthue = 28;
81
102
export const hueAtom = atomWithStorage<number>("hue", defaulthue);
82
103
···
113
134
// console.log("atom get ", initial);
114
135
// document.documentElement.style.setProperty(cssVar, initial.toString());
115
136
// }
137
+
138
+
139
+
140
+
// fun stuff
141
+
142
+
export const enableBitesAtom = atomWithStorage<boolean>(
143
+
"enableBitesAtom",
144
+
false
145
+
);
146
+
147
+
export const enableBridgyTextAtom = atomWithStorage<boolean>(
148
+
"enableBridgyTextAtom",
149
+
false
150
+
);
151
+
152
+
export const enableWafrnTextAtom = atomWithStorage<boolean>(
153
+
"enableWafrnTextAtom",
154
+
false
155
+
);
+34
src/utils/likeMutationQueue.ts
+34
src/utils/likeMutationQueue.ts
···
1
+
import { useAtom } from "jotai";
2
+
import { useCallback } from "react";
3
+
4
+
import { type LikeRecord,useLikeMutationQueue as useLikeMutationQueueFromProvider } from "~/providers/LikeMutationQueueProvider";
5
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
+
7
+
import { internalLikedPostsAtom } from "./atoms";
8
+
9
+
export function useFastLike(target: string, cid: string) {
10
+
const { agent } = useAuth();
11
+
const { fastState, fastToggle, backfillState } = useLikeMutationQueueFromProvider();
12
+
13
+
const liked = fastState(target);
14
+
const toggle = () => fastToggle(target, cid);
15
+
/**
16
+
*
17
+
* @deprecated dont use it yet, will cause infinite rerenders
18
+
*/
19
+
const backfill = () => agent?.did && backfillState(target, agent.did);
20
+
21
+
return { liked, toggle, backfill };
22
+
}
23
+
24
+
export function useFastSetLikesFromFeed() {
25
+
const [_, setLikedPosts] = useAtom(internalLikedPostsAtom);
26
+
27
+
const setFastState = useCallback(
28
+
(target: string, record: LikeRecord | null) =>
29
+
setLikedPosts((prev) => ({ ...prev, [target]: record })),
30
+
[setLikedPosts]
31
+
);
32
+
33
+
return { setFastState };
34
+
}
+2
-2
src/utils/oauthClient.ts
+2
-2
src/utils/oauthClient.ts
···
1
1
import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser';
2
2
3
-
// i tried making this https://pds-nd.whey.party but cors is annoying as fuck
4
-
const handleResolverPDS = 'https://bsky.social';
3
+
import resolvers from '../../public/resolvers.json' with { type: 'json' };
4
+
const handleResolverPDS = resolvers.resolver || 'https://bsky.social';
5
5
6
6
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
7
7
// @ts-ignore this should be fine ? the vite plugin should generate this before errors
+403
-155
src/utils/useQuery.ts
+403
-155
src/utils/useQuery.ts
···
5
5
queryOptions,
6
6
useInfiniteQuery,
7
7
useQuery,
8
-
type UseQueryResult} from "@tanstack/react-query";
8
+
type UseQueryResult,
9
+
} from "@tanstack/react-query";
9
10
import { useAtom } from "jotai";
10
11
11
-
import { constellationURLAtom, slingshotURLAtom } from "./atoms";
12
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
13
+
14
+
import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms";
12
15
13
-
export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) {
16
+
export function constructIdentityQuery(
17
+
didorhandle?: string,
18
+
slingshoturl?: string
19
+
) {
14
20
return queryOptions({
15
21
queryKey: ["identity", didorhandle],
16
22
queryFn: async () => {
17
-
if (!didorhandle) return undefined as undefined
23
+
if (!didorhandle) return undefined as undefined;
18
24
const res = await fetch(
19
25
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
20
26
);
···
31
37
}
32
38
},
33
39
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
34
-
gcTime: /*0//*/5 * 60 * 1000,
40
+
gcTime: /*0//*/ 5 * 60 * 1000,
35
41
});
36
42
}
37
43
export function useQueryIdentity(didorhandle: string): UseQueryResult<
···
43
49
},
44
50
Error
45
51
>;
46
-
export function useQueryIdentity(): UseQueryResult<
47
-
undefined,
48
-
Error
49
-
>
50
-
export function useQueryIdentity(didorhandle?: string):
51
-
UseQueryResult<
52
-
{
53
-
did: string;
54
-
handle: string;
55
-
pds: string;
56
-
signing_key: string;
57
-
} | undefined,
58
-
Error
59
-
>
52
+
export function useQueryIdentity(): UseQueryResult<undefined, Error>;
53
+
export function useQueryIdentity(didorhandle?: string): UseQueryResult<
54
+
| {
55
+
did: string;
56
+
handle: string;
57
+
pds: string;
58
+
signing_key: string;
59
+
}
60
+
| undefined,
61
+
Error
62
+
>;
60
63
export function useQueryIdentity(didorhandle?: string) {
61
-
const [slingshoturl] = useAtom(slingshotURLAtom)
64
+
const [slingshoturl] = useAtom(slingshotURLAtom);
62
65
return useQuery(constructIdentityQuery(didorhandle, slingshoturl));
63
66
}
64
67
···
66
69
return queryOptions({
67
70
queryKey: ["post", uri],
68
71
queryFn: async () => {
69
-
if (!uri) return undefined as undefined
72
+
if (!uri) return undefined as undefined;
70
73
const res = await fetch(
71
74
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
72
75
);
···
77
80
return undefined;
78
81
}
79
82
if (res.status === 400) return undefined;
80
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
83
+
if (
84
+
data?.error === "InvalidRequest" &&
85
+
data.message?.includes("Could not find repo")
86
+
) {
81
87
return undefined; // cache โnot foundโ
82
88
}
83
89
try {
84
90
if (!res.ok) throw new Error("Failed to fetch post");
85
-
return (data) as {
91
+
return data as {
86
92
uri: string;
87
93
cid: string;
88
94
value: any;
···
97
103
return failureCount < 2;
98
104
},
99
105
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
100
-
gcTime: /*0//*/5 * 60 * 1000,
106
+
gcTime: /*0//*/ 5 * 60 * 1000,
101
107
});
102
108
}
103
109
export function useQueryPost(uri: string): UseQueryResult<
···
108
114
},
109
115
Error
110
116
>;
111
-
export function useQueryPost(): UseQueryResult<
112
-
undefined,
113
-
Error
114
-
>
115
-
export function useQueryPost(uri?: string):
116
-
UseQueryResult<
117
-
{
118
-
uri: string;
119
-
cid: string;
120
-
value: ATPAPI.AppBskyFeedPost.Record;
121
-
} | undefined,
122
-
Error
123
-
>
117
+
export function useQueryPost(): UseQueryResult<undefined, Error>;
118
+
export function useQueryPost(uri?: string): UseQueryResult<
119
+
| {
120
+
uri: string;
121
+
cid: string;
122
+
value: ATPAPI.AppBskyFeedPost.Record;
123
+
}
124
+
| undefined,
125
+
Error
126
+
>;
124
127
export function useQueryPost(uri?: string) {
125
-
const [slingshoturl] = useAtom(slingshotURLAtom)
128
+
const [slingshoturl] = useAtom(slingshotURLAtom);
126
129
return useQuery(constructPostQuery(uri, slingshoturl));
127
130
}
128
131
···
130
133
return queryOptions({
131
134
queryKey: ["profile", uri],
132
135
queryFn: async () => {
133
-
if (!uri) return undefined as undefined
136
+
if (!uri) return undefined as undefined;
134
137
const res = await fetch(
135
138
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
136
139
);
···
141
144
return undefined;
142
145
}
143
146
if (res.status === 400) return undefined;
144
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
147
+
if (
148
+
data?.error === "InvalidRequest" &&
149
+
data.message?.includes("Could not find repo")
150
+
) {
145
151
return undefined; // cache โnot foundโ
146
152
}
147
153
try {
148
154
if (!res.ok) throw new Error("Failed to fetch post");
149
-
return (data) as {
155
+
return data as {
150
156
uri: string;
151
157
cid: string;
152
158
value: any;
···
161
167
return failureCount < 2;
162
168
},
163
169
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
164
-
gcTime: /*0//*/5 * 60 * 1000,
170
+
gcTime: /*0//*/ 5 * 60 * 1000,
165
171
});
166
172
}
167
173
export function useQueryProfile(uri: string): UseQueryResult<
···
172
178
},
173
179
Error
174
180
>;
175
-
export function useQueryProfile(): UseQueryResult<
176
-
undefined,
177
-
Error
178
-
>;
179
-
export function useQueryProfile(uri?: string):
180
-
UseQueryResult<
181
-
{
181
+
export function useQueryProfile(): UseQueryResult<undefined, Error>;
182
+
export function useQueryProfile(uri?: string): UseQueryResult<
183
+
| {
182
184
uri: string;
183
185
cid: string;
184
186
value: ATPAPI.AppBskyActorProfile.Record;
185
-
} | undefined,
186
-
Error
187
-
>
187
+
}
188
+
| undefined,
189
+
Error
190
+
>;
188
191
export function useQueryProfile(uri?: string) {
189
-
const [slingshoturl] = useAtom(slingshotURLAtom)
192
+
const [slingshoturl] = useAtom(slingshotURLAtom);
190
193
return useQuery(constructProfileQuery(uri, slingshoturl));
191
194
}
192
195
···
222
225
// method: "/links/all",
223
226
// target: string
224
227
// ): QueryOptions<linksAllResponse, Error>;
225
-
export function constructConstellationQuery(query?:{
226
-
constellation: string,
228
+
export function constructConstellationQuery(query?: {
229
+
constellation: string;
227
230
method:
228
231
| "/links"
229
232
| "/links/distinct-dids"
230
233
| "/links/count"
231
234
| "/links/count/distinct-dids"
232
235
| "/links/all"
233
-
| "undefined",
234
-
target: string,
235
-
collection?: string,
236
-
path?: string,
237
-
cursor?: string,
238
-
dids?: string[]
239
-
}
240
-
) {
236
+
| "undefined";
237
+
target: string;
238
+
collection?: string;
239
+
path?: string;
240
+
cursor?: string;
241
+
dids?: string[];
242
+
}) {
241
243
// : QueryOptions<
242
244
// | linksRecordsResponse
243
245
// | linksDidsResponse
···
247
249
// Error
248
250
// >
249
251
return queryOptions({
250
-
queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const,
252
+
queryKey: [
253
+
"constellation",
254
+
query?.method,
255
+
query?.target,
256
+
query?.collection,
257
+
query?.path,
258
+
query?.cursor,
259
+
query?.dids,
260
+
] as const,
251
261
queryFn: async () => {
252
-
if (!query || query.method === "undefined") return undefined as undefined
253
-
const method = query.method
254
-
const target = query.target
255
-
const collection = query?.collection
256
-
const path = query?.path
257
-
const cursor = query.cursor
258
-
const dids = query?.dids
262
+
if (!query || query.method === "undefined") return undefined as undefined;
263
+
const method = query.method;
264
+
const target = query.target;
265
+
const collection = query?.collection;
266
+
const path = query?.path;
267
+
const cursor = query.cursor;
268
+
const dids = query?.dids;
259
269
const res = await fetch(
260
270
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
261
271
);
···
281
291
},
282
292
// enforce short lifespan
283
293
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
284
-
gcTime: /*0//*/5 * 60 * 1000,
294
+
gcTime: /*0//*/ 5 * 60 * 1000,
285
295
});
286
296
}
297
+
// todo do more of these instead of overloads since overloads sucks so much apparently
298
+
export function useQueryConstellationLinksCountDistinctDids(query?: {
299
+
method: "/links/count/distinct-dids";
300
+
target: string;
301
+
collection: string;
302
+
path: string;
303
+
cursor?: string;
304
+
}): UseQueryResult<linksCountResponse, Error> | undefined {
305
+
//if (!query) return;
306
+
const [constellationurl] = useAtom(constellationURLAtom);
307
+
const queryres = useQuery(
308
+
constructConstellationQuery(
309
+
query && { constellation: constellationurl, ...query }
310
+
)
311
+
) as unknown as UseQueryResult<linksCountResponse, Error>;
312
+
if (!query) {
313
+
return undefined as undefined;
314
+
}
315
+
return queryres as UseQueryResult<linksCountResponse, Error>;
316
+
}
317
+
287
318
export function useQueryConstellation(query: {
288
319
method: "/links";
289
320
target: string;
···
346
377
>
347
378
| undefined {
348
379
//if (!query) return;
349
-
const [constellationurl] = useAtom(constellationURLAtom)
380
+
const [constellationurl] = useAtom(constellationURLAtom);
350
381
return useQuery(
351
-
constructConstellationQuery(query && {constellation: constellationurl, ...query})
382
+
constructConstellationQuery(
383
+
query && { constellation: constellationurl, ...query }
384
+
)
352
385
);
353
386
}
354
387
···
392
425
}) {
393
426
return queryOptions({
394
427
// The query key includes all dependencies to ensure it refetches when they change
395
-
queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }],
428
+
queryKey: [
429
+
"feedSkeleton",
430
+
options?.feedUri,
431
+
{ isAuthed: options?.isAuthed, did: options?.agent?.did },
432
+
],
396
433
queryFn: async () => {
397
-
if (!options) return undefined as undefined
434
+
if (!options) return undefined as undefined;
398
435
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
399
436
if (isAuthed) {
400
437
// Authenticated flow
401
438
if (!agent || !pdsUrl || !feedServiceDid) {
402
-
throw new Error("Missing required info for authenticated feed fetch.");
439
+
throw new Error(
440
+
"Missing required info for authenticated feed fetch."
441
+
);
403
442
}
404
443
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
405
444
const res = await agent.fetchHandler(url, {
···
409
448
"Content-Type": "application/json",
410
449
},
411
450
});
412
-
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
451
+
if (!res.ok)
452
+
throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
413
453
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
414
454
} else {
415
455
// Unauthenticated flow (using a public PDS/AppView)
416
456
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
417
457
const res = await fetch(url);
418
-
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
458
+
if (!res.ok)
459
+
throw new Error(`Public feed fetch failed: ${res.statusText}`);
419
460
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
420
461
}
421
462
},
···
433
474
return useQuery(constructFeedSkeletonQuery(options));
434
475
}
435
476
436
-
export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) {
477
+
export function constructPreferencesQuery(
478
+
agent?: ATPAPI.Agent | undefined,
479
+
pdsUrl?: string | undefined
480
+
) {
437
481
return queryOptions({
438
-
queryKey: ['preferences', agent?.did],
482
+
queryKey: ["preferences", agent?.did],
439
483
queryFn: async () => {
440
484
if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available");
441
485
const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
···
446
490
});
447
491
}
448
492
export function useQueryPreferences(options: {
449
-
agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined
493
+
agent?: ATPAPI.Agent | undefined;
494
+
pdsUrl?: string | undefined;
450
495
}) {
451
496
return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl));
452
497
}
453
498
454
-
455
-
456
499
export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
457
500
return queryOptions({
458
501
queryKey: ["arbitrary", uri],
459
502
queryFn: async () => {
460
-
if (!uri) return undefined as undefined
503
+
if (!uri) return undefined as undefined;
461
504
const res = await fetch(
462
505
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
463
506
);
···
468
511
return undefined;
469
512
}
470
513
if (res.status === 400) return undefined;
471
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
514
+
if (
515
+
data?.error === "InvalidRequest" &&
516
+
data.message?.includes("Could not find repo")
517
+
) {
472
518
return undefined; // cache โnot foundโ
473
519
}
474
520
try {
475
521
if (!res.ok) throw new Error("Failed to fetch post");
476
-
return (data) as {
522
+
return data as {
477
523
uri: string;
478
524
cid: string;
479
525
value: any;
···
488
534
return failureCount < 2;
489
535
},
490
536
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
491
-
gcTime: /*0//*/5 * 60 * 1000,
537
+
gcTime: /*0//*/ 5 * 60 * 1000,
492
538
});
493
539
}
494
540
export function useQueryArbitrary(uri: string): UseQueryResult<
···
499
545
},
500
546
Error
501
547
>;
502
-
export function useQueryArbitrary(): UseQueryResult<
503
-
undefined,
504
-
Error
505
-
>;
548
+
export function useQueryArbitrary(): UseQueryResult<undefined, Error>;
506
549
export function useQueryArbitrary(uri?: string): UseQueryResult<
507
-
{
508
-
uri: string;
509
-
cid: string;
510
-
value: any;
511
-
} | undefined,
550
+
| {
551
+
uri: string;
552
+
cid: string;
553
+
value: any;
554
+
}
555
+
| undefined,
512
556
Error
513
557
>;
514
558
export function useQueryArbitrary(uri?: string) {
515
-
const [slingshoturl] = useAtom(slingshotURLAtom)
559
+
const [slingshoturl] = useAtom(slingshotURLAtom);
516
560
return useQuery(constructArbitraryQuery(uri, slingshoturl));
517
561
}
518
562
519
-
export function constructFallbackNothingQuery(){
563
+
export function constructFallbackNothingQuery() {
520
564
return queryOptions({
521
565
queryKey: ["nothing"],
522
566
queryFn: async () => {
523
-
return undefined
567
+
return undefined;
524
568
},
525
569
});
526
570
}
···
534
578
}[];
535
579
};
536
580
537
-
export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") {
581
+
export function constructAuthorFeedQuery(
582
+
did: string,
583
+
pdsUrl: string,
584
+
collection: string = "app.bsky.feed.post"
585
+
) {
538
586
return queryOptions({
539
-
queryKey: ['authorFeed', did, collection],
587
+
queryKey: ["authorFeed", did, collection],
540
588
queryFn: async ({ pageParam }: QueryFunctionContext) => {
541
589
const limit = 25;
542
-
590
+
543
591
const cursor = pageParam as string | undefined;
544
-
const cursorParam = cursor ? `&cursor=${cursor}` : '';
545
-
592
+
const cursorParam = cursor ? `&cursor=${cursor}` : "";
593
+
546
594
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`;
547
-
595
+
548
596
const res = await fetch(url);
549
597
if (!res.ok) throw new Error("Failed to fetch author's posts");
550
-
598
+
551
599
return res.json() as Promise<ListRecordsResponse>;
552
600
},
553
601
});
554
602
}
555
603
556
-
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) {
557
-
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection);
558
-
604
+
export function useInfiniteQueryAuthorFeed(
605
+
did: string | undefined,
606
+
pdsUrl: string | undefined,
607
+
collection?: string
608
+
) {
609
+
const { queryKey, queryFn } = constructAuthorFeedQuery(
610
+
did!,
611
+
pdsUrl!,
612
+
collection
613
+
);
614
+
559
615
return useInfiniteQuery({
560
616
queryKey,
561
617
queryFn,
···
576
632
// todo the hell is a unauthedfeedurl
577
633
unauthedfeedurl?: string;
578
634
}) {
579
-
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = options;
580
-
635
+
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } =
636
+
options;
637
+
581
638
return queryOptions({
582
639
queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
583
-
584
-
queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
640
+
641
+
queryFn: async ({
642
+
pageParam,
643
+
}: QueryFunctionContext): Promise<FeedSkeletonPage> => {
585
644
const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
586
-
645
+
587
646
if (isAuthed && !unauthedfeedurl) {
588
647
if (!agent || !pdsUrl || !feedServiceDid) {
589
-
throw new Error("Missing required info for authenticated feed fetch.");
648
+
throw new Error(
649
+
"Missing required info for authenticated feed fetch."
650
+
);
590
651
}
591
652
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
592
653
const res = await agent.fetchHandler(url, {
···
596
657
"Content-Type": "application/json",
597
658
},
598
659
});
599
-
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}`);
600
662
return (await res.json()) as FeedSkeletonPage;
601
663
} else {
602
664
const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
603
665
const res = await fetch(url);
604
-
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}`);
605
668
return (await res.json()) as FeedSkeletonPage;
606
669
}
607
670
},
···
617
680
unauthedfeedurl?: string;
618
681
}) {
619
682
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
620
-
621
-
return {...useInfiniteQuery({
622
-
queryKey,
623
-
queryFn,
624
-
initialPageParam: undefined as never,
625
-
getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
626
-
staleTime: Infinity,
627
-
refetchOnWindowFocus: false,
628
-
enabled: !!options.feedUri && (options.isAuthed ? (!!options.agent && !!options.pdsUrl || !!options.unauthedfeedurl) && !!options.feedServiceDid : true),
629
-
}), queryKey: queryKey};
630
-
}
631
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
+
}
632
703
633
704
export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
634
-
constellation: string,
635
-
method: '/links'
636
-
target?: string
637
-
collection: string
638
-
path: string
705
+
constellation: string;
706
+
method: "/links";
707
+
target?: string;
708
+
collection: string;
709
+
path: string;
710
+
staleMult?: number;
639
711
}) {
712
+
const safemult = query?.staleMult ?? 1;
640
713
// console.log(
641
714
// 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
642
715
// query,
···
645
718
return infiniteQueryOptions({
646
719
enabled: !!query?.target,
647
720
queryKey: [
648
-
'reddwarf_constellation',
721
+
"reddwarf_constellation",
649
722
query?.method,
650
723
query?.target,
651
724
query?.collection,
652
725
query?.path,
653
726
] as const,
654
727
655
-
queryFn: async ({pageParam}: {pageParam?: string}) => {
656
-
if (!query || !query?.target) return undefined
728
+
queryFn: async ({ pageParam }: { pageParam?: string }) => {
729
+
if (!query || !query?.target) return undefined;
657
730
658
-
const method = query.method
659
-
const target = query.target
660
-
const collection = query.collection
661
-
const path = query.path
662
-
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;
663
736
664
737
const res = await fetch(
665
738
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
666
-
collection ? `&collection=${encodeURIComponent(collection)}` : ''
667
-
}${path ? `&path=${encodeURIComponent(path)}` : ''}${
668
-
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
669
-
}`,
670
-
)
739
+
collection ? `&collection=${encodeURIComponent(collection)}` : ""
740
+
}${path ? `&path=${encodeURIComponent(path)}` : ""}${
741
+
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""
742
+
}`
743
+
);
671
744
672
-
if (!res.ok) throw new Error('Failed to fetch')
745
+
if (!res.ok) throw new Error("Failed to fetch");
673
746
674
-
return (await res.json()) as linksRecordsResponse
747
+
return (await res.json()) as linksRecordsResponse;
675
748
},
676
749
677
-
getNextPageParam: lastPage => {
678
-
return (lastPage as any)?.cursor ?? undefined
750
+
getNextPageParam: (lastPage) => {
751
+
return (lastPage as any)?.cursor ?? undefined;
679
752
},
680
753
initialPageParam: undefined,
681
-
staleTime: 5 * 60 * 1000,
682
-
gcTime: 5 * 60 * 1000,
683
-
})
684
-
}
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({