an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

background like mutation

rimar1337 2f1eae19 24efdc83

+266 -64
+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 + }