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
desktop hover profile
rimar1337
4 months ago
24dd0e22
fa673e49
+256
-100
5 changed files
expand all
collapse all
unified
split
package-lock.json
package.json
src
components
UniversalPostRenderer.tsx
routes
profile.$did
index.tsx
utils
followState.ts
+2
package-lock.json
···
10
10
"@atproto/oauth-client-browser": "^0.3.33",
11
11
"@radix-ui/react-dialog": "^1.1.15",
12
12
"@radix-ui/react-dropdown-menu": "^2.1.16",
13
13
+
"@radix-ui/react-hover-card": "^1.1.15",
13
14
"@radix-ui/react-slider": "^1.3.6",
14
15
"@tailwindcss/vite": "^4.0.6",
15
16
"@tanstack/query-sync-storage-persister": "^5.85.6",
···
2402
2403
"version": "1.1.15",
2403
2404
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
2404
2405
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
2406
2406
+
"license": "MIT",
2405
2407
"dependencies": {
2406
2408
"@radix-ui/primitive": "1.1.3",
2407
2409
"@radix-ui/react-compose-refs": "1.1.2",
+1
package.json
···
14
14
"@atproto/oauth-client-browser": "^0.3.33",
15
15
"@radix-ui/react-dialog": "^1.1.15",
16
16
"@radix-ui/react-dropdown-menu": "^2.1.16",
17
17
+
"@radix-ui/react-hover-card": "^1.1.15",
17
18
"@radix-ui/react-slider": "^1.3.6",
18
19
"@tailwindcss/vite": "^4.0.6",
19
20
"@tanstack/query-sync-storage-persister": "^5.85.6",
+123
-53
src/components/UniversalPostRenderer.tsx
···
2
2
import DOMPurify from "dompurify";
3
3
import { useAtom } from "jotai";
4
4
import { DropdownMenu } from "radix-ui";
5
5
+
import { HoverCard } from "radix-ui";
5
6
import * as React from "react";
6
7
import { type SVGProps } from "react";
7
8
8
8
-
import { composerAtom, constellationURLAtom, imgCDNAtom, likedPostsAtom } from "~/utils/atoms";
9
9
+
import {
10
10
+
composerAtom,
11
11
+
constellationURLAtom,
12
12
+
imgCDNAtom,
13
13
+
likedPostsAtom,
14
14
+
} from "~/utils/atoms";
9
15
import { useHydratedEmbed } from "~/utils/useHydrated";
10
16
import {
11
17
useQueryConstellation,
···
403
409
// path: ".reply.parent.uri",
404
410
// });
405
411
406
406
-
const [constellationurl] = useAtom(constellationURLAtom)
412
412
+
const [constellationurl] = useAtom(constellationURLAtom);
407
413
408
414
const infinitequeryresults = useInfiniteQuery({
409
415
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
···
725
731
error: embedError,
726
732
} = useHydratedEmbed(postRecord?.value?.embed, resolved?.did);
727
733
728
728
-
const [imgcdn] = useAtom(imgCDNAtom)
734
734
+
const [imgcdn] = useAtom(imgCDNAtom);
729
735
730
736
const parsedaturi = new AtUri(aturi); //parseAtUri(aturi);
731
737
738
738
+
const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>(
739
739
+
() => ({
740
740
+
did: resolved?.did || "",
741
741
+
handle: resolved?.handle || "",
742
742
+
displayName: profileRecord?.value?.displayName || "",
743
743
+
avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "",
744
744
+
viewer: undefined,
745
745
+
labels: profileRecord?.labels || undefined,
746
746
+
verification: undefined,
747
747
+
}),
748
748
+
[imgcdn, profileRecord, resolved?.did, resolved?.handle]
749
749
+
);
750
750
+
751
751
+
const fakeprofileviewdetailed =
752
752
+
React.useMemo<AppBskyActorDefs.ProfileViewDetailed>(
753
753
+
() => ({
754
754
+
...fakeprofileviewbasic,
755
755
+
$type: "app.bsky.actor.defs#profileViewDetailed",
756
756
+
description: profileRecord?.value?.description || undefined,
757
757
+
}),
758
758
+
[fakeprofileviewbasic, profileRecord?.value?.description]
759
759
+
);
760
760
+
732
761
const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(
733
762
() => ({
734
763
$type: "app.bsky.feed.defs#postView",
735
764
uri: aturi,
736
765
cid: postRecord?.cid || "",
737
737
-
author: {
738
738
-
did: resolved?.did || "",
739
739
-
handle: resolved?.handle || "",
740
740
-
displayName: profileRecord?.value?.displayName || "",
741
741
-
avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "",
742
742
-
viewer: undefined,
743
743
-
labels: profileRecord?.labels || undefined,
744
744
-
verification: undefined,
745
745
-
},
766
766
+
author: fakeprofileviewbasic,
746
767
record: postRecord?.value || {},
747
768
embed: hydratedEmbed ?? undefined,
748
769
replyCount: repliesCount ?? 0,
···
759
780
postRecord?.cid,
760
781
postRecord?.value,
761
782
postRecord?.labels,
762
762
-
resolved?.did,
763
763
-
resolved?.handle,
764
764
-
profileRecord,
783
783
+
fakeprofileviewbasic,
765
784
hydratedEmbed,
766
785
repliesCount,
767
786
repostsCount,
768
787
likesCount,
769
769
-
imgcdn
770
788
]
771
789
);
772
790
···
839
857
}
840
858
}}
841
859
post={fakepost}
860
860
+
uprrrsauthor={fakeprofileviewdetailed}
842
861
salt={aturi}
843
862
bottomReplyLine={bottomReplyLine}
844
863
topReplyLine={topReplyLine}
···
1143
1162
//import Masonry from "@mui/lab/Masonry";
1144
1163
import {
1145
1164
type $Typed,
1165
1165
+
AppBskyActorDefs,
1146
1166
AppBskyEmbedDefs,
1147
1167
AppBskyEmbedExternal,
1148
1168
AppBskyEmbedImages,
···
1172
1192
1173
1193
import defaultpfp from "~/../public/favicon.png";
1174
1194
import { useAuth } from "~/providers/UnifiedAuthProvider";
1195
1195
+
import { FollowButton, Mutual } from "~/routes/profile.$did";
1175
1196
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1176
1197
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1177
1198
// import type {
···
1280
1301
1281
1302
function UniversalPostRenderer({
1282
1303
post,
1304
1304
+
uprrrsauthor,
1283
1305
//setMainItem,
1284
1306
//isMainItem,
1285
1307
onPostClick,
···
1304
1326
maxReplies,
1305
1327
}: {
1306
1328
post: PostView;
1329
1329
+
uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed;
1307
1330
// optional for now because i havent ported every use to this yet
1308
1331
// setMainItem?: React.Dispatch<
1309
1332
// React.SetStateAction<AppBskyFeedDefs.FeedViewPost>
···
1487
1510
className="bg-gray-500 dark:bg-gray-400"
1488
1511
/>
1489
1512
)}
1490
1490
-
<div
1491
1491
-
style={{
1492
1492
-
position: "absolute",
1493
1493
-
//top: isRepost ? "calc(16px + 1rem)" : 16,
1494
1494
-
//left: 16,
1495
1495
-
zIndex: 1,
1496
1496
-
top: isRepost
1497
1497
-
? "calc(16px + 1rem)"
1498
1498
-
: isQuote
1499
1499
-
? 12
1500
1500
-
: topReplyLine
1501
1501
-
? 8
1502
1502
-
: 16,
1503
1503
-
left: isQuote ? 12 : 16,
1504
1504
-
}}
1505
1505
-
onClick={onProfileClick}
1506
1506
-
>
1507
1507
-
<img
1508
1508
-
src={post.author.avatar || defaultpfp}
1509
1509
-
alt="avatar"
1510
1510
-
// transition={{
1511
1511
-
// type: "spring",
1512
1512
-
// stiffness: 260,
1513
1513
-
// damping: 20,
1514
1514
-
// }}
1515
1515
-
style={{
1516
1516
-
borderRadius: "50%",
1517
1517
-
marginRight: 12,
1518
1518
-
objectFit: "cover",
1519
1519
-
//background: theme.border,
1520
1520
-
//border: `1px solid ${theme.border}`,
1521
1521
-
width: isQuote ? 16 : 42,
1522
1522
-
height: isQuote ? 16 : 42,
1523
1523
-
}}
1524
1524
-
className="border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1525
1525
-
/>
1526
1526
-
</div>
1513
1513
+
<HoverCard.Root>
1514
1514
+
<HoverCard.Trigger asChild>
1515
1515
+
<div
1516
1516
+
className={`absolute`}
1517
1517
+
style={{
1518
1518
+
top: isRepost
1519
1519
+
? "calc(16px + 1rem)"
1520
1520
+
: isQuote
1521
1521
+
? 12
1522
1522
+
: topReplyLine
1523
1523
+
? 8
1524
1524
+
: 16,
1525
1525
+
left: isQuote ? 12 : 16,
1526
1526
+
}}
1527
1527
+
onClick={onProfileClick}
1528
1528
+
>
1529
1529
+
<img
1530
1530
+
src={post.author.avatar || defaultpfp}
1531
1531
+
alt="avatar"
1532
1532
+
className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
1533
1533
+
style={{
1534
1534
+
width: isQuote ? 16 : 42,
1535
1535
+
height: isQuote ? 16 : 42,
1536
1536
+
}}
1537
1537
+
/>
1538
1538
+
</div>
1539
1539
+
</HoverCard.Trigger>
1540
1540
+
<HoverCard.Portal>
1541
1541
+
<HoverCard.Content
1542
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
1543
+
side={"bottom"}
1544
1544
+
sideOffset={5}
1545
1545
+
>
1546
1546
+
<div className="flex flex-col gap-2">
1547
1547
+
<div className="flex flex-row">
1548
1548
+
<img
1549
1549
+
src={post.author.avatar || defaultpfp}
1550
1550
+
alt="avatar"
1551
1551
+
className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1552
1552
+
/>
1553
1553
+
<div className=" flex-1 flex flex-row align-middle justify-end">
1554
1554
+
<FollowButton targetdidorhandle={post.author.did} />
1555
1555
+
</div>
1556
1556
+
</div>
1557
1557
+
<div className="flex flex-col gap-3">
1558
1558
+
<div>
1559
1559
+
<div className="text-gray-900 dark:text-gray-100 font-medium text-md">
1560
1560
+
{post.author.displayName || post.author.handle}{" "}
1561
1561
+
</div>
1562
1562
+
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1563
1563
+
<Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "}
1564
1564
+
</div>
1565
1565
+
</div>
1566
1566
+
{uprrrsauthor?.description && (
1567
1567
+
<div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3">
1568
1568
+
{uprrrsauthor.description}
1569
1569
+
</div>
1570
1570
+
)}
1571
1571
+
{/* <div className="flex gap-4">
1572
1572
+
<div className="flex gap-1">
1573
1573
+
<div className="font-medium text-gray-900 dark:text-gray-100">
1574
1574
+
0
1575
1575
+
</div>
1576
1576
+
<div className="text-gray-500 dark:text-gray-400">
1577
1577
+
Following
1578
1578
+
</div>
1579
1579
+
</div>
1580
1580
+
<div className="flex gap-1">
1581
1581
+
<div className="font-medium text-gray-900 dark:text-gray-100">
1582
1582
+
2,900
1583
1583
+
</div>
1584
1584
+
<div className="text-gray-500 dark:text-gray-400">
1585
1585
+
Followers
1586
1586
+
</div>
1587
1587
+
</div>
1588
1588
+
</div> */}
1589
1589
+
</div>
1590
1590
+
</div>
1591
1591
+
1592
1592
+
{/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */}
1593
1593
+
</HoverCard.Content>
1594
1594
+
</HoverCard.Portal>
1595
1595
+
</HoverCard.Root>
1596
1596
+
1527
1597
<div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}>
1528
1598
<div
1529
1599
style={{
+97
-47
src/routes/profile.$did/index.tsx
···
7
7
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
8
8
import { useAuth } from "~/providers/UnifiedAuthProvider";
9
9
import { imgCDNAtom } from "~/utils/atoms";
10
10
-
import { toggleFollow, useGetFollowState } from "~/utils/followState";
10
10
+
import { toggleFollow, useGetFollowState, useGetOneToOneState } from "~/utils/followState";
11
11
import {
12
12
useInfiniteQueryAuthorFeed,
13
13
useQueryIdentity,
···
22
22
// booo bad this is not always the did it might be a handle, use identity.did instead
23
23
const { did } = Route.useParams();
24
24
const queryClient = useQueryClient();
25
25
-
const { agent } = useAuth();
26
25
const {
27
26
data: identity,
28
27
isLoading: isIdentityLoading,
29
28
error: identityError,
30
29
} = useQueryIdentity(did);
31
31
-
32
32
-
const followRecords = useGetFollowState({
33
33
-
target: identity?.did || did,
34
34
-
user: agent?.did,
35
35
-
});
36
30
37
31
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
38
32
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
···
68
62
() => postsData?.pages.flatMap((page) => page.records) ?? [],
69
63
[postsData]
70
64
);
71
71
-
72
72
-
const [imgcdn] = useAtom(imgCDNAtom)
65
65
+
66
66
+
const [imgcdn] = useAtom(imgCDNAtom);
73
67
74
68
function getAvatarUrl(p: typeof profile) {
75
69
const link = p?.avatar?.ref?.["$link"];
···
166
160
also delay the backfill to be on demand because it would be pretty intense
167
161
also save it persistently
168
162
*/}
169
169
-
{identity?.did !== agent?.did ? (
170
170
-
<>
171
171
-
{!(followRecords?.length && followRecords?.length > 0) ? (
172
172
-
<button
173
173
-
onClick={() =>
174
174
-
toggleFollow({
175
175
-
agent: agent || undefined,
176
176
-
targetDid: identity?.did,
177
177
-
followRecords: followRecords,
178
178
-
queryClient: queryClient,
179
179
-
})
180
180
-
}
181
181
-
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
182
182
-
>
183
183
-
Follow
184
184
-
</button>
185
185
-
) : (
186
186
-
<button
187
187
-
onClick={() =>
188
188
-
toggleFollow({
189
189
-
agent: agent || undefined,
190
190
-
targetDid: identity?.did,
191
191
-
followRecords: followRecords,
192
192
-
queryClient: queryClient,
193
193
-
})
194
194
-
}
195
195
-
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
196
196
-
>
197
197
-
Unfollow
198
198
-
</button>
199
199
-
)}
200
200
-
</>
201
201
-
) : (
202
202
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
203
203
-
Edit Profile
204
204
-
</button>
205
205
-
)}
163
163
+
<FollowButton targetdidorhandle={did} />
206
164
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
207
165
... {/* todo: icon */}
208
166
</button>
···
211
169
{/* Info Card */}
212
170
<div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100">
213
171
<div className="font-bold text-2xl">{displayName}</div>
214
214
-
<div className="text-gray-500 dark:text-gray-400 text-base mb-3">
172
172
+
<div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1">
173
173
+
<Mutual targetdidorhandle={did} />
215
174
{handle}
216
175
</div>
217
176
{description && (
···
259
218
</>
260
219
);
261
220
}
221
221
+
222
222
+
export function FollowButton({targetdidorhandle}:{targetdidorhandle: string}) {
223
223
+
const {agent} = useAuth()
224
224
+
const {data: identity} = useQueryIdentity(targetdidorhandle);
225
225
+
const queryClient = useQueryClient();
226
226
+
227
227
+
const followRecords = useGetFollowState({
228
228
+
target: identity?.did ?? targetdidorhandle,
229
229
+
user: agent?.did,
230
230
+
});
231
231
+
232
232
+
return (
233
233
+
<>
234
234
+
{identity?.did !== agent?.did ? (
235
235
+
<>
236
236
+
{!(followRecords?.length && followRecords?.length > 0) ? (
237
237
+
<button
238
238
+
onClick={(e) =>
239
239
+
{
240
240
+
e.stopPropagation();
241
241
+
toggleFollow({
242
242
+
agent: agent || undefined,
243
243
+
targetDid: identity?.did,
244
244
+
followRecords: followRecords,
245
245
+
queryClient: queryClient,
246
246
+
})
247
247
+
}
248
248
+
}
249
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
250
+
>
251
251
+
Follow
252
252
+
</button>
253
253
+
) : (
254
254
+
<button
255
255
+
onClick={(e) =>
256
256
+
{
257
257
+
e.stopPropagation();
258
258
+
toggleFollow({
259
259
+
agent: agent || undefined,
260
260
+
targetDid: identity?.did,
261
261
+
followRecords: followRecords,
262
262
+
queryClient: queryClient,
263
263
+
})
264
264
+
}
265
265
+
}
266
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
267
+
>
268
268
+
Unfollow
269
269
+
</button>
270
270
+
)}
271
271
+
</>
272
272
+
) : (
273
273
+
<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]">
274
274
+
Edit Profile
275
275
+
</button>
276
276
+
)}
277
277
+
</>
278
278
+
);
279
279
+
}
280
280
+
281
281
+
282
282
+
export function Mutual({targetdidorhandle}:{targetdidorhandle: string}) {
283
283
+
const {agent} = useAuth()
284
284
+
const {data: identity} = useQueryIdentity(targetdidorhandle);
285
285
+
286
286
+
const mutualfollows = useGetOneToOneState(agent?.did ? {
287
287
+
target: agent?.did,
288
288
+
user: identity?.did ?? targetdidorhandle,
289
289
+
collection: "app.bsky.graph.follow",
290
290
+
path: ".subject"
291
291
+
}:undefined);
292
292
+
293
293
+
const ismutual: boolean = (!!mutualfollows?.length && mutualfollows.length > 0)
294
294
+
295
295
+
return (
296
296
+
<>
297
297
+
{identity?.did !== agent?.did ? (
298
298
+
<>
299
299
+
{!(ismutual) ? (
300
300
+
<></>
301
301
+
) : (
302
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
303
+
)}
304
304
+
</>
305
305
+
) : (
306
306
+
// lmao can someone be mutuals with themselves ??
307
307
+
<></>
308
308
+
)}
309
309
+
</>
310
310
+
);
311
311
+
}
+33
src/utils/followState.ts
···
128
128
};
129
129
});
130
130
}
131
131
+
132
132
+
133
133
+
134
134
+
export function useGetOneToOneState(params?: {
135
135
+
target: string;
136
136
+
user: string;
137
137
+
collection: string;
138
138
+
path: string;
139
139
+
}): string[] | undefined {
140
140
+
const { data: arbitrarydata } = useQueryConstellation(
141
141
+
params && params.user
142
142
+
? {
143
143
+
method: "/links",
144
144
+
target: params.target,
145
145
+
// @ts-expect-error overloading sucks so much
146
146
+
collection: params.collection,
147
147
+
path: params.path,
148
148
+
dids: [params.user],
149
149
+
}
150
150
+
: { method: "undefined", target: "whatever" }
151
151
+
// overloading sucks so much
152
152
+
) as { data: linksRecordsResponse | undefined };
153
153
+
if (!params || !params.user) return undefined;
154
154
+
const data = arbitrarydata?.linking_records.slice(0, 50) ?? [];
155
155
+
156
156
+
if (data.length > 0) {
157
157
+
return data.map((linksRecord) => {
158
158
+
return `at://${linksRecord.did}/${linksRecord.collection}/${linksRecord.rkey}`;
159
159
+
});
160
160
+
}
161
161
+
162
162
+
return undefined;
163
163
+
}