Skip to content

Commit b0f2312

Browse files
feat: add tweet deletion functionality - [CU-869beatwc] (#168)
Co-authored-by: Ahmed Amr <ahmedamr24680@gmail.com>
1 parent 3f00a2d commit b0f2312

14 files changed

Lines changed: 206 additions & 41 deletions

File tree

app/components/tweet/TweetDefaultCard.vue

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,16 @@ import Avatar from '~/components/ui/Avatar.vue';
44
import type { Tweet } from '~~/shared/types/tweets';
55
import TweetMedia from './TweetMedia.vue';
66
import TweetActionButtons from './TweetActionButtons.vue';
7-
import { useUserStore } from '~/stores/user';
87
import QuotedTweetCard from './QuotedTweetCard.vue';
98
import AiSummary from './AiSummary.vue';
9+
import { useUserStore } from '~/stores/user';
1010
interface Props {
1111
tweet: Tweet;
1212
isParent?: boolean;
1313
isRoot?: boolean;
1414
}
1515
const props = defineProps<Props>();
1616
const router = useRouter();
17-
1817
const userStore = useUserStore();
1918
const originalUsername = ref<string>(userStore.user?.username || '');
2019
@@ -159,7 +158,7 @@ const { mutate: blockUser } = useBlockMutation();
159158
>
160159
<Icon name="vscode-icons:file-type-gemini" size="1.2rem" />
161160
</UiButton>
162-
<TweetDropdown :tweet="props.tweet">
161+
<TweetDropdown :tweet="props.tweet" :username="originalUsername">
163162
<UiButton
164163
variant="ghost-default"
165164
size="icon-xs"
Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,101 @@
11
<script lang="ts" setup>
2-
defineProps<{
2+
import { useQueryClient, type InfiniteData } from '@tanstack/vue-query';
3+
import { deleteTweet } from '~/services/tweet/actionButtonsService';
4+
import { showToaster } from '~/utils/showToaster';
5+
import type { Tweet } from '~~/shared/types/tweets';
6+
7+
const props = defineProps<{
38
tweet: Tweet;
9+
username: string;
410
}>();
11+
12+
const queryClient = useQueryClient();
13+
14+
interface TweetPage {
15+
data: Tweet[];
16+
[key: string]: unknown;
17+
}
18+
19+
function removeTweetFromInfiniteData(
20+
data: InfiniteData<TweetPage> | undefined,
21+
tweetId: string,
22+
): InfiniteData<TweetPage> | undefined {
23+
if (!data || !data.pages) return data;
24+
return {
25+
...data,
26+
pages: data.pages.map((page) => ({
27+
...page,
28+
data: page.data.filter((t: Tweet) => t.id !== tweetId),
29+
})),
30+
};
31+
}
32+
33+
async function handleDelete() {
34+
try {
35+
await deleteTweet(props.tweet.id);
36+
showToaster('success', $t('tweet.delete-success'));
37+
38+
const queryKeys = [
39+
['for-you'],
40+
['following'],
41+
['profile', props.tweet.author.username, 'tweets'],
42+
['profile', props.tweet.author.username, 'tweets-replies'],
43+
['profile', props.tweet.author.username, 'tweets-media'],
44+
['profile', props.tweet.author.username, 'tweets-likes'],
45+
];
46+
47+
queryKeys.forEach((key) => {
48+
queryClient.setQueriesData<InfiniteData<TweetPage>>({ queryKey: key }, (oldData) =>
49+
removeTweetFromInfiniteData(oldData, props.tweet.id),
50+
);
51+
});
52+
} catch {
53+
showToaster('error', $t('tweet.delete-error'));
54+
}
55+
}
556
</script>
657
<template>
7-
<UiDropdownMenu>
8-
<UiDropdownMenuTrigger as-child>
9-
<slot />
10-
</UiDropdownMenuTrigger>
11-
<UiDropdownMenuContent align="end">
12-
<UiDropdownMenuItem as-child>
13-
<NuxtLink :to="`/profile/${tweet.author.username}/status/${tweet.id}/likes`">
14-
<Icon name="ion:stats-chart" />{{ $t('tweet.engagement.label') }}
15-
</NuxtLink>
16-
</UiDropdownMenuItem>
17-
</UiDropdownMenuContent>
18-
</UiDropdownMenu>
58+
<UiAlertDialog>
59+
<UiDropdownMenu>
60+
<UiDropdownMenuTrigger as-child>
61+
<slot />
62+
</UiDropdownMenuTrigger>
63+
<UiDropdownMenuContent align="end">
64+
<UiDropdownMenuItem as-child>
65+
<NuxtLink
66+
:to="`/profile/${props.tweet.author.username}/status/${props.tweet.id}/likes`"
67+
class="ltr:flex-row rtl:flex-row-reverse"
68+
>
69+
<Icon name="ion:stats-chart" />{{ $t('tweet.engagement.label') }}
70+
</NuxtLink>
71+
</UiDropdownMenuItem>
72+
<UiDropdownMenuItem as-child @select.prevent>
73+
<UiAlertDialogTrigger as-child class="text-destructive ltr:flex-row rtl:flex-row-reverse">
74+
<div v-if="props.username === props.tweet.author.username">
75+
<Icon name="mi:delete" />
76+
<p>{{ $t('tweet.delete-tweet') }}</p>
77+
</div>
78+
</UiAlertDialogTrigger>
79+
</UiDropdownMenuItem>
80+
</UiDropdownMenuContent>
81+
</UiDropdownMenu>
82+
83+
<UiAlertDialogContent>
84+
<UiAlertDialogHeader>
85+
<UiAlertDialogTitle>{{ $t('tweet.delete-dialog.title') }}</UiAlertDialogTitle>
86+
<UiAlertDialogDescription>
87+
{{ $t('tweet.delete-dialog.description') }}
88+
</UiAlertDialogDescription>
89+
</UiAlertDialogHeader>
90+
<UiAlertDialogFooter>
91+
<UiAlertDialogAction
92+
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
93+
@click="handleDelete"
94+
>
95+
{{ $t('ui.delete') }}
96+
</UiAlertDialogAction>
97+
<UiAlertDialogCancel>{{ $t('ui.cancel') }}</UiAlertDialogCancel>
98+
</UiAlertDialogFooter>
99+
</UiAlertDialogContent>
100+
</UiAlertDialog>
19101
</template>

app/components/tweet/TweetView.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ function handleAiSummary() {
141141
>
142142
<Icon name="vscode-icons:file-type-gemini" size="1.2rem" />
143143
</UiButton>
144-
<TweetDropdown :tweet="props.tweet">
144+
<TweetDropdown :tweet="props.tweet" :username="originalUsername">
145145
<UiButton
146146
variant="ghost-default"
147147
size="icon-xs"

app/pages/home/[tab].vue

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ref, onMounted } from 'vue';
33
import { useRoute } from 'vue-router';
44
import TweetDefaultCard from '~/components/tweet/TweetDefaultCard.vue';
55
import { homeService } from '~/services/home/homeService';
6-
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query';
6+
import { useInfiniteQuery, useQueryClient, type InfiniteData } from '@tanstack/vue-query';
77
import { useWindowVirtualizer } from '@tanstack/vue-virtual';
88
99
function isTab(value: unknown): value is HomeTab {
@@ -50,7 +50,6 @@ const rowVirtualizerOptions = computed(() => {
5050
estimateSize: () => 120,
5151
overscan: 3,
5252
scrollMargin: parentOffsetRef.value,
53-
getItemKey: (index: number) => tweets.value[index]?.id || index,
5453
};
5554
});
5655
@@ -85,23 +84,23 @@ const queryClient = useQueryClient();
8584
function handlePost(tweet: Tweet) {
8685
// Optimistically add the new tweet to the top of the list
8786
if (!tab.value) return;
88-
queryClient.setQueryData<{
89-
pages: Array<{ data: Tweet[]; pagination?: CursorPagination }>;
90-
pageParams: Array<string | null>;
91-
}>([tab.value], (oldData) => {
92-
if (!oldData) return oldData;
93-
const newData = {
94-
...oldData,
95-
pages: [
96-
{
97-
data: [tweet, ...(oldData.pages[0]?.data || [])],
98-
pagination: oldData.pages[0]?.pagination,
99-
},
100-
...oldData.pages.slice(1),
101-
],
102-
};
103-
return newData;
104-
});
87+
queryClient.setQueryData<InfiniteData<{ data: Tweet[]; pagination?: CursorPagination }>>(
88+
[tab.value],
89+
(oldData) => {
90+
if (!oldData) return oldData;
91+
const newData = {
92+
...oldData,
93+
pages: [
94+
{
95+
data: [tweet, ...(oldData.pages[0]?.data || [])],
96+
pagination: oldData.pages[0]?.pagination,
97+
},
98+
...oldData.pages.slice(1),
99+
],
100+
};
101+
return newData;
102+
},
103+
);
105104
}
106105
107106
watch(

app/pages/profile/[username]/index.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ onServerPrefetch(async () => {
5656
:has-next-page="hasNextPage"
5757
:is-fetching-next-page="isFetchingNextPage"
5858
:fetch-next-page="fetchNextPage"
59+
:get-key="(item, index, key) => `${item?.id}-${key || index}`"
5960
>
6061
<template #item="{ item }">
6162
<TweetDefaultCard v-if="item" :tweet="item" />

app/pages/profile/[username]/likes.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ onServerPrefetch(async () => {
4747
:has-next-page="hasNextPage"
4848
:is-fetching-next-page="isFetchingNextPage"
4949
:fetch-next-page="fetchNextPage"
50+
:get-key="(item, index, key) => `${item?.id}-${key || index}`"
5051
>
5152
<template #item="{ item }">
5253
<TweetDefaultCard v-if="item" :tweet="item" />

app/pages/profile/[username]/media.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ onServerPrefetch(async () => {
6161
:has-next-page="hasNextPage"
6262
:is-fetching-next-page="isFetchingNextPage"
6363
:fetch-next-page="fetchNextPage"
64+
:get-key="
65+
(item, index, key) => `${item.map((tweet) => `${tweet.id}-${key || index}`).join('-')}`
66+
"
6467
>
6568
<template #item="{ item }">
6669
<div class="grid grid-cols-3 gap-1 overflow-hidden pt-1">

app/pages/profile/[username]/replies.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ onServerPrefetch(async () => {
4747
:has-next-page="hasNextPage"
4848
:is-fetching-next-page="isFetchingNextPage"
4949
:fetch-next-page="fetchNextPage"
50+
:get-key="(item, index, key) => `${item?.id}-${key || index}`"
5051
>
5152
<template #item="{ item }">
5253
<TweetDefaultCard v-if="item" :tweet="item" />

app/services/tweet/actionButtonsService.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,18 @@ export const undoRetweetTweet = async (tweetId: string) => {
5656
throw error;
5757
}
5858
};
59+
60+
export const deleteTweet = async (tweetId: string) => {
61+
try {
62+
const response = await apiFetch<{
63+
success: boolean;
64+
message: string;
65+
}>(`/api/tweets/${tweetId}/delete`, {
66+
method: 'DELETE',
67+
});
68+
return response;
69+
} catch (error) {
70+
console.error(`Failed to delete tweet:`, error);
71+
throw error;
72+
}
73+
};

i18n/locales/ar.json

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,37 @@
258258
"posting-tweet": "جارٍ نشر التغريدة..."
259259
}
260260
},
261+
"delete-tweet": "حذف",
262+
"delete-success": "تم حذف تغريدتك بنجاح",
263+
"delete-dialog": {
264+
"title": "حذف التغريدة؟",
265+
"description": "لا يمكن التراجع عن هذا وسيتم إزالتها من ملفك الشخصي، وخط الزمن لأي حسابات تتابعك، ومن نتائج بحث تويتر."
266+
},
267+
"engagement": {
268+
"label": "عرض تفاعلات المنشور",
269+
"title": "تفاعلات المنشور",
270+
"likes": {
271+
"title": "الإعجابات",
272+
"empty": {
273+
"title": "لا توجد إعجابات بعد",
274+
"description": "عندما يضغط شخص ما على القلب للإعجاب بهذا المنشور، سيظهر هنا."
275+
}
276+
},
277+
"reposts": {
278+
"title": "إعادة النشر",
279+
"empty": {
280+
"title": "لا توجد عمليات إعادة نشر بعد",
281+
"description": "شارك منشور شخص آخر على خطك الزمني عن طريق إعادة نشره. عندما تفعل ذلك، سيظهر هنا."
282+
}
283+
},
284+
"quotes": {
285+
"title": "الاقتباسات",
286+
"empty": {
287+
"title": "لا توجد اقتباسات بعد",
288+
"description": "ستجد قائمة بكل من اقتبس هذا المنشور هنا."
289+
}
290+
}
291+
},
261292
"no-replies": "لا توجد ردود بعد",
262293
"upload-limit-image": "الصورة {file} حجمها يتجاوز الحد الأقصى المسموح به وهو {size} ميجابايت.",
263294
"upload-limit-video": "الفيديو {file} حجمه يتجاوز الحد الأقصى المسموح به وهو {size} ميجابايت.",
@@ -271,6 +302,8 @@
271302
},
272303
"retweeted-by-you": "لقد أعدتَ النشر",
273304
"show-more-parents": "عرض المزيد من التغريدات الأصلية",
305+
"deleted-quote": "هذا المنشور غير متاح.",
306+
"deleted-parent": "تم حذف هذا المنشور بواسطة المؤلف.",
274307
"replying-to": "الرد علي"
275308
},
276309
"notifications": {
@@ -296,6 +329,7 @@
296329
"cancel": "إلغاء",
297330
"clear": "مسح",
298331
"discard": "تجاهل",
332+
"delete": "حذف",
299333
"post": "نشر",
300334
"reply": "رد",
301335
"follow": "متابعة",
@@ -358,7 +392,7 @@
358392
"FILE_TOO_LARGE": "حجم الملف يتجاوز حد {size}MB. يرجى اختيار ملف أصغر.",
359393
"FAILED_UPDATE_X": "فشل في تحديث {field}. يرجى المحاولة مرة أخرى لاحقًا.",
360394
"otp": {
361-
"INVALID_TOKEN": "رمز OTP الذي أدخلته غير صالح",
395+
"INVALID_TOKEN": "رمز OTP الذي أدخله غير صالح",
362396
"ISLENGTH": "يجب أن يتكون OTP من 6 أرقام",
363397
"IS_EMPTY": "الرجاء إدخال رمز OTP الخاص بك"
364398
},
@@ -618,6 +652,10 @@
618652
"resetPassword": {
619653
"success": "تم إعادة تعيين كلمة المرور بنجاح",
620654
"error": "فشل في إعادة تعيين كلمة المرور"
655+
},
656+
"tweet": {
657+
"delete-success": "تم حذف منشورك",
658+
"delete-error": "فشل حذف المنشور"
621659
}
622660
},
623661
"icons": {

0 commit comments

Comments
 (0)