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
background like mutation
rimar1337
4 months ago
2f1eae19
24efdc83
+266
-64
6 changed files
expand all
collapse all
unified
split
src
components
UniversalPostRenderer.tsx
providers
LikeMutationQueueProvider.tsx
routes
__root.tsx
profile.$did
index.tsx
utils
atoms.ts
likeMutationQueue.ts
+16
-39
src/components/UniversalPostRenderer.tsx
···
10
10
composerAtom,
11
11
constellationURLAtom,
12
12
imgCDNAtom,
13
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
41
-
ref?: React.Ref<HTMLDivElement>;
40
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
662
-
ref?: React.Ref<HTMLDivElement>;
661
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
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
1361
-
ref?: React.Ref<HTMLDivElement>;
1361
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
1370
-
const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom);
1371
1370
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
1372
1371
post.viewer?.repost ? true : false
1373
1372
);
1374
1374
-
const [hasLiked, setHasLiked] = useState<boolean>(
1375
1375
-
post.uri in likedPosts || post.viewer?.like ? true : false
1376
1376
-
);
1377
1373
const [, setComposerPost] = useAtom(composerAtom);
1378
1374
const { agent } = useAuth();
1379
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
1383
-
1384
1384
-
const likeOrUnlikePost = async () => {
1385
1385
-
const newLikedPosts = { ...likedPosts };
1386
1386
-
if (!agent) {
1387
1387
-
console.error("Agent is null or undefined");
1388
1388
-
return;
1389
1389
-
}
1390
1390
-
if (hasLiked) {
1391
1391
-
if (post.uri in likedPosts) {
1392
1392
-
const likeUri = likedPosts[post.uri];
1393
1393
-
setLikeUri(likeUri);
1394
1394
-
}
1395
1395
-
if (likeUri) {
1396
1396
-
await agent.deleteLike(likeUri);
1397
1397
-
setHasLiked(false);
1398
1398
-
delete newLikedPosts[post.uri];
1399
1399
-
}
1400
1400
-
} else {
1401
1401
-
const { uri } = await agent.like(post.uri, post.cid);
1402
1402
-
setLikeUri(uri);
1403
1403
-
setHasLiked(true);
1404
1404
-
newLikedPosts[post.uri] = uri;
1405
1405
-
}
1406
1406
-
setLikedPosts(newLikedPosts);
1407
1407
-
};
1378
1378
+
const { liked, toggle, backfill } = useFastLike(post.uri, post.cid);
1379
1379
+
// const bovref = useBackfillOnView(post.uri, post.cid);
1380
1380
+
// React.useLayoutEffect(()=>{
1381
1381
+
// if (expanded && !isQuote) {
1382
1382
+
// backfill();
1383
1383
+
// }
1384
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
1445
-
console.log("Received ref in UniversalPostRenderer:", ref);
1422
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
1922
-
likeOrUnlikePost();
1899
1899
+
toggle();
1923
1900
}}
1924
1901
style={{
1925
1902
...btnstyle,
1926
1926
-
...(hasLiked ? { color: "#EC4899" } : {}),
1903
1903
+
...(liked ? { color: "#EC4899" } : {}),
1927
1904
}}
1928
1905
>
1929
1929
-
{hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
1930
1930
-
{(post.likeCount || 0) + (hasLiked ? 1 : 0)}
1906
1906
+
{liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
1907
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
···
1
1
+
import { AtUri } from "@atproto/api";
2
2
+
import { TID } from "@atproto/common-web";
3
3
+
import { useQueryClient } from "@tanstack/react-query";
4
4
+
import { useAtom } from "jotai";
5
5
+
import React, { createContext, use, useCallback, useEffect, useRef } from "react";
6
6
+
7
7
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
8
8
+
import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms";
9
9
+
import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery";
10
10
+
11
11
+
export type LikeRecord = { uri: string; target: string; cid: string };
12
12
+
export type LikeMutation = { type: 'like'; target: string; cid: string };
13
13
+
export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord };
14
14
+
export type Mutation = LikeMutation | UnlikeMutation;
15
15
+
16
16
+
interface LikeMutationQueueContextType {
17
17
+
fastState: (target: string) => LikeRecord | null | undefined;
18
18
+
fastToggle: (target:string, cid:string) => void;
19
19
+
backfillState: (target: string, user: string) => Promise<void>;
20
20
+
}
21
21
+
22
22
+
const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined);
23
23
+
24
24
+
export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) {
25
25
+
const { agent } = useAuth();
26
26
+
const queryClient = useQueryClient();
27
27
+
const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom);
28
28
+
const [constellationurl] = useAtom(constellationURLAtom);
29
29
+
30
30
+
const likedPostsRef = useRef(likedPosts);
31
31
+
useEffect(() => {
32
32
+
likedPostsRef.current = likedPosts;
33
33
+
}, [likedPosts]);
34
34
+
35
35
+
const queueRef = useRef<Mutation[]>([]);
36
36
+
const runningRef = useRef(false);
37
37
+
38
38
+
const fastState = (target: string) => likedPosts[target];
39
39
+
40
40
+
const setFastState = useCallback(
41
41
+
(target: string, record: LikeRecord | null) =>
42
42
+
setLikedPosts((prev) => ({ ...prev, [target]: record })),
43
43
+
[setLikedPosts]
44
44
+
);
45
45
+
46
46
+
const enqueue = (mutation: Mutation) => queueRef.current.push(mutation);
47
47
+
48
48
+
const fastToggle = useCallback((target: string, cid: string) => {
49
49
+
const likedRecord = likedPostsRef.current[target];
50
50
+
51
51
+
if (likedRecord) {
52
52
+
setFastState(target, null);
53
53
+
if (likedRecord.uri !== 'pending') {
54
54
+
enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord });
55
55
+
}
56
56
+
} else {
57
57
+
setFastState(target, { uri: "pending", target, cid });
58
58
+
enqueue({ type: "like", target, cid });
59
59
+
}
60
60
+
}, [setFastState]);
61
61
+
62
62
+
/**
63
63
+
*
64
64
+
* @deprecated dont use it yet, will cause infinite rerenders
65
65
+
*/
66
66
+
const backfillState = async (target: string, user: string) => {
67
67
+
const query = constructConstellationQuery({
68
68
+
constellation: constellationurl,
69
69
+
method: "/links",
70
70
+
target,
71
71
+
collection: "app.bsky.feed.like",
72
72
+
path: ".subject.uri",
73
73
+
dids: [user],
74
74
+
});
75
75
+
const data = await queryClient.fetchQuery(query);
76
76
+
const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? [];
77
77
+
const found = likes.find((r) => r.did === user);
78
78
+
if (found) {
79
79
+
const uri = `at://${found.did}/${found.collection}/${found.rkey}`;
80
80
+
const ciddata = await queryClient.fetchQuery(
81
81
+
constructArbitraryQuery(uri)
82
82
+
);
83
83
+
if (ciddata?.cid)
84
84
+
setFastState(target, { uri, target, cid: ciddata?.cid });
85
85
+
} else {
86
86
+
setFastState(target, null);
87
87
+
}
88
88
+
};
89
89
+
90
90
+
91
91
+
useEffect(() => {
92
92
+
if (!agent?.did) return;
93
93
+
94
94
+
const processQueue = async () => {
95
95
+
if (runningRef.current || queueRef.current.length === 0) return;
96
96
+
runningRef.current = true;
97
97
+
98
98
+
while (queueRef.current.length > 0) {
99
99
+
const mutation = queueRef.current.shift()!;
100
100
+
try {
101
101
+
if (mutation.type === "like") {
102
102
+
const newRecord = {
103
103
+
repo: agent.did!,
104
104
+
collection: "app.bsky.feed.like",
105
105
+
rkey: TID.next().toString(),
106
106
+
record: {
107
107
+
$type: "app.bsky.feed.like",
108
108
+
subject: { uri: mutation.target, cid: mutation.cid },
109
109
+
createdAt: new Date().toISOString(),
110
110
+
},
111
111
+
};
112
112
+
const response = await agent.com.atproto.repo.createRecord(newRecord);
113
113
+
if (!response.success) throw new Error("createRecord failed");
114
114
+
115
115
+
const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`;
116
116
+
setFastState(mutation.target, {
117
117
+
uri,
118
118
+
target: mutation.target,
119
119
+
cid: mutation.cid,
120
120
+
});
121
121
+
} else if (mutation.type === "unlike") {
122
122
+
const aturi = new AtUri(mutation.likeRecordUri);
123
123
+
await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey });
124
124
+
setFastState(mutation.target, null);
125
125
+
}
126
126
+
} catch (err) {
127
127
+
console.error("Like mutation failed, reverting:", err);
128
128
+
if (mutation.type === 'like') {
129
129
+
setFastState(mutation.target, null);
130
130
+
} else if (mutation.type === 'unlike') {
131
131
+
setFastState(mutation.target, mutation.originalRecord);
132
132
+
}
133
133
+
}
134
134
+
}
135
135
+
runningRef.current = false;
136
136
+
};
137
137
+
138
138
+
const interval = setInterval(processQueue, 1000);
139
139
+
return () => clearInterval(interval);
140
140
+
}, [agent, setFastState]);
141
141
+
142
142
+
const value = { fastState, fastToggle, backfillState };
143
143
+
144
144
+
return (
145
145
+
<LikeMutationQueueContext value={value}>
146
146
+
{children}
147
147
+
</LikeMutationQueueContext>
148
148
+
);
149
149
+
}
150
150
+
151
151
+
export function useLikeMutationQueue() {
152
152
+
const context = use(LikeMutationQueueContext);
153
153
+
if (context === undefined) {
154
154
+
throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider');
155
155
+
}
156
156
+
return context;
157
157
+
}
+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
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
82
-
<RootDocument>
83
83
-
<KeepAliveProvider>
84
84
-
<KeepAliveOutlet />
85
85
-
</KeepAliveProvider>
86
86
-
</RootDocument>
83
83
+
<LikeMutationQueueProvider>
84
84
+
<RootDocument>
85
85
+
<KeepAliveProvider>
86
86
+
<KeepAliveOutlet />
87
87
+
</KeepAliveProvider>
88
88
+
</RootDocument>
89
89
+
</LikeMutationQueueProvider>
87
90
</UnifiedAuthProvider>
88
91
);
89
92
}
+40
-20
src/routes/profile.$did/index.tsx
···
22
22
useGetFollowState,
23
23
useGetOneToOneState,
24
24
} from "~/utils/followState";
25
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
457
-
// @ts-expect-error overloads sucks
458
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
473
-
onClick={(e)=>{e.stopPropagation();}}
474
474
+
onClick={(e) => {
475
475
+
e.stopPropagation();
476
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
577
-
data: repostsData,
580
580
+
data: likesData,
578
581
fetchNextPage,
579
582
hasNextPage,
580
583
isFetchingNextPage,
···
585
588
"app.bsky.feed.like"
586
589
);
587
590
588
588
-
const reposts = React.useMemo(
589
589
-
() => repostsData?.pages.flatMap((page) => page.records) ?? [],
590
590
-
[repostsData]
591
591
+
const likes = React.useMemo(
592
592
+
() => likesData?.pages.flatMap((page) => page.records) ?? [],
593
593
+
[likesData]
591
594
);
592
595
596
596
+
const { setFastState } = useFastSetLikesFromFeed();
597
597
+
const seededRef = React.useRef(new Set<string>());
598
598
+
599
599
+
useEffect(() => {
600
600
+
for (const like of likes) {
601
601
+
if (!seededRef.current.has(like.uri)) {
602
602
+
seededRef.current.add(like.uri);
603
603
+
const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
604
604
+
setFastState(record.subject.uri, {
605
605
+
target: record.subject.uri,
606
606
+
uri: like.uri,
607
607
+
cid: like.cid,
608
608
+
});
609
609
+
}
610
610
+
}
611
611
+
}, [likes, setFastState]);
612
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
599
-
{reposts.map((repost) => {
619
619
+
{likes.map((like) => {
600
620
if (
601
601
-
!repost ||
602
602
-
!repost?.value ||
603
603
-
!repost?.value?.subject ||
621
621
+
!like ||
622
622
+
!like?.value ||
623
623
+
!like?.value?.subject ||
604
624
// @ts-expect-error blehhhhh
605
605
-
!repost?.value?.subject?.uri
625
625
+
!like?.value?.subject?.uri
606
626
)
607
627
return;
608
608
-
const repostRecord =
609
609
-
repost.value as unknown as ATPAPI.AppBskyFeedLike.Record;
628
628
+
const likeRecord =
629
629
+
like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
610
630
return (
611
631
<UniversalPostRendererATURILoader
612
612
-
key={repostRecord.subject.uri}
613
613
-
atUri={repostRecord.subject.uri}
632
632
+
key={likeRecord.subject.uri}
633
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
621
-
{arePostsLoading && reposts.length === 0 && (
622
622
-
<div className="p-4 text-center text-gray-500">Loading posts...</div>
641
641
+
{arePostsLoading && likes.length === 0 && (
642
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
632
-
Load More Posts
652
652
+
Load More Likes
633
653
</button>
634
654
)}
635
635
-
{reposts.length === 0 && !arePostsLoading && (
636
636
-
<div className="p-4 text-center text-gray-500">No posts found.</div>
655
655
+
{likes.length === 0 && !arePostsLoading && (
656
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
···
59
59
{}
60
60
);
61
61
62
62
+
export type LikeRecord = {
63
63
+
uri: string; // at://did/collection/rkey
64
64
+
target: string;
65
65
+
cid: string;
66
66
+
};
67
67
+
68
68
+
export const internalLikedPostsAtom = atomWithStorage<Record<string, LikeRecord | null>>(
69
69
+
"internal-liked-posts",
70
70
+
{}
71
71
+
);
72
72
+
62
73
export const defaultconstellationURL = "constellation.microcosm.blue";
63
74
export const constellationURLAtom = atomWithStorage<string>(
64
75
"constellationURL",
+34
src/utils/likeMutationQueue.ts
···
1
1
+
import { useAtom } from "jotai";
2
2
+
import { useCallback } from "react";
3
3
+
4
4
+
import { type LikeRecord,useLikeMutationQueue as useLikeMutationQueueFromProvider } from "~/providers/LikeMutationQueueProvider";
5
5
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
6
+
7
7
+
import { internalLikedPostsAtom } from "./atoms";
8
8
+
9
9
+
export function useFastLike(target: string, cid: string) {
10
10
+
const { agent } = useAuth();
11
11
+
const { fastState, fastToggle, backfillState } = useLikeMutationQueueFromProvider();
12
12
+
13
13
+
const liked = fastState(target);
14
14
+
const toggle = () => fastToggle(target, cid);
15
15
+
/**
16
16
+
*
17
17
+
* @deprecated dont use it yet, will cause infinite rerenders
18
18
+
*/
19
19
+
const backfill = () => agent?.did && backfillState(target, agent.did);
20
20
+
21
21
+
return { liked, toggle, backfill };
22
22
+
}
23
23
+
24
24
+
export function useFastSetLikesFromFeed() {
25
25
+
const [_, setLikedPosts] = useAtom(internalLikedPostsAtom);
26
26
+
27
27
+
const setFastState = useCallback(
28
28
+
(target: string, record: LikeRecord | null) =>
29
29
+
setLikedPosts((prev) => ({ ...prev, [target]: record })),
30
30
+
[setLikedPosts]
31
31
+
);
32
32
+
33
33
+
return { setFastState };
34
34
+
}