From ec3b3404cbf6e22b6c45c35d78e06563d0ac0742 Mon Sep 17 00:00:00 2001 From: Yousef Adel Date: Mon, 15 Dec 2025 17:41:25 +0200 Subject: [PATCH] fix: show avatars in profile search and fix IDs in mentions --- .github/workflows/private-trigger.yml | 6 +- src/components/ui/Tab.tsx | 4 +- .../layout/components/LayoutWrapper.tsx | 2 +- src/features/profile/store/profileStore.ts | 11 + src/features/profile/types/store.ts | 1 + src/features/timeline/components/Mention.tsx | 13 +- src/features/timeline/components/Reply.tsx | 42 +++- .../timeline/components/SearchProfile.tsx | 5 +- src/features/timeline/optimistics/Tweets.ts | 214 ++++++++++++++++-- src/features/timeline/types/api.ts | 2 +- .../tweets/components/DeletedTweet.tsx | 29 +++ src/features/tweets/components/FullTweet.tsx | 5 +- src/features/tweets/components/QuoteTweet.tsx | 27 +-- src/features/tweets/components/Tweet.tsx | 26 ++- src/features/tweets/hooks/tweetQueries.ts | 13 +- src/features/tweets/utils/time.ts | 1 + 16 files changed, 324 insertions(+), 77 deletions(-) create mode 100644 src/features/tweets/components/DeletedTweet.tsx diff --git a/.github/workflows/private-trigger.yml b/.github/workflows/private-trigger.yml index 4202a685..fab9fa54 100644 --- a/.github/workflows/private-trigger.yml +++ b/.github/workflows/private-trigger.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout code uses: actions/checkout@v3 with: - fetch-depth: 0 # Required for SonarCloud + fetch-depth: 0 # Required for SonarCloud - name: Setup Node.js uses: actions/setup-node@v3 @@ -26,13 +26,13 @@ jobs: - name: Run tests run: npm test -- --coverage continue-on-error: true - + - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v5.0.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - + - name: Build project run: npm run build diff --git a/src/components/ui/Tab.tsx b/src/components/ui/Tab.tsx index 52e927ab..dd6789b3 100644 --- a/src/components/ui/Tab.tsx +++ b/src/components/ui/Tab.tsx @@ -17,11 +17,11 @@ export default function Tab({
onClick(id)} - className="flex flex-1 flex-col h-full items-center justify-center px-4 relative hover:cursor-pointer hover:bg-white/12" + className="flex flex-1 flex-col h-full items-center justify-center px-4 relative hover:cursor-pointer hover:bg-white/12 " >
{text} diff --git a/src/features/layout/components/LayoutWrapper.tsx b/src/features/layout/components/LayoutWrapper.tsx index a7e95a76..477ddfe1 100644 --- a/src/features/layout/components/LayoutWrapper.tsx +++ b/src/features/layout/components/LayoutWrapper.tsx @@ -33,7 +33,7 @@ export default function LayoutWrapper({ )}
-
+
{children}
diff --git a/src/features/profile/store/profileStore.ts b/src/features/profile/store/profileStore.ts index ef71f1b9..ba592b50 100644 --- a/src/features/profile/store/profileStore.ts +++ b/src/features/profile/store/profileStore.ts @@ -33,6 +33,17 @@ export const useProfileStore = create()( selectTab: (tab) => { set({ selectedTab: tab }); }, + setBlockedFlag: (flag) => + set((state) => + state.currentProfile + ? { + currentProfile: { + ...state.currentProfile, + is_blocked_by_me: flag, + }, + } + : { currentProfile: state.currentProfile } + ), }, }), { diff --git a/src/features/profile/types/store.ts b/src/features/profile/types/store.ts index 02fc4c0f..9bcafc38 100644 --- a/src/features/profile/types/store.ts +++ b/src/features/profile/types/store.ts @@ -14,5 +14,6 @@ export interface ProfileStore { clearProfile: () => void; actions: { selectTab: (tab: string) => void; + setBlockedFlag: (flag: boolean) => void; }; } diff --git a/src/features/timeline/components/Mention.tsx b/src/features/timeline/components/Mention.tsx index 01a1af1a..fe1c5af5 100644 --- a/src/features/timeline/components/Mention.tsx +++ b/src/features/timeline/components/Mention.tsx @@ -66,7 +66,7 @@ export default function Mention() { if (totalProfiles && pages) { // setMention(pages[0].data[0].User.username + ''); setIsDone( - pages[0].data[0].User.username + ' ' + pages[0].data[0].id + pages[0].data[0].User.username + ' ' + pages[0].data[0] ); console.log(pages[0].data[0].User.username); @@ -82,8 +82,8 @@ export default function Mention() { const profile = pages[page].data[index]; console.log(profile); // setMention(profile.User.username + ''); - console.log(profile.User.username + ' ' + profile.id); - setIsDone(profile.User.username + ' ' + profile.id); + console.log(profile.User.username + ' ' + profile.user_id); + setIsDone(profile.User.username + ' ' + profile.user_id); console.log(profile.User.username); // set user name with profile } @@ -122,7 +122,7 @@ export default function Mention() { {group.data.map((profile, indx) => (
{ @@ -130,7 +130,7 @@ export default function Mention() { console.log(profile.User.username); setIsOpen(false); // setIsDone(true); - setIsDone(profile.User.username + ' ' + profile.id); + setIsDone(profile.User.username + ' ' + profile.user_id); }} // onKeyDown={(e: React.KeyboardEvent) => { // e.preventDefault(); @@ -139,11 +139,12 @@ export default function Mention() { >
))} diff --git a/src/features/timeline/components/Reply.tsx b/src/features/timeline/components/Reply.tsx index 8dc07267..f42c1572 100644 --- a/src/features/timeline/components/Reply.tsx +++ b/src/features/timeline/components/Reply.tsx @@ -3,32 +3,56 @@ import { TimelineFeed, TimelineTweet } from '../types/api'; import { ADD_TWEET } from '../constants/tweetConstants'; import Tweet from '@/features/tweets/components/Tweet'; import { useTweetById } from '@/features/tweets/hooks/tweetQueries'; +import DeletedTweet from '@/features/tweets/components/DeletedTweet'; export default function Reply({ data, inProfile = false, + withoutOriginal = false, }: { data: TimelineFeed; inProfile?: boolean; + withoutOriginal?: boolean; }) { const tweetData = { ...(data.originalPostData as TimelineTweet), isRepost: data.originalPostData?.isRepost ?? false, isQuote: data.originalPostData?.isQuote ?? false, } as TimelineFeed; - + const showUpperColumn = + data.originalPostData && data.originalPostData.isDeleted; return (
- {data.originalPostData && ( - + {data.originalPostData && data.originalPostData.isDeleted ? ( +
+ +
+ ) : ( + data.originalPostData && + (data.originalPostData.type !== ADD_TWEET.REPLY ? ( + + ) : ( + + )) )} + {group.data.map((profile, indx) => (
{ setIsOpen(false); @@ -179,11 +179,12 @@ export default function SearchProfile() { >
))} diff --git a/src/features/timeline/optimistics/Tweets.ts b/src/features/timeline/optimistics/Tweets.ts index e3f3d657..ebcd4f6d 100644 --- a/src/features/timeline/optimistics/Tweets.ts +++ b/src/features/timeline/optimistics/Tweets.ts @@ -21,7 +21,10 @@ import { useInterest, useSelectedInterestTab, } from '@/features/explore/store/useExploreStore'; -import { useSelectedTab as useProfileSelectedTab } from '@/features/profile/store/profileStore'; +import { + useActions, + useSelectedTab as useProfileSelectedTab, +} from '@/features/profile/store/profileStore'; import { LATEST_TAB, TOP_TAB } from '@/features/explore/constants/tabs'; import { PROFILE_QUERY_KEYS, useProfileStore } from '@/features/profile'; import { useAuth } from '@/features/authentication/hooks'; @@ -32,6 +35,7 @@ import { POSTS_TAB, REPLIES_TAB, } from '@/features/profile/constants/tabs'; +import { ADD_TWEET } from '../constants/tweetConstants'; function updateTweetInInfiniteData( data: FeedType, @@ -181,7 +185,8 @@ function updateTweetPersonalizedInterestsData( function updateTweet( type: string, tweet: TimelineFeed, - userId: number + userId: number, + tweetId?: number ): TimelineFeed { let updatedTweet = { ...tweet }; switch (type) { @@ -193,7 +198,9 @@ function updateTweet( updatedOriginal = { ...original, likesCount: original.isLikedByMe - ? original.likesCount - 1 + ? original.likesCount - 1 < 0 + ? 0 + : original.likesCount - 1 : original.likesCount + 1, isLikedByMe: !original.isLikedByMe, }; @@ -203,7 +210,9 @@ function updateTweet( updatedTweet = { ...tweet, likesCount: tweet.isLikedByMe - ? tweet.likesCount - 1 + ? tweet.likesCount - 1 < 0 + ? 0 + : tweet.likesCount - 1 : tweet.likesCount + 1, isLikedByMe: !tweet.isLikedByMe, }; @@ -218,7 +227,9 @@ function updateTweet( updatedOriginal = { ...original, retweetsCount: original.isRepostedByMe - ? original.retweetsCount - 1 + ? original.retweetsCount - 1 < 0 + ? 0 + : original.retweetsCount - 1 : original.retweetsCount + 1, isRepostedByMe: !original.isRepostedByMe, }; @@ -228,7 +239,9 @@ function updateTweet( updatedTweet = { ...tweet, retweetsCount: tweet.isRepostedByMe - ? tweet.retweetsCount - 1 + ? tweet.retweetsCount - 1 < 0 + ? 0 + : tweet.retweetsCount - 1 : tweet.retweetsCount + 1, isRepostedByMe: !tweet.isRepostedByMe, }; @@ -280,9 +293,13 @@ function updateTweet( updatedTweet = { ...newTweet, originalPostData: originalPostData }; return updatedTweet; + // case OPTIMISTIC_TYPES.DELETE: + // let newOriginalTweet: TimelineFeed = tweet; + // let neworiginalPostData: TimelineTweet | undefined = + // tweet.originalPostData; + case OPTIMISTIC_TYPES.BLOCK: case OPTIMISTIC_TYPES.MUTE: - case OPTIMISTIC_TYPES.DELETE: // happens in updateTweetInInfiniteData with shouldRemove flag return tweet; @@ -342,6 +359,140 @@ function handleOldTweets( return { oldTweets: undefined, pages }; } } +function handleDeleteTweets( + feed: FeedType, + tweetId?: number +): { oldTweets: TimelineFeed[] | undefined; pages: number[] } { + const pages: number[] = []; + try { + const oldTweets = feed.pages.flatMap((page, indx) => + (page.data.posts || []).map((post) => { + if ( + (post?.isQuote || post?.type === ADD_TWEET.REPLY) && + post.originalPostData + ) { + if (post.originalPostData.postId === tweetId) { + if (!pages.includes(indx)) pages.push(indx); + return { + ...post, + originalPostData: { ...post.originalPostData, isDeleted: true }, + }; + } else { + if ( + (post.originalPostData?.isQuote || + post.originalPostData?.type === ADD_TWEET.REPLY) && + post.originalPostData?.originalPostData + ) { + if (post.originalPostData?.originalPostData?.postId === tweetId) { + if (!pages.includes(indx)) pages.push(indx); + return { + ...post, + originalPostData: { + ...post.originalPostData, + originalPostData: { + ...post.originalPostData.originalPostData, + isDeleted: true, + }, + }, + }; + } else return post; + } else return post; + } + } else { + if ( + post?.isRepost && + post?.originalPostData && + post?.originalPostData?.originalPostData && + post.originalPostData?.originalPostData?.postId === tweetId + ) { + if (!pages.includes(indx)) pages.push(indx); + return { + ...post, + originalPostData: { + ...post.originalPostData, + originalPostData: { + ...post.originalPostData.originalPostData, + isDeleted: true, + }, + }, + }; + } else return post; + } + }) + ); + return { oldTweets, pages }; + } catch (e) { + return { oldTweets: undefined, pages }; + } +} +function handleDeleteInterestsTweets( + feed: ExplorePersonalizedFeedDtoResponse, + tweetId?: number +): { oldTweets: TimelineFeed[] | undefined; pages: string[] } { + const pages: string[] = []; + try { + const oldTweets: TimelineFeed[] = []; + Object.keys(feed.data).map((category) => + feed.data[category].forEach((post, i) => { + if ( + (post?.isQuote || post?.type === ADD_TWEET.REPLY) && + post.originalPostData + ) { + if (post.originalPostData.postId === tweetId) { + if (!pages.includes(category)) pages.push(category); + + oldTweets.push({ + ...post, + originalPostData: { ...post.originalPostData, isDeleted: true }, + }); + } else { + if ( + (post.originalPostData?.isQuote || + post.originalPostData?.type === ADD_TWEET.REPLY) && + post.originalPostData?.originalPostData + ) { + if (post.originalPostData?.originalPostData?.postId === tweetId) { + if (!pages.includes(category)) pages.push(category); + oldTweets.push({ + ...post, + originalPostData: { + ...post.originalPostData, + originalPostData: { + ...post.originalPostData.originalPostData, + isDeleted: true, + }, + }, + }); + } + } + } + } else { + if ( + post?.isRepost && + post?.originalPostData && + post?.originalPostData?.originalPostData && + post.originalPostData?.originalPostData?.postId === tweetId + ) { + if (!pages.includes(category)) pages.push(category); + oldTweets.push({ + ...post, + originalPostData: { + ...post.originalPostData, + originalPostData: { + ...post.originalPostData.originalPostData, + isDeleted: true, + }, + }, + }); + } + } + }) + ); + return { oldTweets, pages }; + } catch (e) { + return { oldTweets: undefined, pages }; + } +} function handleOldInterestsTweets( type: string, @@ -464,6 +615,7 @@ export function useOptimisticTweet() { const path = usePathname(); const isHome = path?.startsWith('/home'); const isInterest = path?.startsWith('/explore/'); + const { setBlockedFlag } = useActions(); const isProfile = path?.startsWith(`/${username}`); const interest = useInterest(); const router = useRouter(); @@ -487,7 +639,7 @@ export function useOptimisticTweet() { if (!old) return old; return { ...old, - data: updateTweet(type, old.data, userId), + data: [updateTweet(type, old.data[0], userId)], }; } ); @@ -529,6 +681,7 @@ export function useOptimisticTweet() { queryKeys.pop(); queryKeys.pop(); queryKeys.pop(); + setBlockedFlag(true); } else { queryKeys = queryKeys.filter( (key) => JSON.stringify(key) !== JSON.stringify(currentKey) @@ -601,6 +754,22 @@ export function useOptimisticTweet() { oldTweets, type ); + if (type === OPTIMISTIC_TYPES.DELETE) { + const { oldTweets: deletedOriginal, pages: deletedPages } = + handleDeleteInterestsTweets(timelineFeed, tweetId); + const newTweets: TimelineFeed[] = []; + if (deletedOriginal) { + deletedOriginal.forEach((tweet) => { + newTweets.push(updateTweet(type, tweet, userId, tweetId)); + }); + timelineFeed = updateTweetPersonalizedInterestsData( + timelineFeed, + deletedPages, + newTweets, + OPTIMISTIC_TYPES.LIKE + ); + } + } } else { const newTweets: TimelineFeed[] = []; oldTweets.forEach((tweet) => { @@ -638,11 +807,7 @@ export function useOptimisticTweet() { timelineFeed ); - if ( - type === OPTIMISTIC_TYPES.BLOCK || - type === OPTIMISTIC_TYPES.MUTE || - type === OPTIMISTIC_TYPES.DELETE - ) { + if (type === OPTIMISTIC_TYPES.BLOCK || type === OPTIMISTIC_TYPES.MUTE) { if ( currentTweet && (currentTweet.userId === userId || @@ -690,6 +855,23 @@ export function useOptimisticTweet() { oldTweets, type ); + + if (type === OPTIMISTIC_TYPES.DELETE) { + const { oldTweets: deletedOriginal, pages: deletedPages } = + handleDeleteTweets(timelineFeed, tweetId); + const newTweets: TimelineFeed[] = []; + if (deletedOriginal) { + deletedOriginal.forEach((tweet) => { + newTweets.push(updateTweet(type, tweet, userId, tweetId)); + }); + timelineFeed = updateTweetInInfiniteData( + timelineFeed, + deletedPages, + newTweets, + OPTIMISTIC_TYPES.LIKE + ); + } + } } else { const newTweets: TimelineFeed[] = []; oldTweets.forEach((tweet) => { @@ -724,11 +906,7 @@ export function useOptimisticTweet() { queryClient.setQueryData(queryKey, timelineFeed); - if ( - type === OPTIMISTIC_TYPES.BLOCK || - type === OPTIMISTIC_TYPES.MUTE || - type === OPTIMISTIC_TYPES.DELETE - ) { + if (type === OPTIMISTIC_TYPES.BLOCK || type === OPTIMISTIC_TYPES.MUTE) { if ( currentTweet && (currentTweet.userId === userId || diff --git a/src/features/timeline/types/api.ts b/src/features/timeline/types/api.ts index d1dfc1c9..34be7136 100644 --- a/src/features/timeline/types/api.ts +++ b/src/features/timeline/types/api.ts @@ -83,7 +83,7 @@ export interface Profile { is_verified: boolean; }; is_followed_by_me: boolean; - id: number; + user_id: number; profile_image_url: string; } export interface ProfileSearchDtoResponse { diff --git a/src/features/tweets/components/DeletedTweet.tsx b/src/features/tweets/components/DeletedTweet.tsx new file mode 100644 index 00000000..2619bbfa --- /dev/null +++ b/src/features/tweets/components/DeletedTweet.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +export default function DeletedTweet({ id }: { id: number }) { + return ( +
+
+
+
+
+ This tweet is unavailable +
+
+
+
+
+ ); +} diff --git a/src/features/tweets/components/FullTweet.tsx b/src/features/tweets/components/FullTweet.tsx index 3dde60f5..83fc5793 100644 --- a/src/features/tweets/components/FullTweet.tsx +++ b/src/features/tweets/components/FullTweet.tsx @@ -105,7 +105,10 @@ function FullTweet({ data, id }: { data: TimelineFeed | null; id: number }) { const renderReplys = pages?.map((group, i) => ( {group.data.posts.map((reply, index) => ( - + ))} )); diff --git a/src/features/tweets/components/QuoteTweet.tsx b/src/features/tweets/components/QuoteTweet.tsx index 194ad6c5..cc5b4c46 100644 --- a/src/features/tweets/components/QuoteTweet.tsx +++ b/src/features/tweets/components/QuoteTweet.tsx @@ -5,6 +5,7 @@ import UserInfo from './UserInfo'; import Timing from './Timing'; import Content from './Content'; import TweetAvatar from './TweetAvatar'; +import DeletedTweet from './DeletedTweet'; type MediaItem = { url: string; @@ -47,31 +48,7 @@ export default function QuoteTweet(data: quoteProps) { }; const isDeleted = data.isDeleted; if (isDeleted) { - return ( -
-
-
-
-
- This tweet is unavailable -
-
-
-
-
- ); + return ; } return ( diff --git a/src/features/tweets/components/Tweet.tsx b/src/features/tweets/components/Tweet.tsx index bc4a7cc8..064d6726 100644 --- a/src/features/tweets/components/Tweet.tsx +++ b/src/features/tweets/components/Tweet.tsx @@ -26,10 +26,14 @@ export default function Tweet({ data, inProfile = false, showBorder = true, + showColumn = false, + showUpperColumn = false, }: { data: TimelineFeed; inProfile?: boolean; showBorder?: boolean; + showColumn?: boolean; + showUpperColumn?: boolean; }) { const myId = useAuth().user?.id; const myTweet = data.isRepost @@ -111,7 +115,14 @@ export default function Tweet({ } } }, - [dataViewd.postId, addVisibleTweet, removeVisibleTweet, joinPost, leavePost] + [ + isVisible, + dataViewd.postId, + addVisibleTweet, + removeVisibleTweet, + joinPost, + leavePost, + ] ); // const byMe = userId === dataViewd.userId; @@ -277,7 +288,7 @@ export default function Tweet({ //setCurrentTweet(data); router.push(`/home/${dataViewd.postId}`); }} - className={`block mx-auto w-full ${showBorder && 'border-b border-gray-700'} text-white relative transition-colors ${!Hovered ? 'hover:bg-[#0a0a0a]' : ''} hover:cursor-pointer p-4`} + className={`block mx-auto w-full ${showBorder && 'border-b border-gray-700'} text-white relative transition-colors ${!Hovered ? 'hover:bg-[#0a0a0a]' : ''} hover:cursor-pointer p-4 `} style={{ boxSizing: 'border-box', maxWidth: '100%' }} > {/* Show reposted by if present */} @@ -298,7 +309,16 @@ export default function Tweet({
)}
- +
+ {showUpperColumn && ( +
+ )} + + {showColumn && ( +
+ )} +
+
{ handleErrorOptimisticTweet(onMutateResult); }, - onSuccess: () => { + onSuccess: async () => { queryClient.invalidateQueries({ queryKey: TWEET_QUERY_KEYS.toggleLikeTweet(tweetId), }); @@ -92,7 +92,7 @@ export const useToggleLikeTweet = ( queryKey: TWEET_QUERY_KEYS.tweetById(tweetId), }); if (user) { - queryClient.refetchQueries({ + await queryClient.refetchQueries({ queryKey: PROFILE_QUERY_KEYS.profileLikes(user), }); } @@ -139,7 +139,7 @@ export const useToggleRepostTweet = ( onError: (error, variables, onMutateResult) => { handleErrorOptimisticTweet(onMutateResult); }, - onSuccess: () => { + onSuccess: async () => { queryClient.invalidateQueries({ queryKey: TWEET_QUERY_KEYS.toggleRepostTweet(tweetId), }); @@ -148,7 +148,7 @@ export const useToggleRepostTweet = ( }); if (user) - queryClient.refetchQueries({ + await queryClient.refetchQueries({ queryKey: PROFILE_QUERY_KEYS.profilePosts(user), }); // if (user) { @@ -246,7 +246,7 @@ export const useDeleteTweet = ( const queryClient = useQueryClient(); const { onMutate, handleErrorOptimisticTweet } = useOptimisticTweet(); const user = useAuth().user?.id; - + const { setBlockedFlag } = useActions(); return useMutation({ mutationFn: () => tweetApi.deleteTweet(tweetId), onMutate: () => { @@ -254,6 +254,7 @@ export const useDeleteTweet = ( }, onError: (error, variables, onMutateResult) => { handleErrorOptimisticTweet(onMutateResult); + setBlockedFlag(false); }, onSuccess: () => { diff --git a/src/features/tweets/utils/time.ts b/src/features/tweets/utils/time.ts index acd3bb84..371ead0d 100644 --- a/src/features/tweets/utils/time.ts +++ b/src/features/tweets/utils/time.ts @@ -25,6 +25,7 @@ export const formatDateRelative = (date: Date): string => { const year = date.getFullYear(); if (seconds < TIME_CONSTANTS.SECONDS_IN_MINUTE) { + if (seconds <= 0) return 'Just now'; return `${seconds}s`; } else if (seconds < TIME_CONSTANTS.SECONDS_IN_HOUR) { return `${Math.floor(seconds / TIME_CONSTANTS.SECONDS_IN_MINUTE)}m`;