+2
package-lock.json
+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
+
"@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
+
"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
+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
+
"@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
+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
+
import { HoverCard } from "radix-ui";
5
6
import * as React from "react";
6
7
import { type SVGProps } from "react";
7
8
8
-
import { composerAtom, constellationURLAtom, imgCDNAtom, likedPostsAtom } from "~/utils/atoms";
9
+
import {
10
+
composerAtom,
11
+
constellationURLAtom,
12
+
imgCDNAtom,
13
+
likedPostsAtom,
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
-
const [constellationurl] = useAtom(constellationURLAtom)
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
-
const [imgcdn] = useAtom(imgCDNAtom)
734
+
const [imgcdn] = useAtom(imgCDNAtom);
729
735
730
736
const parsedaturi = new AtUri(aturi); //parseAtUri(aturi);
731
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
+
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
-
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
-
},
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
-
resolved?.did,
763
-
resolved?.handle,
764
-
profileRecord,
783
+
fakeprofileviewbasic,
765
784
hydratedEmbed,
766
785
repliesCount,
767
786
repostsCount,
768
787
likesCount,
769
-
imgcdn
770
788
]
771
789
);
772
790
···
839
857
}
840
858
}}
841
859
post={fakepost}
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
+
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
+
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
+
uprrrsauthor,
1283
1305
//setMainItem,
1284
1306
//isMainItem,
1285
1307
onPostClick,
···
1304
1326
maxReplies,
1305
1327
}: {
1306
1328
post: PostView;
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
-
<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>
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
+
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
+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
-
import { toggleFollow, useGetFollowState } from "~/utils/followState";
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
-
const { agent } = useAuth();
26
25
const {
27
26
data: identity,
28
27
isLoading: isIdentityLoading,
29
28
error: identityError,
30
29
} = useQueryIdentity(did);
31
-
32
-
const followRecords = useGetFollowState({
33
-
target: identity?.did || did,
34
-
user: agent?.did,
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
-
72
-
const [imgcdn] = useAtom(imgCDNAtom)
65
+
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
-
{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
-
)}
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
-
<div className="text-gray-500 dark:text-gray-400 text-base mb-3">
172
+
<div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1">
173
+
<Mutual targetdidorhandle={did} />
215
174
{handle}
216
175
</div>
217
176
{description && (
···
259
218
</>
260
219
);
261
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
+33
src/utils/followState.ts
···
128
128
};
129
129
});
130
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
+
}