From 2f1eae1991a79304b411e305d49bcb8a006b2f62 Mon Sep 17 00:00:00 2001 From: rimar1337 <132627503+rimar1337@users.noreply.github.com> Date: Sat, 1 Nov 2025 20:43:10 +0700 Subject: [PATCH] background like mutation --- src/components/UniversalPostRenderer.tsx | 55 ++----- src/providers/LikeMutationQueueProvider.tsx | 157 ++++++++++++++++++++ src/routes/__root.tsx | 13 +- src/routes/profile.$did/index.tsx | 60 +++++--- src/utils/atoms.ts | 11 ++ src/utils/likeMutationQueue.ts | 34 +++++ 6 files changed, 266 insertions(+), 64 deletions(-) create mode 100644 src/providers/LikeMutationQueueProvider.tsx create mode 100644 src/utils/likeMutationQueue.ts diff --git a/src/components/UniversalPostRenderer.tsx b/src/components/UniversalPostRenderer.tsx index 13eaee9..2137a5b 100644 --- a/src/components/UniversalPostRenderer.tsx +++ b/src/components/UniversalPostRenderer.tsx @@ -10,7 +10,6 @@ import { composerAtom, constellationURLAtom, imgCDNAtom, - likedPostsAtom, } from "~/utils/atoms"; import { useHydratedEmbed } from "~/utils/useHydrated"; import { @@ -38,7 +37,7 @@ export interface UniversalPostRendererATURILoaderProps { feedviewpost?: boolean; repostedby?: string; style?: React.CSSProperties; - ref?: React.Ref; + ref?: React.RefObject; dataIndexPropPass?: number; nopics?: boolean; concise?: boolean; @@ -659,7 +658,7 @@ export function UniversalPostRendererRawRecordShim({ feedviewpost?: boolean; repostedby?: string; style?: React.CSSProperties; - ref?: React.Ref; + ref?: React.RefObject; dataIndexPropPass?: number; nopics?: boolean; concise?: boolean; @@ -1206,6 +1205,7 @@ import defaultpfp from "~/../public/favicon.png"; import { useAuth } from "~/providers/UnifiedAuthProvider"; import { FeedItemRenderAturiLoader, FollowButton, Mutual } from "~/routes/profile.$did"; import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; +import { useFastLike } from "~/utils/likeMutationQueue"; // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; // import type { // ViewRecord, @@ -1358,7 +1358,7 @@ function UniversalPostRenderer({ depth?: number; repostedby?: string; style?: React.CSSProperties; - ref?: React.Ref; + ref?: React.RefObject; dataIndexPropPass?: number; nopics?: boolean; concise?: boolean; @@ -1367,44 +1367,21 @@ function UniversalPostRenderer({ }) { const parsed = new AtUri(post.uri); const navigate = useNavigate(); - const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom); const [hasRetweeted, setHasRetweeted] = useState( post.viewer?.repost ? true : false ); - const [hasLiked, setHasLiked] = useState( - post.uri in likedPosts || post.viewer?.like ? true : false - ); const [, setComposerPost] = useAtom(composerAtom); const { agent } = useAuth(); - const [likeUri, setLikeUri] = useState(post.viewer?.like); const [retweetUri, setRetweetUri] = useState( post.viewer?.repost ); - - const likeOrUnlikePost = async () => { - const newLikedPosts = { ...likedPosts }; - if (!agent) { - console.error("Agent is null or undefined"); - return; - } - if (hasLiked) { - if (post.uri in likedPosts) { - const likeUri = likedPosts[post.uri]; - setLikeUri(likeUri); - } - if (likeUri) { - await agent.deleteLike(likeUri); - setHasLiked(false); - delete newLikedPosts[post.uri]; - } - } else { - const { uri } = await agent.like(post.uri, post.cid); - setLikeUri(uri); - setHasLiked(true); - newLikedPosts[post.uri] = uri; - } - setLikedPosts(newLikedPosts); - }; + const { liked, toggle, backfill } = useFastLike(post.uri, post.cid); + // const bovref = useBackfillOnView(post.uri, post.cid); + // React.useLayoutEffect(()=>{ + // if (expanded && !isQuote) { + // backfill(); + // } + // },[backfill, expanded, isQuote]) const repostOrUnrepostPost = async () => { if (!agent) { @@ -1442,7 +1419,7 @@ function UniversalPostRenderer({ const isMainItem = false; const setMainItem = (any: any) => {}; // eslint-disable-next-line react-hooks/refs - console.log("Received ref in UniversalPostRenderer:", ref); + //console.log("Received ref in UniversalPostRenderer:", usedref); return (
{ - likeOrUnlikePost(); + toggle(); }} style={{ ...btnstyle, - ...(hasLiked ? { color: "#EC4899" } : {}), + ...(liked ? { color: "#EC4899" } : {}), }} > - {hasLiked ? : } - {(post.likeCount || 0) + (hasLiked ? 1 : 0)} + {liked ? : } + {(post.likeCount || 0) + (liked ? 1 : 0)}
LikeRecord | null | undefined; + fastToggle: (target:string, cid:string) => void; + backfillState: (target: string, user: string) => Promise; +} + +const LikeMutationQueueContext = createContext(undefined); + +export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) { + const { agent } = useAuth(); + const queryClient = useQueryClient(); + const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom); + const [constellationurl] = useAtom(constellationURLAtom); + + const likedPostsRef = useRef(likedPosts); + useEffect(() => { + likedPostsRef.current = likedPosts; + }, [likedPosts]); + + const queueRef = useRef([]); + const runningRef = useRef(false); + + const fastState = (target: string) => likedPosts[target]; + + const setFastState = useCallback( + (target: string, record: LikeRecord | null) => + setLikedPosts((prev) => ({ ...prev, [target]: record })), + [setLikedPosts] + ); + + const enqueue = (mutation: Mutation) => queueRef.current.push(mutation); + + const fastToggle = useCallback((target: string, cid: string) => { + const likedRecord = likedPostsRef.current[target]; + + if (likedRecord) { + setFastState(target, null); + if (likedRecord.uri !== 'pending') { + enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord }); + } + } else { + setFastState(target, { uri: "pending", target, cid }); + enqueue({ type: "like", target, cid }); + } + }, [setFastState]); + + /** + * + * @deprecated dont use it yet, will cause infinite rerenders + */ + const backfillState = async (target: string, user: string) => { + const query = constructConstellationQuery({ + constellation: constellationurl, + method: "/links", + target, + collection: "app.bsky.feed.like", + path: ".subject.uri", + dids: [user], + }); + const data = await queryClient.fetchQuery(query); + const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? []; + const found = likes.find((r) => r.did === user); + if (found) { + const uri = `at://${found.did}/${found.collection}/${found.rkey}`; + const ciddata = await queryClient.fetchQuery( + constructArbitraryQuery(uri) + ); + if (ciddata?.cid) + setFastState(target, { uri, target, cid: ciddata?.cid }); + } else { + setFastState(target, null); + } + }; + + + useEffect(() => { + if (!agent?.did) return; + + const processQueue = async () => { + if (runningRef.current || queueRef.current.length === 0) return; + runningRef.current = true; + + while (queueRef.current.length > 0) { + const mutation = queueRef.current.shift()!; + try { + if (mutation.type === "like") { + const newRecord = { + repo: agent.did!, + collection: "app.bsky.feed.like", + rkey: TID.next().toString(), + record: { + $type: "app.bsky.feed.like", + subject: { uri: mutation.target, cid: mutation.cid }, + createdAt: new Date().toISOString(), + }, + }; + const response = await agent.com.atproto.repo.createRecord(newRecord); + if (!response.success) throw new Error("createRecord failed"); + + const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`; + setFastState(mutation.target, { + uri, + target: mutation.target, + cid: mutation.cid, + }); + } else if (mutation.type === "unlike") { + const aturi = new AtUri(mutation.likeRecordUri); + await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey }); + setFastState(mutation.target, null); + } + } catch (err) { + console.error("Like mutation failed, reverting:", err); + if (mutation.type === 'like') { + setFastState(mutation.target, null); + } else if (mutation.type === 'unlike') { + setFastState(mutation.target, mutation.originalRecord); + } + } + } + runningRef.current = false; + }; + + const interval = setInterval(processQueue, 1000); + return () => clearInterval(interval); + }, [agent, setFastState]); + + const value = { fastState, fastToggle, backfillState }; + + return ( + + {children} + + ); +} + +export function useLikeMutationQueue() { + const context = use(LikeMutationQueueContext); + if (context === undefined) { + throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 2c74aeb..074b5ba 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -22,6 +22,7 @@ import { Import } from "~/components/Import"; import Login from "~/components/Login"; import { NotFound } from "~/components/NotFound"; import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; +import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms"; import { seo } from "~/utils/seo"; @@ -79,11 +80,13 @@ export const Route = createRootRouteWithContext<{ function RootComponent() { return ( - - - - - + + + + + + + ); } diff --git a/src/routes/profile.$did/index.tsx b/src/routes/profile.$did/index.tsx index 4d1a9c5..8ee1aa2 100644 --- a/src/routes/profile.$did/index.tsx +++ b/src/routes/profile.$did/index.tsx @@ -22,6 +22,7 @@ import { useGetFollowState, useGetOneToOneState, } from "~/utils/followState"; +import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue"; import { useInfiniteQueryAuthorFeed, useQueryArbitrary, @@ -454,7 +455,7 @@ export function FeedItemRender({ } const { data: likes } = useQueryConstellation( - // @ts-expect-error overloads sucks + // @ts-expect-error overloads sucks !listmode ? { target: feed.uri, @@ -470,7 +471,9 @@ export function FeedItemRender({ className={`px-4 py-4 ${!disableBottomBorder && "border-b"} flex flex-col gap-1`} to="/profile/$did/feed/$rkey" params={{ did: aturi.host, rkey: aturi.rkey }} - onClick={(e)=>{e.stopPropagation();}} + onClick={(e) => { + e.stopPropagation(); + }} >
@@ -574,7 +577,7 @@ function SelfLikesTab({ did }: { did: string }) { const resolvedDid = did.startsWith("did:") ? did : identity?.did; const { - data: repostsData, + data: likesData, fetchNextPage, hasNextPage, isFetchingNextPage, @@ -585,32 +588,49 @@ function SelfLikesTab({ did }: { did: string }) { "app.bsky.feed.like" ); - const reposts = React.useMemo( - () => repostsData?.pages.flatMap((page) => page.records) ?? [], - [repostsData] + const likes = React.useMemo( + () => likesData?.pages.flatMap((page) => page.records) ?? [], + [likesData] ); + const { setFastState } = useFastSetLikesFromFeed(); + const seededRef = React.useRef(new Set()); + + useEffect(() => { + for (const like of likes) { + if (!seededRef.current.has(like.uri)) { + seededRef.current.add(like.uri); + const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record; + setFastState(record.subject.uri, { + target: record.subject.uri, + uri: like.uri, + cid: like.cid, + }); + } + } + }, [likes, setFastState]); + return ( <>
Likes
- {reposts.map((repost) => { + {likes.map((like) => { if ( - !repost || - !repost?.value || - !repost?.value?.subject || + !like || + !like?.value || + !like?.value?.subject || // @ts-expect-error blehhhhh - !repost?.value?.subject?.uri + !like?.value?.subject?.uri ) return; - const repostRecord = - repost.value as unknown as ATPAPI.AppBskyFeedLike.Record; + const likeRecord = + like.value as unknown as ATPAPI.AppBskyFeedLike.Record; return ( ); @@ -618,8 +638,8 @@ function SelfLikesTab({ did }: { did: string }) {
{/* Loading and "Load More" states */} - {arePostsLoading && reposts.length === 0 && ( -
Loading posts...
+ {arePostsLoading && likes.length === 0 && ( +
Loading likes...
)} {isFetchingNextPage && (
Loading more...
@@ -629,11 +649,11 @@ function SelfLikesTab({ did }: { did: string }) { onClick={() => fetchNextPage()} 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" > - Load More Posts + Load More Likes )} - {reposts.length === 0 && !arePostsLoading && ( -
No posts found.
+ {likes.length === 0 && !arePostsLoading && ( +
No likes found.
)} ); diff --git a/src/utils/atoms.ts b/src/utils/atoms.ts index 146f6a5..b4f3e3f 100644 --- a/src/utils/atoms.ts +++ b/src/utils/atoms.ts @@ -59,6 +59,17 @@ export const likedPostsAtom = atomWithStorage>( {} ); +export type LikeRecord = { + uri: string; // at://did/collection/rkey + target: string; + cid: string; +}; + +export const internalLikedPostsAtom = atomWithStorage>( + "internal-liked-posts", + {} +); + export const defaultconstellationURL = "constellation.microcosm.blue"; export const constellationURLAtom = atomWithStorage( "constellationURL", diff --git a/src/utils/likeMutationQueue.ts b/src/utils/likeMutationQueue.ts new file mode 100644 index 0000000..ba90333 --- /dev/null +++ b/src/utils/likeMutationQueue.ts @@ -0,0 +1,34 @@ +import { useAtom } from "jotai"; +import { useCallback } from "react"; + +import { type LikeRecord,useLikeMutationQueue as useLikeMutationQueueFromProvider } from "~/providers/LikeMutationQueueProvider"; +import { useAuth } from "~/providers/UnifiedAuthProvider"; + +import { internalLikedPostsAtom } from "./atoms"; + +export function useFastLike(target: string, cid: string) { + const { agent } = useAuth(); + const { fastState, fastToggle, backfillState } = useLikeMutationQueueFromProvider(); + + const liked = fastState(target); + const toggle = () => fastToggle(target, cid); + /** + * + * @deprecated dont use it yet, will cause infinite rerenders + */ + const backfill = () => agent?.did && backfillState(target, agent.did); + + return { liked, toggle, backfill }; +} + +export function useFastSetLikesFromFeed() { + const [_, setLikedPosts] = useAtom(internalLikedPostsAtom); + + const setFastState = useCallback( + (target: string, record: LikeRecord | null) => + setLikedPosts((prev) => ({ ...prev, [target]: record })), + [setLikedPosts] + ); + + return { setFastState }; +} -- 2.51.2