+16
-39
src/components/UniversalPostRenderer.tsx
+16
-39
src/components/UniversalPostRenderer.tsx
···
10
10
composerAtom,
11
11
constellationURLAtom,
12
12
imgCDNAtom,
13
-
likedPostsAtom,
14
13
} from "~/utils/atoms";
15
14
import { useHydratedEmbed } from "~/utils/useHydrated";
16
15
import {
···
38
37
feedviewpost?: boolean;
39
38
repostedby?: string;
40
39
style?: React.CSSProperties;
41
-
ref?: React.Ref<HTMLDivElement>;
40
+
ref?: React.RefObject<HTMLDivElement>;
42
41
dataIndexPropPass?: number;
43
42
nopics?: boolean;
44
43
concise?: boolean;
···
659
658
feedviewpost?: boolean;
660
659
repostedby?: string;
661
660
style?: React.CSSProperties;
662
-
ref?: React.Ref<HTMLDivElement>;
661
+
ref?: React.RefObject<HTMLDivElement>;
663
662
dataIndexPropPass?: number;
664
663
nopics?: boolean;
665
664
concise?: boolean;
···
1206
1205
import { useAuth } from "~/providers/UnifiedAuthProvider";
1207
1206
import { FeedItemRenderAturiLoader, FollowButton, Mutual } from "~/routes/profile.$did";
1208
1207
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1208
+
import { useFastLike } from "~/utils/likeMutationQueue";
1209
1209
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1210
1210
// import type {
1211
1211
// ViewRecord,
···
1358
1358
depth?: number;
1359
1359
repostedby?: string;
1360
1360
style?: React.CSSProperties;
1361
-
ref?: React.Ref<HTMLDivElement>;
1361
+
ref?: React.RefObject<HTMLDivElement>;
1362
1362
dataIndexPropPass?: number;
1363
1363
nopics?: boolean;
1364
1364
concise?: boolean;
···
1367
1367
}) {
1368
1368
const parsed = new AtUri(post.uri);
1369
1369
const navigate = useNavigate();
1370
-
const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom);
1371
1370
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
1372
1371
post.viewer?.repost ? true : false
1373
1372
);
1374
-
const [hasLiked, setHasLiked] = useState<boolean>(
1375
-
post.uri in likedPosts || post.viewer?.like ? true : false
1376
-
);
1377
1373
const [, setComposerPost] = useAtom(composerAtom);
1378
1374
const { agent } = useAuth();
1379
-
const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like);
1380
1375
const [retweetUri, setRetweetUri] = useState<string | undefined>(
1381
1376
post.viewer?.repost
1382
1377
);
1383
-
1384
-
const likeOrUnlikePost = async () => {
1385
-
const newLikedPosts = { ...likedPosts };
1386
-
if (!agent) {
1387
-
console.error("Agent is null or undefined");
1388
-
return;
1389
-
}
1390
-
if (hasLiked) {
1391
-
if (post.uri in likedPosts) {
1392
-
const likeUri = likedPosts[post.uri];
1393
-
setLikeUri(likeUri);
1394
-
}
1395
-
if (likeUri) {
1396
-
await agent.deleteLike(likeUri);
1397
-
setHasLiked(false);
1398
-
delete newLikedPosts[post.uri];
1399
-
}
1400
-
} else {
1401
-
const { uri } = await agent.like(post.uri, post.cid);
1402
-
setLikeUri(uri);
1403
-
setHasLiked(true);
1404
-
newLikedPosts[post.uri] = uri;
1405
-
}
1406
-
setLikedPosts(newLikedPosts);
1407
-
};
1378
+
const { liked, toggle, backfill } = useFastLike(post.uri, post.cid);
1379
+
// const bovref = useBackfillOnView(post.uri, post.cid);
1380
+
// React.useLayoutEffect(()=>{
1381
+
// if (expanded && !isQuote) {
1382
+
// backfill();
1383
+
// }
1384
+
// },[backfill, expanded, isQuote])
1408
1385
1409
1386
const repostOrUnrepostPost = async () => {
1410
1387
if (!agent) {
···
1442
1419
const isMainItem = false;
1443
1420
const setMainItem = (any: any) => {};
1444
1421
// eslint-disable-next-line react-hooks/refs
1445
-
console.log("Received ref in UniversalPostRenderer:", ref);
1422
+
//console.log("Received ref in UniversalPostRenderer:", usedref);
1446
1423
return (
1447
1424
<div ref={ref} style={style} data-index={dataIndexPropPass}>
1448
1425
<div
···
1919
1896
</DropdownMenu.Root>
1920
1897
<HitSlopButton
1921
1898
onClick={() => {
1922
-
likeOrUnlikePost();
1899
+
toggle();
1923
1900
}}
1924
1901
style={{
1925
1902
...btnstyle,
1926
-
...(hasLiked ? { color: "#EC4899" } : {}),
1903
+
...(liked ? { color: "#EC4899" } : {}),
1927
1904
}}
1928
1905
>
1929
-
{hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
1930
-
{(post.likeCount || 0) + (hasLiked ? 1 : 0)}
1906
+
{liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
1907
+
{(post.likeCount || 0) + (liked ? 1 : 0)}
1931
1908
</HitSlopButton>
1932
1909
<div style={{ display: "flex", gap: 8 }}>
1933
1910
<HitSlopButton
+157
src/providers/LikeMutationQueueProvider.tsx
+157
src/providers/LikeMutationQueueProvider.tsx
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { TID } from "@atproto/common-web";
3
+
import { useQueryClient } from "@tanstack/react-query";
4
+
import { useAtom } from "jotai";
5
+
import React, { createContext, use, useCallback, useEffect, useRef } from "react";
6
+
7
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
8
+
import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms";
9
+
import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery";
10
+
11
+
export type LikeRecord = { uri: string; target: string; cid: string };
12
+
export type LikeMutation = { type: 'like'; target: string; cid: string };
13
+
export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord };
14
+
export type Mutation = LikeMutation | UnlikeMutation;
15
+
16
+
interface LikeMutationQueueContextType {
17
+
fastState: (target: string) => LikeRecord | null | undefined;
18
+
fastToggle: (target:string, cid:string) => void;
19
+
backfillState: (target: string, user: string) => Promise<void>;
20
+
}
21
+
22
+
const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined);
23
+
24
+
export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) {
25
+
const { agent } = useAuth();
26
+
const queryClient = useQueryClient();
27
+
const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom);
28
+
const [constellationurl] = useAtom(constellationURLAtom);
29
+
30
+
const likedPostsRef = useRef(likedPosts);
31
+
useEffect(() => {
32
+
likedPostsRef.current = likedPosts;
33
+
}, [likedPosts]);
34
+
35
+
const queueRef = useRef<Mutation[]>([]);
36
+
const runningRef = useRef(false);
37
+
38
+
const fastState = (target: string) => likedPosts[target];
39
+
40
+
const setFastState = useCallback(
41
+
(target: string, record: LikeRecord | null) =>
42
+
setLikedPosts((prev) => ({ ...prev, [target]: record })),
43
+
[setLikedPosts]
44
+
);
45
+
46
+
const enqueue = (mutation: Mutation) => queueRef.current.push(mutation);
47
+
48
+
const fastToggle = useCallback((target: string, cid: string) => {
49
+
const likedRecord = likedPostsRef.current[target];
50
+
51
+
if (likedRecord) {
52
+
setFastState(target, null);
53
+
if (likedRecord.uri !== 'pending') {
54
+
enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord });
55
+
}
56
+
} else {
57
+
setFastState(target, { uri: "pending", target, cid });
58
+
enqueue({ type: "like", target, cid });
59
+
}
60
+
}, [setFastState]);
61
+
62
+
/**
63
+
*
64
+
* @deprecated dont use it yet, will cause infinite rerenders
65
+
*/
66
+
const backfillState = async (target: string, user: string) => {
67
+
const query = constructConstellationQuery({
68
+
constellation: constellationurl,
69
+
method: "/links",
70
+
target,
71
+
collection: "app.bsky.feed.like",
72
+
path: ".subject.uri",
73
+
dids: [user],
74
+
});
75
+
const data = await queryClient.fetchQuery(query);
76
+
const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? [];
77
+
const found = likes.find((r) => r.did === user);
78
+
if (found) {
79
+
const uri = `at://${found.did}/${found.collection}/${found.rkey}`;
80
+
const ciddata = await queryClient.fetchQuery(
81
+
constructArbitraryQuery(uri)
82
+
);
83
+
if (ciddata?.cid)
84
+
setFastState(target, { uri, target, cid: ciddata?.cid });
85
+
} else {
86
+
setFastState(target, null);
87
+
}
88
+
};
89
+
90
+
91
+
useEffect(() => {
92
+
if (!agent?.did) return;
93
+
94
+
const processQueue = async () => {
95
+
if (runningRef.current || queueRef.current.length === 0) return;
96
+
runningRef.current = true;
97
+
98
+
while (queueRef.current.length > 0) {
99
+
const mutation = queueRef.current.shift()!;
100
+
try {
101
+
if (mutation.type === "like") {
102
+
const newRecord = {
103
+
repo: agent.did!,
104
+
collection: "app.bsky.feed.like",
105
+
rkey: TID.next().toString(),
106
+
record: {
107
+
$type: "app.bsky.feed.like",
108
+
subject: { uri: mutation.target, cid: mutation.cid },
109
+
createdAt: new Date().toISOString(),
110
+
},
111
+
};
112
+
const response = await agent.com.atproto.repo.createRecord(newRecord);
113
+
if (!response.success) throw new Error("createRecord failed");
114
+
115
+
const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`;
116
+
setFastState(mutation.target, {
117
+
uri,
118
+
target: mutation.target,
119
+
cid: mutation.cid,
120
+
});
121
+
} else if (mutation.type === "unlike") {
122
+
const aturi = new AtUri(mutation.likeRecordUri);
123
+
await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey });
124
+
setFastState(mutation.target, null);
125
+
}
126
+
} catch (err) {
127
+
console.error("Like mutation failed, reverting:", err);
128
+
if (mutation.type === 'like') {
129
+
setFastState(mutation.target, null);
130
+
} else if (mutation.type === 'unlike') {
131
+
setFastState(mutation.target, mutation.originalRecord);
132
+
}
133
+
}
134
+
}
135
+
runningRef.current = false;
136
+
};
137
+
138
+
const interval = setInterval(processQueue, 1000);
139
+
return () => clearInterval(interval);
140
+
}, [agent, setFastState]);
141
+
142
+
const value = { fastState, fastToggle, backfillState };
143
+
144
+
return (
145
+
<LikeMutationQueueContext value={value}>
146
+
{children}
147
+
</LikeMutationQueueContext>
148
+
);
149
+
}
150
+
151
+
export function useLikeMutationQueue() {
152
+
const context = use(LikeMutationQueueContext);
153
+
if (context === undefined) {
154
+
throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider');
155
+
}
156
+
return context;
157
+
}
+8
-5
src/routes/__root.tsx
+8
-5
src/routes/__root.tsx
···
22
22
import Login from "~/components/Login";
23
23
import { NotFound } from "~/components/NotFound";
24
24
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
25
+
import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider";
25
26
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
26
27
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
27
28
import { seo } from "~/utils/seo";
···
79
80
function RootComponent() {
80
81
return (
81
82
<UnifiedAuthProvider>
82
-
<RootDocument>
83
-
<KeepAliveProvider>
84
-
<KeepAliveOutlet />
85
-
</KeepAliveProvider>
86
-
</RootDocument>
83
+
<LikeMutationQueueProvider>
84
+
<RootDocument>
85
+
<KeepAliveProvider>
86
+
<KeepAliveOutlet />
87
+
</KeepAliveProvider>
88
+
</RootDocument>
89
+
</LikeMutationQueueProvider>
87
90
</UnifiedAuthProvider>
88
91
);
89
92
}
+40
-20
src/routes/profile.$did/index.tsx
+40
-20
src/routes/profile.$did/index.tsx
···
22
22
useGetFollowState,
23
23
useGetOneToOneState,
24
24
} from "~/utils/followState";
25
+
import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue";
25
26
import {
26
27
useInfiniteQueryAuthorFeed,
27
28
useQueryArbitrary,
···
454
455
}
455
456
456
457
const { data: likes } = useQueryConstellation(
457
-
// @ts-expect-error overloads sucks
458
+
// @ts-expect-error overloads sucks
458
459
!listmode
459
460
? {
460
461
target: feed.uri,
···
470
471
className={`px-4 py-4 ${!disableBottomBorder && "border-b"} flex flex-col gap-1`}
471
472
to="/profile/$did/feed/$rkey"
472
473
params={{ did: aturi.host, rkey: aturi.rkey }}
473
-
onClick={(e)=>{e.stopPropagation();}}
474
+
onClick={(e) => {
475
+
e.stopPropagation();
476
+
}}
474
477
>
475
478
<div className="flex flex-row gap-3">
476
479
<div className="min-w-10 min-h-10">
···
574
577
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
575
578
576
579
const {
577
-
data: repostsData,
580
+
data: likesData,
578
581
fetchNextPage,
579
582
hasNextPage,
580
583
isFetchingNextPage,
···
585
588
"app.bsky.feed.like"
586
589
);
587
590
588
-
const reposts = React.useMemo(
589
-
() => repostsData?.pages.flatMap((page) => page.records) ?? [],
590
-
[repostsData]
591
+
const likes = React.useMemo(
592
+
() => likesData?.pages.flatMap((page) => page.records) ?? [],
593
+
[likesData]
591
594
);
592
595
596
+
const { setFastState } = useFastSetLikesFromFeed();
597
+
const seededRef = React.useRef(new Set<string>());
598
+
599
+
useEffect(() => {
600
+
for (const like of likes) {
601
+
if (!seededRef.current.has(like.uri)) {
602
+
seededRef.current.add(like.uri);
603
+
const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
604
+
setFastState(record.subject.uri, {
605
+
target: record.subject.uri,
606
+
uri: like.uri,
607
+
cid: like.cid,
608
+
});
609
+
}
610
+
}
611
+
}, [likes, setFastState]);
612
+
593
613
return (
594
614
<>
595
615
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
596
616
Likes
597
617
</div>
598
618
<div>
599
-
{reposts.map((repost) => {
619
+
{likes.map((like) => {
600
620
if (
601
-
!repost ||
602
-
!repost?.value ||
603
-
!repost?.value?.subject ||
621
+
!like ||
622
+
!like?.value ||
623
+
!like?.value?.subject ||
604
624
// @ts-expect-error blehhhhh
605
-
!repost?.value?.subject?.uri
625
+
!like?.value?.subject?.uri
606
626
)
607
627
return;
608
-
const repostRecord =
609
-
repost.value as unknown as ATPAPI.AppBskyFeedLike.Record;
628
+
const likeRecord =
629
+
like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
610
630
return (
611
631
<UniversalPostRendererATURILoader
612
-
key={repostRecord.subject.uri}
613
-
atUri={repostRecord.subject.uri}
632
+
key={likeRecord.subject.uri}
633
+
atUri={likeRecord.subject.uri}
614
634
feedviewpost={true}
615
635
/>
616
636
);
···
618
638
</div>
619
639
620
640
{/* Loading and "Load More" states */}
621
-
{arePostsLoading && reposts.length === 0 && (
622
-
<div className="p-4 text-center text-gray-500">Loading posts...</div>
641
+
{arePostsLoading && likes.length === 0 && (
642
+
<div className="p-4 text-center text-gray-500">Loading likes...</div>
623
643
)}
624
644
{isFetchingNextPage && (
625
645
<div className="p-4 text-center text-gray-500">Loading more...</div>
···
629
649
onClick={() => fetchNextPage()}
630
650
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
631
651
>
632
-
Load More Posts
652
+
Load More Likes
633
653
</button>
634
654
)}
635
-
{reposts.length === 0 && !arePostsLoading && (
636
-
<div className="p-4 text-center text-gray-500">No posts found.</div>
655
+
{likes.length === 0 && !arePostsLoading && (
656
+
<div className="p-4 text-center text-gray-500">No likes found.</div>
637
657
)}
638
658
</>
639
659
);
+11
src/utils/atoms.ts
+11
src/utils/atoms.ts
···
59
59
{}
60
60
);
61
61
62
+
export type LikeRecord = {
63
+
uri: string; // at://did/collection/rkey
64
+
target: string;
65
+
cid: string;
66
+
};
67
+
68
+
export const internalLikedPostsAtom = atomWithStorage<Record<string, LikeRecord | null>>(
69
+
"internal-liked-posts",
70
+
{}
71
+
);
72
+
62
73
export const defaultconstellationURL = "constellation.microcosm.blue";
63
74
export const constellationURLAtom = atomWithStorage<string>(
64
75
"constellationURL",
+34
src/utils/likeMutationQueue.ts
+34
src/utils/likeMutationQueue.ts
···
1
+
import { useAtom } from "jotai";
2
+
import { useCallback } from "react";
3
+
4
+
import { type LikeRecord,useLikeMutationQueue as useLikeMutationQueueFromProvider } from "~/providers/LikeMutationQueueProvider";
5
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
+
7
+
import { internalLikedPostsAtom } from "./atoms";
8
+
9
+
export function useFastLike(target: string, cid: string) {
10
+
const { agent } = useAuth();
11
+
const { fastState, fastToggle, backfillState } = useLikeMutationQueueFromProvider();
12
+
13
+
const liked = fastState(target);
14
+
const toggle = () => fastToggle(target, cid);
15
+
/**
16
+
*
17
+
* @deprecated dont use it yet, will cause infinite rerenders
18
+
*/
19
+
const backfill = () => agent?.did && backfillState(target, agent.did);
20
+
21
+
return { liked, toggle, backfill };
22
+
}
23
+
24
+
export function useFastSetLikesFromFeed() {
25
+
const [_, setLikedPosts] = useAtom(internalLikedPostsAtom);
26
+
27
+
const setFastState = useCallback(
28
+
(target: string, record: LikeRecord | null) =>
29
+
setLikedPosts((prev) => ({ ...prev, [target]: record })),
30
+
[setLikedPosts]
31
+
);
32
+
33
+
return { setFastState };
34
+
}