an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

background like mutation

rimar1337 2f1eae19 24efdc83

Changed files
+266 -64
src
+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
··· 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
··· 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
··· 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
··· 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
··· 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 + }