From a6c2005b5fca32ae5b4821d4e62e607f6e1e5e79 Mon Sep 17 00:00:00 2001 From: Ahmed Sobhy <48056730+AhmedSobhy01@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:22:14 +0200 Subject: [PATCH 1/3] feat: Implement user interaction features including tweet likers/retweeters, follow/block mutations, and their tests. --- .../hooks/profile/useFollowMutation.test.tsx | 70 +++++++++++++++---- src/hooks/profile/useBlockMutation.tsx | 69 +++++++++++++++++- src/hooks/profile/useFollowMutation.ts | 29 ++++++-- src/hooks/tweets/useTweetLikers.ts | 3 +- src/hooks/tweets/useTweetRetweeters.ts | 3 +- src/libs/queryKeys.ts | 4 +- 6 files changed, 156 insertions(+), 22 deletions(-) diff --git a/src/__tests__/hooks/profile/useFollowMutation.test.tsx b/src/__tests__/hooks/profile/useFollowMutation.test.tsx index 075e94870..70c37d29f 100644 --- a/src/__tests__/hooks/profile/useFollowMutation.test.tsx +++ b/src/__tests__/hooks/profile/useFollowMutation.test.tsx @@ -277,8 +277,22 @@ describe('useFollowMutation', () => { pages: [ { data: [ - { username: 'targetUser', displayName: 'Target User', isFollowing: false }, - { username: 'otherUser', displayName: 'Other User', isFollowing: true }, + { + username: 'targetUser', + displayName: 'Target User', + bio: null, + avatarUrl: null, + bioEntities: null, + relationship: { ...rel, following: false }, + }, + { + username: 'otherUser', + displayName: 'Other User', + bio: null, + avatarUrl: null, + bioEntities: null, + relationship: { ...rel, following: true }, + }, ], nextCursor: undefined, }, @@ -299,8 +313,8 @@ describe('useFollowMutation', () => { 'tweetLikers', 'tweet-123', ]) as typeof likersData; - expect(updatedLikers.pages[0].data[0].isFollowing).toBe(true); - expect(updatedLikers.pages[0].data[1].isFollowing).toBe(true); // unchanged + expect(updatedLikers.pages[0].data[0].relationship?.following).toBe(true); + expect(updatedLikers.pages[0].data[1].relationship?.following).toBe(true); // unchanged }); it('updates tweetRetweeters cache when following a user', async () => { @@ -322,8 +336,22 @@ describe('useFollowMutation', () => { pages: [ { data: [ - { username: 'retweeter', displayName: 'Retweeter', isFollowing: false }, - { username: 'anotherUser', displayName: 'Another', isFollowing: false }, + { + username: 'retweeter', + displayName: 'Retweeter', + bio: null, + avatarUrl: null, + bioEntities: null, + relationship: { ...rel, following: false }, + }, + { + username: 'anotherUser', + displayName: 'Another', + bio: null, + avatarUrl: null, + bioEntities: null, + relationship: { ...rel, following: false }, + }, ], nextCursor: undefined, }, @@ -344,8 +372,8 @@ describe('useFollowMutation', () => { 'tweetRetweeters', 'tweet-456', ]) as typeof retweetersData; - expect(updatedRetweeters.pages[0].data[0].isFollowing).toBe(true); - expect(updatedRetweeters.pages[0].data[1].isFollowing).toBe(false); + expect(updatedRetweeters.pages[0].data[0].relationship?.following).toBe(true); + expect(updatedRetweeters.pages[0].data[1].relationship?.following).toBe(false); }); it('creates default relationship when target profile has no relationship', async () => { @@ -446,7 +474,16 @@ describe('useFollowMutation', () => { const likersData = { pages: [ { - data: [{ username: 'targetUser', displayName: 'Target', isFollowing: false }], + data: [ + { + username: 'targetUser', + displayName: 'Target', + bio: null, + avatarUrl: null, + bioEntities: null, + relationship: { ...rel, following: false }, + }, + ], nextCursor: undefined, }, ], @@ -457,7 +494,16 @@ describe('useFollowMutation', () => { const retweetersData = { pages: [ { - data: [{ username: 'targetUser', displayName: 'Target', isFollowing: false }], + data: [ + { + username: 'targetUser', + displayName: 'Target', + bio: null, + avatarUrl: null, + bioEntities: null, + relationship: { ...rel, following: false }, + }, + ], nextCursor: undefined, }, ], @@ -484,8 +530,8 @@ describe('useFollowMutation', () => { 'tweet-789', ]) as typeof retweetersData; - expect(revertedLikers.pages[0].data[0].isFollowing).toBe(false); - expect(revertedRetweeters.pages[0].data[0].isFollowing).toBe(false); + expect(revertedLikers.pages[0].data[0].relationship?.following).toBe(false); + expect(revertedRetweeters.pages[0].data[0].relationship?.following).toBe(false); }); it('handles mutation without target profile in cache', async () => { diff --git a/src/hooks/profile/useBlockMutation.tsx b/src/hooks/profile/useBlockMutation.tsx index fd70de94d..06e0cf94e 100644 --- a/src/hooks/profile/useBlockMutation.tsx +++ b/src/hooks/profile/useBlockMutation.tsx @@ -1,8 +1,10 @@ import { InfiniteData, QueryClient, useMutation, useQueryClient } from '@tanstack/react-query'; import { ApiException, ApiResponseBase } from '@/libs/api'; +import { queryKeys } from '@/libs/queryKeys'; import { blockUser, unblockUser } from '@/services/me'; import { ListResponse } from '@/services/settings'; +import { GetTweetLikesResponse, GetTweetRetweetersResponse } from '@/services/tweets'; import { useUserStore } from '@/stores/userStore'; import { UserProfile } from '@/types/user'; import { updateSearchUsersCache } from '@/utils/updateSearchCache'; @@ -21,11 +23,13 @@ export type BlockMutationContext = { }; type InfiniteListResponse = InfiniteData; +type TweetLikersInfiniteResponse = InfiniteData; +type TweetRetweetersInfiniteResponse = InfiniteData; function updateLists(queryClient: QueryClient, username: string, isBlocked: boolean) { - const queryKeys: ('blocks' | 'mutes')[] = ['blocks', 'mutes']; + const listKeys: ('blocks' | 'mutes')[] = ['blocks', 'mutes']; - for (const key of queryKeys) { + for (const key of listKeys) { const queries = queryClient.getQueriesData({ queryKey: [key], }); @@ -55,6 +59,66 @@ function updateLists(queryClient: QueryClient, username: string, isBlocked: bool } } +function updateTweetLikersAndRetweetersLists( + queryClient: QueryClient, + username: string, + isBlocked: boolean +) { + const likersQueries = queryClient.getQueriesData({ + predicate: (query) => query.queryKey[0] === queryKeys.tweetLikers('').at(0), + }); + + likersQueries.forEach(([queryKey, data]) => { + if (!data) return; + + const pages = data.pages.map((page) => ({ + ...page, + data: page.data.map((user) => + user.username === username + ? { + ...user, + relationship: { + ...user.relationship, + blocking: isBlocked, + follower: isBlocked ? false : user.relationship?.follower, + following: isBlocked ? false : user.relationship?.following, + }, + } + : user + ), + })); + + queryClient.setQueryData(queryKey, { ...data, pages }); + }); + + const retweetersQueries = queryClient.getQueriesData({ + predicate: (query) => query.queryKey[0] === queryKeys.tweetRetweeters('').at(0), + }); + + retweetersQueries.forEach(([queryKey, data]) => { + if (!data) return; + + const pages = data.pages.map((page) => ({ + ...page, + data: page.data.map((user) => + user.username === username + ? { + ...user, + relationship: { + ...user.relationship, + blocking: isBlocked, + follower: isBlocked ? false : user.relationship?.follower, + following: isBlocked ? false : user.relationship?.following, + }, + } + : user + ), + })); + + queryClient.setQueryData(queryKey, { ...data, pages }); + }); +} + export function useBlockMutation() { const queryClient = useQueryClient(); const updateUser = useUserStore((state) => state.updateUser); @@ -110,6 +174,7 @@ export function useBlockMutation() { }); updateLists(queryClient, username, block); + updateTweetLikersAndRetweetersLists(queryClient, username, block); updateUser({ followersCount: newViewerFollowersCount, diff --git a/src/hooks/profile/useFollowMutation.ts b/src/hooks/profile/useFollowMutation.ts index c15e67ab3..ad9be76eb 100644 --- a/src/hooks/profile/useFollowMutation.ts +++ b/src/hooks/profile/useFollowMutation.ts @@ -1,6 +1,7 @@ import { InfiniteData, QueryClient, useMutation, useQueryClient } from '@tanstack/react-query'; import { ApiException } from '@/libs/api'; +import { queryKeys } from '@/libs/queryKeys'; import { followUser, unfollowUser } from '@/services/connections'; import { GetTweetLikesResponse, GetTweetRetweetersResponse } from '@/services/tweets'; import { useUserStore } from '@/stores/userStore'; @@ -77,7 +78,7 @@ function updateTweetLikersAndRetweetersLists( isFollowing: boolean ) { const likersQueries = queryClient.getQueriesData({ - queryKey: ['tweetLikers'], + predicate: (query) => query.queryKey[0] === queryKeys.tweetLikers('').at(0), }); likersQueries.forEach(([queryKey, data]) => { @@ -85,14 +86,24 @@ function updateTweetLikersAndRetweetersLists( const pages = data.pages.map((page) => ({ ...page, - data: page.data.map((user) => (user.username === username ? { ...user, isFollowing } : user)), + data: page.data.map((user) => + user.username === username + ? { + ...user, + relationship: { + ...user.relationship, + following: isFollowing, + }, + } + : user + ), })); queryClient.setQueryData(queryKey, { ...data, pages }); }); const retweetersQueries = queryClient.getQueriesData({ - queryKey: ['tweetRetweeters'], + predicate: (query) => query.queryKey[0] === queryKeys.tweetRetweeters('').at(0), }); retweetersQueries.forEach(([queryKey, data]) => { @@ -100,7 +111,17 @@ function updateTweetLikersAndRetweetersLists( const pages = data.pages.map((page) => ({ ...page, - data: page.data.map((user) => (user.username === username ? { ...user, isFollowing } : user)), + data: page.data.map((user) => + user.username === username + ? { + ...user, + relationship: { + ...user.relationship, + following: isFollowing, + }, + } + : user + ), })); queryClient.setQueryData(queryKey, { ...data, pages }); diff --git a/src/hooks/tweets/useTweetLikers.ts b/src/hooks/tweets/useTweetLikers.ts index d7187d47b..74233f6ee 100644 --- a/src/hooks/tweets/useTweetLikers.ts +++ b/src/hooks/tweets/useTweetLikers.ts @@ -1,10 +1,11 @@ import { useInfiniteQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/libs/queryKeys'; import { getTweetLikes } from '@/services/tweets'; export const useTweetLikers = (tweetId: string) => { return useInfiniteQuery({ - queryKey: ['tweetLikers', tweetId], + queryKey: queryKeys.tweetLikers(tweetId), queryFn: ({ pageParam }) => getTweetLikes(tweetId, pageParam ? { cursor: pageParam } : undefined), initialPageParam: undefined as string | undefined, diff --git a/src/hooks/tweets/useTweetRetweeters.ts b/src/hooks/tweets/useTweetRetweeters.ts index 118d57bcb..cd7f90ccb 100644 --- a/src/hooks/tweets/useTweetRetweeters.ts +++ b/src/hooks/tweets/useTweetRetweeters.ts @@ -1,10 +1,11 @@ import { useInfiniteQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/libs/queryKeys'; import { getTweetRetweeters } from '@/services/tweets'; export const useTweetRetweeters = (tweetId: string) => { return useInfiniteQuery({ - queryKey: ['tweetRetweeters', tweetId], + queryKey: queryKeys.tweetRetweeters(tweetId), queryFn: ({ pageParam }) => getTweetRetweeters(tweetId, pageParam ? { cursor: pageParam } : undefined), initialPageParam: undefined as string | undefined, diff --git a/src/libs/queryKeys.ts b/src/libs/queryKeys.ts index bfd157cd7..3e9138f67 100644 --- a/src/libs/queryKeys.ts +++ b/src/libs/queryKeys.ts @@ -8,8 +8,8 @@ export const queryKeys = { ? (['tweet', tweetId, 'replies', cursor] as const) : (['tweet', tweetId, 'replies'] as const), tweetQuotes: (tweetId: string) => ['tweet', tweetId, 'quotes'] as const, - tweetLikers: (tweetId: string) => ['tweet', tweetId, 'likers'] as const, - tweetRetweeters: (tweetId: string) => ['tweet', tweetId, 'retweeters'] as const, + tweetLikers: (tweetId: string) => ['tweetLikers', tweetId] as const, + tweetRetweeters: (tweetId: string) => ['tweetRetweeters', tweetId] as const, timeline: { forYou: (username: string) => ['timeline', 'for-you', username] as const, From 356c675162e5562574a2d79fd8abc0ad7394fea4 Mon Sep 17 00:00:00 2001 From: Ahmed Sobhy <48056730+AhmedSobhy01@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:27:39 +0200 Subject: [PATCH 2/3] fix: correct predicate usage for tweet likers and retweeters queries --- src/hooks/profile/useBlockMutation.tsx | 4 ++-- src/hooks/profile/useFollowMutation.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hooks/profile/useBlockMutation.tsx b/src/hooks/profile/useBlockMutation.tsx index 06e0cf94e..b9d697024 100644 --- a/src/hooks/profile/useBlockMutation.tsx +++ b/src/hooks/profile/useBlockMutation.tsx @@ -65,7 +65,7 @@ function updateTweetLikersAndRetweetersLists( isBlocked: boolean ) { const likersQueries = queryClient.getQueriesData({ - predicate: (query) => query.queryKey[0] === queryKeys.tweetLikers('').at(0), + predicate: (query) => query.queryKey[0] === queryKeys.tweetLikers('')[0], }); likersQueries.forEach(([queryKey, data]) => { @@ -92,7 +92,7 @@ function updateTweetLikersAndRetweetersLists( }); const retweetersQueries = queryClient.getQueriesData({ - predicate: (query) => query.queryKey[0] === queryKeys.tweetRetweeters('').at(0), + predicate: (query) => query.queryKey[0] === queryKeys.tweetRetweeters('')[0], }); retweetersQueries.forEach(([queryKey, data]) => { diff --git a/src/hooks/profile/useFollowMutation.ts b/src/hooks/profile/useFollowMutation.ts index ad9be76eb..75bd6ccd9 100644 --- a/src/hooks/profile/useFollowMutation.ts +++ b/src/hooks/profile/useFollowMutation.ts @@ -78,7 +78,7 @@ function updateTweetLikersAndRetweetersLists( isFollowing: boolean ) { const likersQueries = queryClient.getQueriesData({ - predicate: (query) => query.queryKey[0] === queryKeys.tweetLikers('').at(0), + predicate: (query) => query.queryKey[0] === queryKeys.tweetLikers('')[0], }); likersQueries.forEach(([queryKey, data]) => { @@ -103,7 +103,7 @@ function updateTweetLikersAndRetweetersLists( }); const retweetersQueries = queryClient.getQueriesData({ - predicate: (query) => query.queryKey[0] === queryKeys.tweetRetweeters('').at(0), + predicate: (query) => query.queryKey[0] === queryKeys.tweetRetweeters('')[0], }); retweetersQueries.forEach(([queryKey, data]) => { From 8732aded693f10fcf944af900120ac00f20611fc Mon Sep 17 00:00:00 2001 From: Ahmed Sobhy <48056730+AhmedSobhy01@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:30:11 +0200 Subject: [PATCH 3/3] fix: ensure input value assertion waits for asynchronous updates in TweetDetailScreen tests --- src/__tests__/screens/tweets/TweetDetailScreen.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/__tests__/screens/tweets/TweetDetailScreen.test.tsx b/src/__tests__/screens/tweets/TweetDetailScreen.test.tsx index 44e7fd57b..55ba37178 100644 --- a/src/__tests__/screens/tweets/TweetDetailScreen.test.tsx +++ b/src/__tests__/screens/tweets/TweetDetailScreen.test.tsx @@ -561,7 +561,9 @@ describe('TweetDetailScreen', () => { }); }); - expect(input.props.value).toBe(''); + await waitFor(() => { + expect(input.props.value).toBe(''); + }); }); it('should show error if reply fails', async () => {