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