+1
-1
README.md
+1
-1
README.md
···
8
## running dev and build
9
in the `vite.config.ts` file you should change these values
10
```ts
11
-
const PROD_URL = "https://reddwarf.whey.party"
12
const DEV_URL = "https://local3768forumtest.whey.party"
13
```
14
the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work
···
8
## running dev and build
9
in the `vite.config.ts` file you should change these values
10
```ts
11
+
const PROD_URL = "https://reddwarf.app"
12
const DEV_URL = "https://local3768forumtest.whey.party"
13
```
14
the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work
+16
-1
src/components/Composer.tsx
+16
-1
src/components/Composer.tsx
···
1
-
import { RichText } from "@atproto/api";
2
import { useAtom } from "jotai";
3
import { Dialog } from "radix-ui";
4
import { useEffect, useRef, useState } from "react";
···
47
try {
48
const rt = new RichText({ text: postText });
49
await rt.detectFacets(agent);
50
51
const record: Record<string, unknown> = {
52
$type: "app.bsky.feed.post",
···
1
+
import { AppBskyRichtextFacet, RichText } from "@atproto/api";
2
import { useAtom } from "jotai";
3
import { Dialog } from "radix-ui";
4
import { useEffect, useRef, useState } from "react";
···
47
try {
48
const rt = new RichText({ text: postText });
49
await rt.detectFacets(agent);
50
+
51
+
if (rt.facets?.length) {
52
+
rt.facets = rt.facets.filter((item) => {
53
+
if (item.$type !== "app.bsky.richtext.facet") return true;
54
+
if (!item.features?.length) return true;
55
+
56
+
item.features = item.features.filter((feature) => {
57
+
if (feature.$type !== "app.bsky.richtext.facet#mention") return true;
58
+
const did = feature.$type === "app.bsky.richtext.facet#mention" ? (feature as AppBskyRichtextFacet.Mention)?.did : undefined;
59
+
return typeof did === "string" && did.startsWith("did:");
60
+
});
61
+
62
+
return item.features.length > 0;
63
+
});
64
+
}
65
66
const record: Record<string, unknown> = {
67
$type: "app.bsky.feed.post",
+150
src/components/Import.tsx
+150
src/components/Import.tsx
···
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { useNavigate, type UseNavigateResult } from "@tanstack/react-router";
3
+
import { useState } from "react";
4
+
5
+
/**
6
+
* Basically the best equivalent to Search that i can do
7
+
*/
8
+
export function Import() {
9
+
const [textInput, setTextInput] = useState<string | undefined>();
10
+
const navigate = useNavigate();
11
+
12
+
const handleEnter = () => {
13
+
if (!textInput) return;
14
+
handleImport({
15
+
text: textInput,
16
+
navigate,
17
+
});
18
+
};
19
+
20
+
return (
21
+
<div className="w-full relative">
22
+
<IconMaterialSymbolsSearch className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" />
23
+
24
+
<input
25
+
type="text"
26
+
placeholder="Import..."
27
+
value={textInput}
28
+
onChange={(e) => setTextInput(e.target.value)}
29
+
onKeyDown={(e) => {
30
+
if (e.key === "Enter") handleEnter();
31
+
}}
32
+
className="w-full h-12 pl-12 pr-4 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-500 box-border transition"
33
+
/>
34
+
</div>
35
+
);
36
+
}
37
+
38
+
function handleImport({
39
+
text,
40
+
navigate,
41
+
}: {
42
+
text: string;
43
+
navigate: UseNavigateResult<string>;
44
+
}) {
45
+
const trimmed = text.trim();
46
+
// parse text
47
+
/**
48
+
* text might be
49
+
* 1. bsky dot app url (reddwarf link segments might be uri encoded,)
50
+
* 2. aturi
51
+
* 3. plain handle
52
+
* 4. plain did
53
+
*/
54
+
55
+
// 1. Check if itโs a URL
56
+
try {
57
+
const url = new URL(text);
58
+
const knownHosts = [
59
+
"bsky.app",
60
+
"social.daniela.lol",
61
+
"deer.social",
62
+
"reddwarf.whey.party",
63
+
"reddwarf.app",
64
+
"main.bsky.dev",
65
+
"catsky.social",
66
+
"blacksky.community",
67
+
"red-dwarf-social-app.whey.party",
68
+
"zeppelin.social",
69
+
];
70
+
if (knownHosts.includes(url.hostname)) {
71
+
// parse path to get URI or handle
72
+
const path = decodeURIComponent(url.pathname.slice(1)); // remove leading /
73
+
console.log("BSky URL path:", path);
74
+
navigate({
75
+
to: `/${path}`,
76
+
});
77
+
return;
78
+
}
79
+
} catch {
80
+
// not a URL, continue
81
+
}
82
+
83
+
// 2. Check if text looks like an at-uri
84
+
try {
85
+
if (text.startsWith("at://")) {
86
+
console.log("AT URI detected:", text);
87
+
const aturi = new AtUri(text);
88
+
switch (aturi.collection) {
89
+
case "app.bsky.feed.post": {
90
+
navigate({
91
+
to: "/profile/$did/post/$rkey",
92
+
params: {
93
+
did: aturi.host,
94
+
rkey: aturi.rkey,
95
+
},
96
+
});
97
+
return;
98
+
}
99
+
case "app.bsky.actor.profile": {
100
+
navigate({
101
+
to: "/profile/$did",
102
+
params: {
103
+
did: aturi.host,
104
+
},
105
+
});
106
+
return;
107
+
}
108
+
// todo add more handlers as more routes are added. like feeds, lists, etc etc thanks!
109
+
default: {
110
+
// continue
111
+
}
112
+
}
113
+
}
114
+
} catch {
115
+
// continue
116
+
}
117
+
118
+
// 3. Plain handle (starts with @)
119
+
try {
120
+
if (text.startsWith("@")) {
121
+
const handle = text.slice(1);
122
+
console.log("Handle detected:", handle);
123
+
navigate({ to: "/profile/$did", params: { did: handle } });
124
+
return;
125
+
}
126
+
} catch {
127
+
// continue
128
+
}
129
+
130
+
// 4. Plain DID (starts with did:)
131
+
try {
132
+
if (text.startsWith("did:")) {
133
+
console.log("did detected:", text);
134
+
navigate({ to: "/profile/$did", params: { did: text } });
135
+
return;
136
+
}
137
+
} catch {
138
+
// continue
139
+
}
140
+
141
+
// if all else fails
142
+
143
+
// try {
144
+
// // probably a user?
145
+
// navigate({ to: "/profile/$did", params: { did: text } });
146
+
// return;
147
+
// } catch {
148
+
// // continue
149
+
// }
150
+
}
+32
-6
src/components/InfiniteCustomFeed.tsx
+32
-6
src/components/InfiniteCustomFeed.tsx
···
1
import * as React from "react";
2
3
//import { useInView } from "react-intersection-observer";
···
37
isFetchingNextPage,
38
refetch,
39
isRefetching,
40
} = useInfiniteQueryFeedSkeleton({
41
feedUri: feedUri,
42
agent: agent ?? undefined,
···
44
pdsUrl: pdsUrl,
45
feedServiceDid: feedServiceDid,
46
});
47
48
const handleRefresh = () => {
49
refetch();
50
};
51
52
//const { ref, inView } = useInView();
53
54
// React.useEffect(() => {
···
67
);
68
}
69
70
-
const allPosts =
71
-
data?.pages.flatMap((page) => {
72
-
if (page) return page.feed;
73
-
}) ?? [];
74
75
if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) {
76
return (
···
116
className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed"
117
aria-label="Refresh feed"
118
>
119
-
<RefreshIcon className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} />
120
</button>
121
</>
122
);
···
139
d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"
140
></path>
141
</svg>
142
-
);
···
1
+
import { useQueryClient } from "@tanstack/react-query";
2
import * as React from "react";
3
4
//import { useInView } from "react-intersection-observer";
···
38
isFetchingNextPage,
39
refetch,
40
isRefetching,
41
+
queryKey,
42
} = useInfiniteQueryFeedSkeleton({
43
feedUri: feedUri,
44
agent: agent ?? undefined,
···
46
pdsUrl: pdsUrl,
47
feedServiceDid: feedServiceDid,
48
});
49
+
const queryClient = useQueryClient();
50
+
51
52
const handleRefresh = () => {
53
+
queryClient.removeQueries({queryKey: queryKey});
54
+
//queryClient.invalidateQueries(["infinite-feed", feedUri] as const);
55
refetch();
56
};
57
58
+
const allPosts = React.useMemo(() => {
59
+
const flattenedPosts = data?.pages.flatMap((page) => page?.feed) ?? [];
60
+
61
+
const seenUris = new Set<string>();
62
+
63
+
return flattenedPosts.filter((item) => {
64
+
if (!item?.post) return false;
65
+
66
+
if (seenUris.has(item.post)) {
67
+
return false;
68
+
}
69
+
70
+
seenUris.add(item.post);
71
+
72
+
return true;
73
+
});
74
+
}, [data]);
75
+
76
//const { ref, inView } = useInView();
77
78
// React.useEffect(() => {
···
91
);
92
}
93
94
+
// const allPosts =
95
+
// data?.pages.flatMap((page) => {
96
+
// if (page) return page.feed;
97
+
// }) ?? [];
98
99
if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) {
100
return (
···
140
className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed"
141
aria-label="Refresh feed"
142
>
143
+
<RefreshIcon
144
+
className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`}
145
+
/>
146
</button>
147
</>
148
);
···
165
d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"
166
></path>
167
</svg>
168
+
);
+3
-3
src/components/Login.tsx
+3
-3
src/components/Login.tsx
···
24
className={
25
compact
26
? "flex items-center justify-center p-1"
27
-
: "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]"
28
}
29
>
30
<span
···
43
// Large view
44
if (!compact) {
45
return (
46
-
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4">
47
<div className="flex flex-col items-center justify-center text-center">
48
<p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100">
49
You are logged in!
···
77
if (!compact) {
78
// Large view renders the form directly in the card
79
return (
80
-
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4">
81
<UnifiedLoginForm />
82
</div>
83
);
···
24
className={
25
compact
26
? "flex items-center justify-center p-1"
27
+
: "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-4 mx-4 flex justify-center items-center h-[280px]"
28
}
29
>
30
<span
···
43
// Large view
44
if (!compact) {
45
return (
46
+
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4">
47
<div className="flex flex-col items-center justify-center text-center">
48
<p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100">
49
You are logged in!
···
77
if (!compact) {
78
// Large view renders the form directly in the card
79
return (
80
+
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4">
81
<UnifiedLoginForm />
82
</div>
83
);
+11
-5
src/components/UniversalPostRenderer.tsx
+11
-5
src/components/UniversalPostRenderer.tsx
···
518
? true
519
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
520
? false
521
-
: bottomReplyLine
522
}
523
topReplyLine={topReplyLine}
524
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
540
maxReplies={maxReplies}
541
isQuote={isQuote}
542
/>
543
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
544
<>
545
{/* <span>hello {maxReplies}</span> */}
···
564
maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
565
}
566
/>
567
-
{maxReplies && maxReplies - 1 === 0 && replies && replies > 0 && (
568
-
<MoreReplies atUri={oldestOpsReplyElseNewestNonOpsReply} />
569
-
)}
570
</>
571
)}
572
</>
···
1542
className="rounded-md p-4 w-72 bg-gray-50 dark:bg-gray-900 shadow-lg border border-gray-300 dark:border-gray-800 animate-slide-fade z-50"
1543
side={"bottom"}
1544
sideOffset={5}
1545
>
1546
<div className="flex flex-col gap-2">
1547
<div className="flex flex-row">
···
2649
return { start, end, feature: f.features[0] };
2650
});
2651
}
2652
-
function renderTextWithFacets({
2653
text,
2654
facets,
2655
navigate,
···
518
? true
519
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
520
? false
521
+
: (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine
522
}
523
topReplyLine={topReplyLine}
524
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
540
maxReplies={maxReplies}
541
isQuote={isQuote}
542
/>
543
+
<>
544
+
{(maxReplies && maxReplies === 0 && replies && replies > 0) ? (
545
+
<>
546
+
{/* <div>hello</div> */}
547
+
<MoreReplies atUri={atUri} />
548
+
</>
549
+
) : (<></>)}
550
+
</>
551
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
552
<>
553
{/* <span>hello {maxReplies}</span> */}
···
572
maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
573
}
574
/>
575
</>
576
)}
577
</>
···
1547
className="rounded-md p-4 w-72 bg-gray-50 dark:bg-gray-900 shadow-lg border border-gray-300 dark:border-gray-800 animate-slide-fade z-50"
1548
side={"bottom"}
1549
sideOffset={5}
1550
+
onClick={onProfileClick}
1551
>
1552
<div className="flex flex-col gap-2">
1553
<div className="flex flex-row">
···
2655
return { start, end, feature: f.features[0] };
2656
});
2657
}
2658
+
export function renderTextWithFacets({
2659
text,
2660
facets,
2661
navigate,
+28
-26
src/routes/__root.tsx
+28
-26
src/routes/__root.tsx
···
18
19
import { Composer } from "~/components/Composer";
20
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
21
import Login from "~/components/Login";
22
import { NotFound } from "~/components/NotFound";
23
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
···
154
/>
155
156
<MaterialNavItem
157
InactiveIcon={
158
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
159
}
···
180
})
181
}
182
text="Feeds"
183
-
/>
184
-
<MaterialNavItem
185
-
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
186
-
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
187
-
active={locationEnum === "search"}
188
-
onClickCallbback={() =>
189
-
navigate({
190
-
to: "/search",
191
-
//params: { did: agent.assertDid },
192
-
})
193
-
}
194
-
text="Search"
195
/>
196
<MaterialNavItem
197
InactiveIcon={
···
389
390
<MaterialNavItem
391
small
392
InactiveIcon={
393
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
394
}
···
419
/>
420
<MaterialNavItem
421
small
422
-
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
423
-
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
424
-
active={locationEnum === "search"}
425
-
onClickCallbback={() =>
426
-
navigate({
427
-
to: "/search",
428
-
//params: { did: agent.assertDid },
429
-
})
430
-
}
431
-
text="Search"
432
-
/>
433
-
<MaterialNavItem
434
-
small
435
InactiveIcon={
436
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
437
}
···
498
</main>
499
500
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
501
<Login />
502
503
<div className="flex-1"></div>
···
551
//params: { did: agent.assertDid },
552
})
553
}
554
-
text="Search"
555
/>
556
{/* <Link
557
to="/search"
···
18
19
import { Composer } from "~/components/Composer";
20
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
21
+
import { Import } from "~/components/Import";
22
import Login from "~/components/Login";
23
import { NotFound } from "~/components/NotFound";
24
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
···
155
/>
156
157
<MaterialNavItem
158
+
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
159
+
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
160
+
active={locationEnum === "search"}
161
+
onClickCallbback={() =>
162
+
navigate({
163
+
to: "/search",
164
+
//params: { did: agent.assertDid },
165
+
})
166
+
}
167
+
text="Explore"
168
+
/>
169
+
<MaterialNavItem
170
InactiveIcon={
171
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
172
}
···
193
})
194
}
195
text="Feeds"
196
/>
197
<MaterialNavItem
198
InactiveIcon={
···
390
391
<MaterialNavItem
392
small
393
+
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
394
+
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
395
+
active={locationEnum === "search"}
396
+
onClickCallbback={() =>
397
+
navigate({
398
+
to: "/search",
399
+
//params: { did: agent.assertDid },
400
+
})
401
+
}
402
+
text="Explore"
403
+
/>
404
+
<MaterialNavItem
405
+
small
406
InactiveIcon={
407
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
408
}
···
433
/>
434
<MaterialNavItem
435
small
436
InactiveIcon={
437
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
438
}
···
499
</main>
500
501
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
502
+
<div className="px-4 pt-4"><Import /></div>
503
<Login />
504
505
<div className="flex-1"></div>
···
553
//params: { did: agent.assertDid },
554
})
555
}
556
+
text="Explore"
557
/>
558
{/* <Link
559
to="/search"
+1
src/routes/index.tsx
+1
src/routes/index.tsx
+125
-41
src/routes/profile.$did/index.tsx
+125
-41
src/routes/profile.$did/index.tsx
···
1
import { useQueryClient } from "@tanstack/react-query";
2
-
import { createFileRoute } from "@tanstack/react-router";
3
import { useAtom } from "jotai";
4
-
import React from "react";
5
6
import { Header } from "~/components/Header";
7
-
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
8
import { useAuth } from "~/providers/UnifiedAuthProvider";
9
-
import { imgCDNAtom } from "~/utils/atoms";
10
-
import { toggleFollow, useGetFollowState, useGetOneToOneState } from "~/utils/followState";
11
import {
12
-
useInfiniteQueryAuthorFeed,
13
useQueryIdentity,
14
useQueryProfile,
15
} from "~/utils/useQuery";
···
21
function ProfileComponent() {
22
// booo bad this is not always the did it might be a handle, use identity.did instead
23
const { did } = Route.useParams();
24
const queryClient = useQueryClient();
25
const {
26
data: identity,
···
30
31
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
32
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
33
-
const pdsUrl = identity?.pds;
34
35
const profileUri = resolvedDid
36
? `at://${resolvedDid}/app.bsky.actor.profile/self`
37
: undefined;
38
const { data: profileRecord } = useQueryProfile(profileUri);
39
const profile = profileRecord?.value;
40
41
const {
42
data: postsData,
···
44
hasNextPage,
45
isFetchingNextPage,
46
isLoading: arePostsLoading,
47
-
} = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl);
48
49
React.useEffect(() => {
50
if (postsData) {
51
postsData.pages.forEach((page) => {
52
-
page.records.forEach((record) => {
53
if (!queryClient.getQueryData(["post", record.uri])) {
54
queryClient.setQueryData(["post", record.uri], record);
55
}
···
59
}, [postsData, queryClient]);
60
61
const posts = React.useMemo(
62
-
() => postsData?.pages.flatMap((page) => page.records) ?? [],
63
[postsData]
64
);
65
···
170
<div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100">
171
<div className="font-bold text-2xl">{displayName}</div>
172
<div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1">
173
-
<Mutual targetdidorhandle={did} />
174
{handle}
175
</div>
176
{description && (
177
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
178
-
{description}
179
</div>
180
)}
181
</div>
···
219
);
220
}
221
222
-
export function FollowButton({targetdidorhandle}:{targetdidorhandle: string}) {
223
-
const {agent} = useAuth()
224
-
const {data: identity} = useQueryIdentity(targetdidorhandle);
225
const queryClient = useQueryClient();
226
227
const followRecords = useGetFollowState({
228
target: identity?.did ?? targetdidorhandle,
229
user: agent?.did,
230
});
231
-
232
return (
233
<>
234
{identity?.did !== agent?.did ? (
235
<>
236
{!(followRecords?.length && followRecords?.length > 0) ? (
237
<button
238
-
onClick={(e) =>
239
-
{
240
e.stopPropagation();
241
toggleFollow({
242
agent: agent || undefined,
243
targetDid: identity?.did,
244
followRecords: followRecords,
245
queryClient: queryClient,
246
-
})
247
-
}
248
-
}
249
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]"
250
>
251
Follow
252
</button>
253
) : (
254
<button
255
-
onClick={(e) =>
256
-
{
257
e.stopPropagation();
258
toggleFollow({
259
agent: agent || undefined,
260
targetDid: identity?.did,
261
followRecords: followRecords,
262
queryClient: queryClient,
263
-
})
264
-
}
265
-
}
266
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]"
267
>
268
Unfollow
···
278
);
279
}
280
281
282
-
export function Mutual({targetdidorhandle}:{targetdidorhandle: string}) {
283
-
const {agent} = useAuth()
284
-
const {data: identity} = useQueryIdentity(targetdidorhandle);
285
286
-
const mutualfollows = useGetOneToOneState(agent?.did ? {
287
-
target: agent?.did,
288
-
user: identity?.did ?? targetdidorhandle,
289
-
collection: "app.bsky.graph.follow",
290
-
path: ".subject"
291
-
}:undefined);
292
293
-
const ismutual: boolean = (!!mutualfollows?.length && mutualfollows.length > 0)
294
-
295
return (
296
<>
297
{identity?.did !== agent?.did ? (
298
<>
299
-
{!(ismutual) ? (
300
-
<></>
301
) : (
302
-
<div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center">mutuals</div>
303
)}
304
</>
305
) : (
···
308
)}
309
</>
310
);
311
-
}
···
1
+
import { RichText } from "@atproto/api";
2
import { useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
4
import { useAtom } from "jotai";
5
+
import React, { type ReactNode, useEffect, useState } from "react";
6
7
import { Header } from "~/components/Header";
8
+
import {
9
+
renderTextWithFacets,
10
+
UniversalPostRendererATURILoader,
11
+
} from "~/components/UniversalPostRenderer";
12
import { useAuth } from "~/providers/UnifiedAuthProvider";
13
+
import { aturiListServiceAtom, imgCDNAtom } from "~/utils/atoms";
14
+
import {
15
+
toggleFollow,
16
+
useGetFollowState,
17
+
useGetOneToOneState,
18
+
} from "~/utils/followState";
19
import {
20
+
useInfiniteQueryAturiList,
21
useQueryIdentity,
22
useQueryProfile,
23
} from "~/utils/useQuery";
···
29
function ProfileComponent() {
30
// booo bad this is not always the did it might be a handle, use identity.did instead
31
const { did } = Route.useParams();
32
+
//const navigate = useNavigate();
33
const queryClient = useQueryClient();
34
const {
35
data: identity,
···
39
40
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
41
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
42
+
//const pdsUrl = identity?.pds;
43
44
const profileUri = resolvedDid
45
? `at://${resolvedDid}/app.bsky.actor.profile/self`
46
: undefined;
47
const { data: profileRecord } = useQueryProfile(profileUri);
48
const profile = profileRecord?.value;
49
+
50
+
const [aturilistservice] = useAtom(aturiListServiceAtom);
51
52
const {
53
data: postsData,
···
55
hasNextPage,
56
isFetchingNextPage,
57
isLoading: arePostsLoading,
58
+
} = useInfiniteQueryAturiList({
59
+
aturilistservice: aturilistservice,
60
+
did: resolvedDid,
61
+
collection: "app.bsky.feed.post",
62
+
reverse: true
63
+
});
64
65
React.useEffect(() => {
66
if (postsData) {
67
postsData.pages.forEach((page) => {
68
+
page.forEach((record) => {
69
if (!queryClient.getQueryData(["post", record.uri])) {
70
queryClient.setQueryData(["post", record.uri], record);
71
}
···
75
}, [postsData, queryClient]);
76
77
const posts = React.useMemo(
78
+
() => postsData?.pages.flatMap((page) => page) ?? [],
79
[postsData]
80
);
81
···
186
<div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100">
187
<div className="font-bold text-2xl">{displayName}</div>
188
<div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1">
189
+
<Mutual targetdidorhandle={did} />
190
{handle}
191
</div>
192
{description && (
193
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
194
+
{/* {description} */}
195
+
<RichTextRenderer key={did} description={description} />
196
</div>
197
)}
198
</div>
···
236
);
237
}
238
239
+
export function FollowButton({
240
+
targetdidorhandle,
241
+
}: {
242
+
targetdidorhandle: string;
243
+
}) {
244
+
const { agent } = useAuth();
245
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
246
const queryClient = useQueryClient();
247
248
const followRecords = useGetFollowState({
249
target: identity?.did ?? targetdidorhandle,
250
user: agent?.did,
251
});
252
+
253
return (
254
<>
255
{identity?.did !== agent?.did ? (
256
<>
257
{!(followRecords?.length && followRecords?.length > 0) ? (
258
<button
259
+
onClick={(e) => {
260
e.stopPropagation();
261
toggleFollow({
262
agent: agent || undefined,
263
targetDid: identity?.did,
264
followRecords: followRecords,
265
queryClient: queryClient,
266
+
});
267
+
}}
268
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]"
269
>
270
Follow
271
</button>
272
) : (
273
<button
274
+
onClick={(e) => {
275
e.stopPropagation();
276
toggleFollow({
277
agent: agent || undefined,
278
targetDid: identity?.did,
279
followRecords: followRecords,
280
queryClient: queryClient,
281
+
});
282
+
}}
283
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
284
>
285
Unfollow
···
295
);
296
}
297
298
+
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
299
+
const { agent } = useAuth();
300
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
301
302
+
const theyFollowYouRes = useGetOneToOneState(
303
+
agent?.did
304
+
? {
305
+
target: agent?.did,
306
+
user: identity?.did ?? targetdidorhandle,
307
+
collection: "app.bsky.graph.follow",
308
+
path: ".subject",
309
+
}
310
+
: undefined
311
+
);
312
313
+
const youFollowThemRes = useGetFollowState({
314
+
target: identity?.did ?? targetdidorhandle,
315
+
user: agent?.did,
316
+
});
317
+
318
+
const theyFollowYou: boolean =
319
+
!!theyFollowYouRes?.length && theyFollowYouRes.length > 0;
320
+
const youFollowThem: boolean =
321
+
!!youFollowThemRes?.length && youFollowThemRes.length > 0;
322
323
return (
324
<>
325
+
{/* if not self */}
326
{identity?.did !== agent?.did ? (
327
<>
328
+
{theyFollowYou ? (
329
+
<>
330
+
{youFollowThem ? (
331
+
<div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center">
332
+
mutuals
333
+
</div>
334
+
) : (
335
+
<div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center">
336
+
follows you
337
+
</div>
338
+
)}
339
+
</>
340
) : (
341
+
<></>
342
)}
343
</>
344
) : (
···
347
)}
348
</>
349
);
350
+
}
351
+
352
+
export function RichTextRenderer({ description }: { description: string }) {
353
+
const [richDescription, setRichDescription] = useState<string | ReactNode[]>(
354
+
description
355
+
);
356
+
const { agent } = useAuth();
357
+
const navigate = useNavigate();
358
+
359
+
useEffect(() => {
360
+
let mounted = true;
361
+
362
+
// setRichDescription(description);
363
+
364
+
async function processRichText() {
365
+
try {
366
+
if (!agent?.did) return;
367
+
const rt = new RichText({ text: description });
368
+
await rt.detectFacets(agent);
369
+
370
+
if (!mounted) return;
371
+
372
+
if (rt.facets) {
373
+
setRichDescription(
374
+
renderTextWithFacets({ text: rt.text, facets: rt.facets, navigate })
375
+
);
376
+
} else {
377
+
setRichDescription(rt.text);
378
+
}
379
+
} catch (error) {
380
+
console.error("Failed to detect facets:", error);
381
+
if (mounted) {
382
+
setRichDescription(description);
383
+
}
384
+
}
385
+
}
386
+
387
+
processRichText();
388
+
389
+
return () => {
390
+
mounted = false;
391
+
};
392
+
}, [description, agent, navigate]);
393
+
394
+
return <>{richDescription}</>;
395
+
}
+50
-1
src/routes/search.tsx
+50
-1
src/routes/search.tsx
···
1
import { createFileRoute } from "@tanstack/react-router";
2
3
+
import { Header } from "~/components/Header";
4
+
import { Import } from "~/components/Import";
5
+
6
export const Route = createFileRoute("/search")({
7
component: Search,
8
});
9
10
export function Search() {
11
+
return (
12
+
<>
13
+
<Header
14
+
title="Explore"
15
+
backButtonCallback={() => {
16
+
if (window.history.length > 1) {
17
+
window.history.back();
18
+
} else {
19
+
window.location.assign("/");
20
+
}
21
+
}}
22
+
/>
23
+
<div className=" flex flex-col items-center mt-4 mx-4 gap-4">
24
+
<Import />
25
+
<div className="flex flex-col">
26
+
<p className="text-gray-600 dark:text-gray-400">
27
+
Sorry we dont have search. But instead, you can load some of these
28
+
types of content into Red Dwarf:
29
+
</p>
30
+
<ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400">
31
+
<li>
32
+
Bluesky URLs from supported clients (like{" "}
33
+
<code className="text-sm">bsky.app</code> or{" "}
34
+
<code className="text-sm">deer.social</code>).
35
+
</li>
36
+
<li>
37
+
AT-URIs (e.g.,{" "}
38
+
<code className="text-sm">at://did:example/collection/item</code>
39
+
).
40
+
</li>
41
+
<li>
42
+
Plain handles (like{" "}
43
+
<code className="text-sm">@username.bsky.social</code>).
44
+
</li>
45
+
<li>
46
+
Direct DIDs (Decentralized Identifiers, starting with{" "}
47
+
<code className="text-sm">did:</code>).
48
+
</li>
49
+
</ul>
50
+
<p className="mt-2 text-gray-600 dark:text-gray-400">
51
+
Simply paste one of these into the import field above and press
52
+
Enter to load the content.
53
+
</p>
54
+
</div>
55
+
</div>
56
+
</>
57
+
);
58
}
+8
src/routes/settings.tsx
+8
src/routes/settings.tsx
···
5
import { Header } from "~/components/Header";
6
import Login from "~/components/Login";
7
import {
8
constellationURLAtom,
9
defaultconstellationURL,
10
defaulthue,
11
defaultImgCDN,
···
51
title={"Slingshot"}
52
description={"Customize the Slingshot instance to be used by Red Dwarf"}
53
init={defaultslingshotURL}
54
/>
55
<TextInputSetting
56
atom={imgCDNAtom}
···
5
import { Header } from "~/components/Header";
6
import Login from "~/components/Login";
7
import {
8
+
aturiListServiceAtom,
9
constellationURLAtom,
10
+
defaultaturilistservice,
11
defaultconstellationURL,
12
defaulthue,
13
defaultImgCDN,
···
53
title={"Slingshot"}
54
description={"Customize the Slingshot instance to be used by Red Dwarf"}
55
init={defaultslingshotURL}
56
+
/>
57
+
<TextInputSetting
58
+
atom={aturiListServiceAtom}
59
+
title={"AtUriListService"}
60
+
description={"Customize the AtUriListService instance to be used by Red Dwarf"}
61
+
init={defaultaturilistservice}
62
/>
63
<TextInputSetting
64
atom={imgCDNAtom}
+5
src/utils/atoms.ts
+5
src/utils/atoms.ts
···
32
"slingshotURL",
33
defaultslingshotURL
34
);
35
+
export const defaultaturilistservice = "aturilistservice.reddwarf.app";
36
+
export const aturiListServiceAtom = atomWithStorage<string>(
37
+
"aturilistservice",
38
+
defaultaturilistservice
39
+
);
40
export const defaultImgCDN = "cdn.bsky.app";
41
export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN);
42
export const defaultVideoCDN = "video.bsky.app";
+82
-2
src/utils/useQuery.ts
+82
-2
src/utils/useQuery.ts
···
565
});
566
}
567
568
type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
569
570
export function constructInfiniteFeedSkeletonQuery(options: {
···
615
}) {
616
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
617
618
-
return useInfiniteQuery({
619
queryKey,
620
queryFn,
621
initialPageParam: undefined as never,
···
623
staleTime: Infinity,
624
refetchOnWindowFocus: false,
625
enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
626
-
});
627
}
628
629
···
565
});
566
}
567
568
+
export const ATURI_PAGE_LIMIT = 100;
569
+
570
+
export interface AturiDirectoryAturisItem {
571
+
uri: string;
572
+
cid: string;
573
+
rkey: string;
574
+
}
575
+
576
+
export type AturiDirectoryAturis = AturiDirectoryAturisItem[];
577
+
578
+
export function constructAturiListQuery(aturilistservice: string, did: string, collection: string, reverse?: boolean) {
579
+
return queryOptions({
580
+
// A unique key for this query, including all parameters that affect the data.
581
+
queryKey: ["aturiList", did, collection, { reverse }],
582
+
583
+
// The function that fetches the data.
584
+
queryFn: async ({ pageParam }: QueryFunctionContext) => {
585
+
const cursor = pageParam as string | undefined;
586
+
587
+
// Use URLSearchParams for safe and clean URL construction.
588
+
const params = new URLSearchParams({
589
+
did,
590
+
collection,
591
+
});
592
+
593
+
if (cursor) {
594
+
params.set("cursor", cursor);
595
+
}
596
+
597
+
// Add the reverse parameter if it's true
598
+
if (reverse) {
599
+
params.set("reverse", "true");
600
+
}
601
+
602
+
const url = `https://${aturilistservice}/aturis?${params.toString()}`;
603
+
604
+
const res = await fetch(url);
605
+
if (!res.ok) {
606
+
// You can add more specific error handling here
607
+
throw new Error(`Failed to fetch AT-URI list for ${did}`);
608
+
}
609
+
610
+
return res.json() as Promise<AturiDirectoryAturis>;
611
+
},
612
+
});
613
+
}
614
+
615
+
export function useInfiniteQueryAturiList({aturilistservice, did, collection, reverse}:{aturilistservice: string, did: string | undefined, collection: string | undefined, reverse?: boolean}) {
616
+
// We only enable the query if both `did` and `collection` are provided.
617
+
const isEnabled = !!did && !!collection;
618
+
619
+
const { queryKey, queryFn } = constructAturiListQuery(aturilistservice, did!, collection!, reverse);
620
+
621
+
return useInfiniteQuery({
622
+
queryKey,
623
+
queryFn,
624
+
initialPageParam: undefined as never, // ???? what is this shit
625
+
626
+
// @ts-expect-error i wouldve used as null | undefined, anyways
627
+
getNextPageParam: (lastPage: AturiDirectoryAturis) => {
628
+
// If the last page returned no records, we're at the end.
629
+
if (!lastPage || lastPage.length === 0) {
630
+
return undefined;
631
+
}
632
+
633
+
// If the number of records is less than our page limit, it must be the last page.
634
+
if (lastPage.length < ATURI_PAGE_LIMIT) {
635
+
return undefined;
636
+
}
637
+
638
+
// The cursor for the next page is the `rkey` of the last item we received.
639
+
const lastItem = lastPage[lastPage.length - 1];
640
+
return lastItem.rkey;
641
+
},
642
+
643
+
enabled: isEnabled,
644
+
});
645
+
}
646
+
647
+
648
type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
649
650
export function constructInfiniteFeedSkeletonQuery(options: {
···
695
}) {
696
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
697
698
+
return {...useInfiniteQuery({
699
queryKey,
700
queryFn,
701
initialPageParam: undefined as never,
···
703
staleTime: Infinity,
704
refetchOnWindowFocus: false,
705
enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
706
+
}), queryKey: queryKey};
707
}
708
709
+1
-1
vite.config.ts
+1
-1
vite.config.ts