tangled
alpha
login
or
join now
whey.party
/
red-dwarf
82
fork
atom
an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
82
fork
atom
overview
issues
25
pulls
pipelines
reply indicator and parent chain
rimar1337
6 months ago
c64e32b7
1c939a15
+216
-94
6 changed files
expand all
collapse all
unified
split
index.html
src
components
UniversalPostRenderer.tsx
main.tsx
routes
__root.tsx
profile.$did
index.tsx
post.$rkey.tsx
+1
-1
index.html
···
12
12
<link rel="apple-touch-icon" href="/redstar.png" />
13
13
<link rel="manifest" href="/manifest.json" />
14
14
<link rel="stylesheet" href="/src/styles/app.css" />
15
15
-
<title>Red Dwarf lite</title>
15
15
+
<title>Red Dwarf</title>
16
16
</head>
17
17
<body>
18
18
<div id="app"></div>
+101
-29
src/components/UniversalPostRenderer.tsx
···
14
14
atUri: string;
15
15
onConstellation?: (data: any) => void;
16
16
detailed?: boolean;
17
17
+
bottomReplyLine?: boolean;
18
18
+
topReplyLine?: boolean;
19
19
+
bottomBorder?:boolean;
20
20
+
feedviewpost?:boolean;
17
21
}
18
22
19
23
export async function cachedGetRecord({
···
113
117
atUri,
114
118
onConstellation,
115
119
detailed = false,
120
120
+
bottomReplyLine,
121
121
+
topReplyLine,
122
122
+
bottomBorder= true,
123
123
+
feedviewpost = false,
116
124
}: UniversalPostRendererATURILoaderProps) {
117
125
console.log("atUri", atUri);
118
126
const { get, set } = usePersistentStore();
···
359
367
likesCount={likes}
360
368
repostsCount={reposts}
361
369
repliesCount={replies}
370
370
+
bottomReplyLine={bottomReplyLine}
371
371
+
topReplyLine={topReplyLine}
372
372
+
bottomBorder={bottomBorder}
373
373
+
feedviewpost={feedviewpost}
362
374
/>
363
375
);
364
376
}
···
372
384
repostsCount,
373
385
repliesCount,
374
386
detailed = false,
387
387
+
bottomReplyLine = false,
388
388
+
topReplyLine = false,
389
389
+
bottomBorder= true,
390
390
+
feedviewpost= false,
375
391
}: {
376
392
postRecord: any;
377
393
profileRecord: any;
···
381
397
repostsCount?: number | null;
382
398
repliesCount?: number | null;
383
399
detailed?: boolean;
400
400
+
bottomReplyLine?: boolean;
401
401
+
topReplyLine?: boolean;
402
402
+
bottomBorder?: boolean;
403
403
+
feedviewpost?: boolean;
384
404
}) {
385
405
const navigate = useNavigate();
386
406
···
458
478
459
479
const parsedaturi = parseAtUri(aturi);
460
480
481
481
+
const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(() => ({
482
482
+
$type: "app.bsky.feed.defs#postView",
483
483
+
uri: aturi,
484
484
+
cid: postRecord?.cid || "",
485
485
+
author: {
486
486
+
did: resolved?.did || "",
487
487
+
handle: resolved?.handle || "",
488
488
+
displayName: profileRecord?.value?.displayName || "",
489
489
+
avatar: getAvatarUrl(profileRecord) || "",
490
490
+
viewer: undefined,
491
491
+
labels: profileRecord?.labels || undefined,
492
492
+
verification: undefined,
493
493
+
},
494
494
+
record: postRecord?.value || {},
495
495
+
embed: hydratedEmbed ?? undefined,
496
496
+
replyCount: repliesCount ?? 0,
497
497
+
repostCount: repostsCount ?? 0,
498
498
+
likeCount: likesCount ?? 0,
499
499
+
quoteCount: 0,
500
500
+
indexedAt: postRecord?.value?.createdAt || "",
501
501
+
viewer: undefined,
502
502
+
labels: postRecord?.labels || undefined,
503
503
+
threadgate: undefined,
504
504
+
}), [
505
505
+
aturi,
506
506
+
postRecord,
507
507
+
profileRecord,
508
508
+
hydratedEmbed,
509
509
+
repliesCount,
510
510
+
repostsCount,
511
511
+
likesCount,
512
512
+
resolved,
513
513
+
]);
514
514
+
515
515
+
const [feedviewpostreplyhandle, setFeedviewpostreplyhandle] = useState<string | undefined>(undefined);
516
516
+
517
517
+
useEffect(() => {
518
518
+
if(!feedviewpost) return;
519
519
+
let cancelled = false;
520
520
+
521
521
+
const run = async () => {
522
522
+
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent?.uri;
523
523
+
const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
524
524
+
525
525
+
if (feedviewpostreplydid) {
526
526
+
const opi = await cachedResolveIdentity({
527
527
+
didOrHandle: feedviewpostreplydid,
528
528
+
get,
529
529
+
set,
530
530
+
});
531
531
+
532
532
+
if (!cancelled) {
533
533
+
setFeedviewpostreplyhandle(opi?.handle);
534
534
+
}
535
535
+
}
536
536
+
};
537
537
+
538
538
+
run();
539
539
+
540
540
+
return () => {
541
541
+
cancelled = true;
542
542
+
};
543
543
+
}, [fakepost, get, set]);
544
544
+
461
545
return (
462
546
<>
463
547
{/* <p>
···
484
568
});
485
569
}
486
570
}}
487
487
-
post={{
488
488
-
$type: "app.bsky.feed.defs#postView",
489
489
-
uri: aturi,
490
490
-
cid: postRecord?.cid || "",
491
491
-
author: {
492
492
-
did: resolved?.did || "",
493
493
-
handle: resolved?.handle || "",
494
494
-
displayName: profileRecord?.value?.displayName || "",
495
495
-
avatar: getAvatarUrl(profileRecord) || "",
496
496
-
viewer: undefined,
497
497
-
labels: profileRecord?.labels || undefined,
498
498
-
verification: undefined,
499
499
-
},
500
500
-
record: postRecord?.value || {},
501
501
-
embed: hydratedEmbed ?? undefined,
502
502
-
replyCount: repliesCount ?? 0,
503
503
-
repostCount: repostsCount ?? 0,
504
504
-
likeCount: likesCount ?? 0,
505
505
-
quoteCount: 0,
506
506
-
indexedAt: postRecord?.value?.createdAt || "",
507
507
-
viewer: undefined,
508
508
-
labels: postRecord?.labels || undefined,
509
509
-
threadgate: undefined,
510
510
-
}}
571
571
+
post={fakepost}
511
572
salt={aturi}
573
573
+
bottomReplyLine={bottomReplyLine}
574
574
+
topReplyLine={topReplyLine}
575
575
+
bottomBorder={bottomBorder}
576
576
+
//extraOptionalItemInfo={{reply: postRecord?.value?.reply as AppBskyFeedDefs.ReplyRef, post: fakepost}}
577
577
+
feedviewpostreplyhandle={feedviewpostreplyhandle}
512
578
/>
513
579
</>
514
580
);
···
1071
1137
AppBskyFeedDefs,
1072
1138
AppBskyFeedPost,
1073
1139
AppBskyGraphDefs,
1140
1140
+
AtUri,
1074
1141
//AppBskyLabelerDefs,
1075
1142
//AtUri,
1076
1143
//ComAtprotoRepoStrongRef,
···
1171
1238
topReplyLine,
1172
1239
salt,
1173
1240
bottomBorder = true,
1241
1241
+
feedviewpostreplyhandle,
1174
1242
}: {
1175
1243
post: PostView;
1176
1244
// optional for now because i havent ported every use to this yet
···
1187
1255
topReplyLine?: boolean;
1188
1256
salt: string;
1189
1257
bottomBorder?: boolean;
1258
1258
+
feedviewpostreplyhandle?: string;
1190
1259
}) {
1191
1260
const navigate = useNavigate();
1192
1261
const [hasRetweeted, setHasRetweeted] = useState<Boolean>(
···
1319
1388
//opacity: 0.5,
1320
1389
// no flex here
1321
1390
}}
1391
1391
+
className="bg-gray-500 dark:bg-gray-400"
1322
1392
/>
1323
1393
)}
1324
1394
<div
···
1375
1445
//background: theme.textSecondary,
1376
1446
opacity: 0.5,
1377
1447
// no flex here
1448
1448
+
//color: "Red",
1449
1449
+
//zIndex: 99
1378
1450
}}
1379
1379
-
className="text-gray-500 dark:text-gray-400"
1451
1451
+
className="bg-gray-500 dark:bg-gray-400"
1380
1452
/>
1381
1453
)}
1382
1454
{/* <div
···
1482
1554
</div>
1483
1555
</div>
1484
1556
{/* reply indicator */}
1485
1485
-
{false && isReply && (
1557
1557
+
{!!feedviewpostreplyhandle && (
1486
1558
<div
1487
1559
style={{
1488
1560
display: "flex",
···
1494
1566
gap: 4,
1495
1567
alignItems: "center",
1496
1568
//marginLeft: 36,
1497
1497
-
height: !(expanded || isQuote) && isReply ? "1rem" : 0,
1498
1498
-
opacity: !(expanded || isQuote) && isReply ? 1 : 0,
1569
1569
+
height: !(expanded || isQuote) && !!feedviewpostreplyhandle ? "1rem" : 0,
1570
1570
+
opacity: !(expanded || isQuote) && !!feedviewpostreplyhandle ? 1 : 0,
1499
1571
}}
1500
1572
className="text-gray-500 dark:text-gray-400"
1501
1573
>
1502
1502
-
<MdiReply /> Reply to some other post lmao
1574
1574
+
<MdiReply /> Reply to {feedviewpostreplyhandle}
1503
1575
</div>
1504
1576
)}
1505
1577
<div
+3
-3
src/main.tsx
···
31
31
const root = ReactDOM.createRoot(rootElement);
32
32
root.render(
33
33
// double queries annoys me
34
34
-
//<StrictMode>
35
35
-
<RouterProvider router={router} />,
36
36
-
//</StrictMode>,
34
34
+
<StrictMode>
35
35
+
<RouterProvider router={router} />
36
36
+
</StrictMode>
37
37
);
38
38
}
39
39
+13
-6
src/routes/__root.tsx
···
176
176
<img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" />
177
177
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
178
178
Red Dwarf{" "}
179
179
-
<span className="text-gray-500 dark:text-gray-400 text-sm">
179
179
+
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
180
180
lite
181
181
-
</span>
181
181
+
</span> */}
182
182
</span>
183
183
</div>
184
184
<Link
···
277
277
</button>
278
278
<div className="flex-1"></div>
279
279
<a
280
280
+
href="https://tangled.sh/@whey.party/red-dwarf"
281
281
+
target="_blank"
282
282
+
rel="noopener noreferrer"
283
283
+
className="mt-1 text-xs text-gray-400 dark:text-gray-500 text-center hover:underline"
284
284
+
>
285
285
+
git repo
286
286
+
</a>
287
287
+
<a
280
288
href="https://whey.party/"
281
289
target="_blank"
282
290
rel="noopener noreferrer"
···
339
347
340
348
<div className="flex-1"></div>
341
349
<p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4">
342
342
-
Red Dwarf lite is a bluesky client that uses Constellation and
343
343
-
direct PDS queries. Red Dwarf (without the lite) would be a
344
344
-
self-hosted bluesky "instance". Stay tuned for the "without the
345
345
-
lite" version.
350
350
+
Red Dwarf is a bluesky client that uses Constellation and
351
351
+
direct PDS queries. Skylite would be a
352
352
+
self-hosted bluesky "instance". Stay tuned for the release of Skylite.
346
353
</p>
347
354
</aside>
348
355
</div>
+1
src/routes/profile.$did/index.tsx
···
404
404
<UniversalPostRendererATURILoader
405
405
key={post.uri}
406
406
atUri={post.uri}
407
407
+
feedviewpost={true}
407
408
/>
408
409
);
409
410
})}
+97
-55
src/routes/profile.$did/post.$rkey.tsx
···
1
1
-
import { createFileRoute, Link } from "@tanstack/react-router";
2
2
-
import React from "react";
3
3
-
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
4
4
-
import { usePersistentStore } from "~/providers/PersistentStoreProvider";
1
1
+
import { createFileRoute, Link } from '@tanstack/react-router';
2
2
+
import React from 'react';
3
3
+
import { UniversalPostRendererATURILoader, cachedGetRecord } from '~/components/UniversalPostRenderer';
4
4
+
import { usePersistentStore } from '~/providers/PersistentStoreProvider';
5
5
6
6
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
7
7
8
8
-
export const Route = createFileRoute("/profile/$did/post/$rkey")({
8
8
+
export const Route = createFileRoute('/profile/$did/post/$rkey')({
9
9
component: RouterWrapper,
10
10
});
11
11
12
12
function RouterWrapper() {
13
13
const { did, rkey } = Route.useParams();
14
14
15
15
-
return (
16
16
-
<ProfilePostComponent
17
17
-
key={`/profile/${did}/post/${rkey}`}
18
18
-
did={did}
19
19
-
rkey={rkey}
20
20
-
/>
21
21
-
);
15
15
+
return <ProfilePostComponent key={`/profile/${did}/post/${rkey}`} did={did} rkey={rkey} />;
22
16
}
23
17
24
18
function ProfilePostComponent({ did, rkey }: { did: string; rkey: string }) {
···
26
20
const [resolvedDid, setResolvedDid] = React.useState<string | null>(null);
27
21
const [loading, setLoading] = React.useState(false);
28
22
const [error, setError] = React.useState<string | null>(null);
23
23
+
24
24
+
const [mainPost, setMainPost] = React.useState<any | null>(null);
25
25
+
const [parents, setParents] = React.useState<any[]>([]);
26
26
+
const [parentsLoading, setParentsLoading] = React.useState(false);
29
27
const [replies, setReplies] = React.useState<any[]>([]);
30
28
31
29
React.useEffect(() => {
···
35
33
setResolvedDid(null);
36
34
return;
37
35
}
38
38
-
if (did.startsWith("did:")) {
36
36
+
if (did.startsWith('did:')) {
39
37
setResolvedDid(did);
40
38
return;
41
39
}
···
44
42
const cacheKey = `handleDid:${did}`;
45
43
const now = Date.now();
46
44
const cached = await get(cacheKey); // <-- await here
47
47
-
if (
48
48
-
cached &&
49
49
-
cached.value &&
50
50
-
cached.time &&
51
51
-
now - cached.time < HANDLE_DID_CACHE_TIMEOUT
52
52
-
) {
45
45
+
if (cached && cached.value && cached.time && now - cached.time < HANDLE_DID_CACHE_TIMEOUT) {
53
46
try {
54
47
const data = JSON.parse(cached.value);
55
48
if (!ignore) setResolvedDid(data.did);
···
60
53
try {
61
54
const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(did)}`;
62
55
const res = await fetch(url);
63
63
-
if (!res.ok) throw new Error("Failed to resolve handle");
56
56
+
if (!res.ok) throw new Error('Failed to resolve handle');
64
57
const data = await res.json();
65
58
await set(cacheKey, JSON.stringify(data)); // <-- await here
66
59
if (!ignore) setResolvedDid(data.did);
67
60
} catch (e: any) {
68
68
-
if (!ignore) setError("Failed to resolve handle: " + (e?.message || e));
61
61
+
if (!ignore) setError('Failed to resolve handle: ' + (e?.message || e));
69
62
} finally {
70
63
setLoading(false);
71
64
}
···
76
69
};
77
70
}, [did, get, set]);
78
71
79
79
-
const atUri =
80
80
-
resolvedDid && rkey
81
81
-
? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}`
82
82
-
: "";
72
72
+
const atUri = resolvedDid && rkey ? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}` : '';
83
73
84
84
-
const handleConstellation = React.useCallback((data: any) => {}, []);
74
74
+
React.useEffect(() => {
75
75
+
if (!atUri) return;
76
76
+
let ignore = false;
77
77
+
async function fetchMainPost() {
78
78
+
try {
79
79
+
const postData = await cachedGetRecord({ atUri, get, set });
80
80
+
if (!ignore) {
81
81
+
setMainPost(postData);
82
82
+
}
83
83
+
} catch (e) {
84
84
+
console.error('Failed to fetch main post record:', e);
85
85
+
}
86
86
+
}
87
87
+
fetchMainPost();
88
88
+
return () => {
89
89
+
ignore = true;
90
90
+
};
91
91
+
}, [atUri, get, set]);
92
92
+
93
93
+
React.useEffect(() => {
94
94
+
if (!mainPost) return;
95
95
+
let ignore = false;
96
96
+
async function fetchParents() {
97
97
+
setParentsLoading(true);
98
98
+
const parentChain: any[] = [];
99
99
+
let currentParentUri = mainPost.value?.reply?.parent?.uri;
100
100
+
const MAX_PARENTS = 25; // Important to know theres a limit
101
101
+
let safetyCounter = 0;
102
102
+
103
103
+
while (currentParentUri && safetyCounter < MAX_PARENTS) {
104
104
+
try {
105
105
+
const parentPost = await cachedGetRecord({ atUri: currentParentUri, get, set });
106
106
+
if (!parentPost) break;
107
107
+
parentChain.push(parentPost);
108
108
+
currentParentUri = parentPost.value?.reply?.parent?.uri;
109
109
+
safetyCounter++;
110
110
+
} catch (error) {
111
111
+
console.error('Failed to fetch a parent post:', error);
112
112
+
break;
113
113
+
}
114
114
+
}
115
115
+
116
116
+
if (!ignore) {
117
117
+
setParents(parentChain.reverse());
118
118
+
setParentsLoading(false);
119
119
+
}
120
120
+
}
121
121
+
122
122
+
fetchParents();
123
123
+
return () => {
124
124
+
ignore = true;
125
125
+
};
126
126
+
}, [mainPost, get, set]);
85
127
86
128
React.useEffect(() => {
87
129
if (!atUri) return;
88
130
let ignore = false;
89
131
async function fetchReplies() {
90
132
try {
91
91
-
const url = `https://constellation.microcosm.blue/links?target=${encodeURIComponent(atUri)}&collection=app.bsky.feed.post&path=.reply.parent.uri`;
133
133
+
const url = `https://constellation.microcosm.blue/links?target=${encodeURIComponent(
134
134
+
atUri,
135
135
+
)}&collection=app.bsky.feed.post&path=.reply.parent.uri`;
92
136
const res = await fetch(url);
93
93
-
if (!res.ok) throw new Error("Failed to fetch replies");
137
137
+
if (!res.ok) throw new Error('Failed to fetch replies');
94
138
const data = await res.json();
95
139
if (!ignore && data.linking_records) {
96
140
setReplies(data.linking_records.slice(0, 50));
···
107
151
108
152
if (!did || !rkey) return <div>Invalid post URI</div>;
109
153
if (loading) return <div>Resolving handle...</div>;
110
110
-
if (error) return <div style={{ color: "red" }}>{error}</div>;
154
154
+
if (error) return <div style={{ color: 'red' }}>{error}</div>;
111
155
if (!atUri) return <div>Invalid post URI</div>;
112
112
-
113
113
-
console.log("atUri", atUri);
114
156
115
157
return (
116
158
<>
···
118
160
<Link
119
161
to=".."
120
162
className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
121
121
-
onClick={(e) => {
163
163
+
onClick={e => {
122
164
e.preventDefault();
123
123
-
window.history.length > 1
124
124
-
? window.history.back()
125
125
-
: window.location.assign("/");
165
165
+
window.history.length > 1 ? window.history.back() : window.location.assign('/');
126
166
}}
127
167
aria-label="Go back"
128
168
>
···
130
170
</Link>
131
171
<span className="text-xl font-bold ml-2">Post</span>
132
172
</div>
133
133
-
<UniversalPostRendererATURILoader
134
134
-
atUri={atUri}
135
135
-
onConstellation={handleConstellation}
136
136
-
detailed={true}
137
137
-
/>
173
173
+
174
174
+
{parentsLoading && <div className="p-4 text-center text-gray-500 dark:text-gray-400">Loading conversation...</div>}
175
175
+
176
176
+
{/* we should use the reply lines here thats provided by UPR*/}
177
177
+
<div style={{ maxWidth: 600, margin: '0px auto 0', padding: 0 }}>
178
178
+
{parents.map((parent, index) => (
179
179
+
<UniversalPostRendererATURILoader key={parent.uri} atUri={parent.uri}
180
180
+
topReplyLine={index > 0}
181
181
+
bottomReplyLine={true}
182
182
+
bottomBorder={false}
183
183
+
/>
184
184
+
))}
185
185
+
</div>
186
186
+
187
187
+
<UniversalPostRendererATURILoader atUri={atUri} detailed={true} topReplyLine={parents.length > 0} />
188
188
+
138
189
{replies.length > 0 && (
139
139
-
<div style={{ maxWidth: 600, margin: "0px auto 0", padding: 0 }}>
190
190
+
<div style={{ maxWidth: 600, margin: '0px auto 0', padding: 0 }}>
140
191
<div
141
192
className="text-gray-500 dark:text-gray-400 text-sm font-bold"
142
142
-
style={{
143
143
-
fontSize: 18,
144
144
-
margin: "12px 16px 12px 16px",
145
145
-
fontWeight: 600,
146
146
-
}}
193
193
+
style={{ fontSize: 18, margin: '12px 16px 12px 16px', fontWeight: 600 }}
147
194
>
148
195
Replies
149
196
</div>
150
150
-
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
151
151
-
{replies.map((reply, i) => {
197
197
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
198
198
+
{replies.map(reply => {
152
199
const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`;
153
153
-
return (
154
154
-
<UniversalPostRendererATURILoader
155
155
-
key={replyAtUri}
156
156
-
atUri={replyAtUri}
157
157
-
/>
158
158
-
);
200
200
+
return <UniversalPostRendererATURILoader key={replyAtUri} atUri={replyAtUri} />;
159
201
})}
160
202
</div>
161
203
</div>
162
204
)}
163
205
</>
164
206
);
165
165
-
}
207
207
+
}