Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const {
lastNewMessageinfo,
lastNotification,
unseenNotificationsCount,
timelineFollowingAvatars,
clearTimelineFollowingAvatars,
} = useDmSse({
autoReconnect: true,
maxReconnectAttempts: 5,
Expand All @@ -33,6 +35,8 @@ provide('dmUnseenCount', unseenCount);
provide('lastNewMessageinfo', lastNewMessageinfo);
provide('lastNotification', lastNotification);
provide('unseenNotificationsCount', unseenNotificationsCount);
provide('timelineFollowingAvatars', timelineFollowingAvatars);
provide('clearTimelineFollowingAvatars', clearTimelineFollowingAvatars);

watch(
() => userStore.user,
Expand Down
47 changes: 47 additions & 0 deletions app/components/tweet/NewTweetsIndicator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useQueryClient } from '@tanstack/vue-query';
import { tweetKeys } from '~/constants/query-keys';

interface Props {
avatars: string[]; // Array of avatar URLs
}

defineProps<Props>();

const emit = defineEmits<{
click: [];
}>();

const queryClient = useQueryClient();

const handleClick = () => {
queryClient.invalidateQueries({ queryKey: tweetKeys.timeline('following') });
emit('click');
};

// Show max 3 avatars
const displayAvatars = computed(() => {
return (avatars: string[]) => avatars.slice(0, 3);
});
</script>

<template>
<button
v-if="avatars.length > 0"
class="bg-primary/80 hover:bg-primary/20 border-primary/20 text-primary mx-auto flex w-fit cursor-pointer items-center gap-2 rounded-full border px-4 py-2 transition-colors"
data-testid="new-tweets-indicator"
@click="handleClick"
>
<div class="flex -space-x-2">
<UiAvatar
v-for="(avatarUrl, index) in displayAvatars(avatars)"
:key="index"
:img="avatarUrl || '/default_profile.png'"
size="xs"
variant="secondary"
class="ring-background ring-2"
/>
</div>
<span class="text-sm font-medium">{{ $t('home.new_tweets') }}</span>
</button>
</template>
18 changes: 17 additions & 1 deletion app/composables/useDmSse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@
export function useDmSse(options: UseDmSseOptions = {}) {
const { autoReconnect = true, maxReconnectAttempts = 5, baseReconnectDelay = 1000 } = options;

const SSEendpoint = `/api/stream?topics=dm,notifications`;
const SSEendpoint = `/api/stream?topics=dm,notifications,timeline`;
const unseenCount = ref<number>(0);
const lastNewMessageinfo = ref<DmSseEventMap['dm.new_message'] | null>(null);
const lastNotification = ref<Notification | null>(null);
const unseenNotificationsCount = ref<number>(0);
const timelineFollowingAvatars = ref<string[]>([]);
const isConnected = ref<boolean>(false);
const error = ref<Event | null>(null);
const reconnectAttempts = ref<number>(0);
Expand Down Expand Up @@ -138,14 +139,14 @@
});

es.addEventListener('notifications.delete', (evt: MessageEvent) => {
console.log('Received notifications.delete event:', evt.data);

Check warning on line 142 in app/composables/useDmSse.ts

View workflow job for this annotation

GitHub Actions / Code Style & Quality

Unexpected console statement. Only these console methods are allowed: warn, error
// invalidate notifications queries to refetch immediatly and update list
queryClient.invalidateQueries({ queryKey: ['notifications-main'] });
});

// negative updates: unlike/unretweet/unfollow
es.addEventListener('notifications.update', () => {
console.log('Received notifications.update event:');

Check warning on line 149 in app/composables/useDmSse.ts

View workflow job for this annotation

GitHub Actions / Code Style & Quality

Unexpected console statement. Only these console methods are allowed: warn, error
// invalidate notifications queries to refetch immediatly and update list
queryClient.invalidateQueries({ queryKey: ['notifications-main'] });
});
Expand Down Expand Up @@ -229,6 +230,15 @@
createError('Failed to parse notifications.new event data');
}
});

es.addEventListener('timeline.following', (evt: MessageEvent) => {
try {
const data = JSON.parse(evt.data) as DmSseEventMap['timeline.following'];
timelineFollowingAvatars.value = data.authors;
} catch {
createError('Failed to parse timeline.following event data');
}
});
} catch {
scheduleReconnect();
}
Expand All @@ -255,18 +265,24 @@
disconnect();
});

const clearTimelineFollowingAvatars = () => {
timelineFollowingAvatars.value = [];
};

return {
// state
unseenCount,
lastNewMessageinfo,
lastNotification,
unseenNotificationsCount,
timelineFollowingAvatars,
isConnected,
error,
reconnectAttempts,
// controls
connect,
disconnect,
reconnect,
clearTimelineFollowingAvatars,
};
}
12 changes: 12 additions & 0 deletions app/pages/home/[tab].vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { useRoute } from 'vue-router';
import TweetDefaultCard from '~/components/tweet/TweetDefaultCard.vue';
import VirtualInfiniteScroller from '~/components/common/VirtualInfiniteScroller.vue';
import NewTweetsIndicator from '~/components/tweet/NewTweetsIndicator.vue';
import { useTimelineTweets } from '~/composables/tweet/useTweetLists';
import { getItemKey } from '~/constants/query-keys';

Expand All @@ -17,6 +18,10 @@ definePageMeta({
const route = useRoute();
const tab = computed(() => route.params.tab as HomeTab);

// Inject timeline following avatars from SSE (array of avatar URL strings)
const timelineFollowingAvatars = inject<Ref<string[]>>('timelineFollowingAvatars', ref([]));
const clearTimelineFollowingAvatars = inject<() => void>('clearTimelineFollowingAvatars', () => {});

const {
data: response,
fetchNextPage,
Expand All @@ -28,13 +33,20 @@ const {

const tweets = computed(() => response.value?.pages.flatMap((page) => page.data) || []);

const handleNewTweetsClick = () => {
clearTimelineFollowingAvatars();
};

onServerPrefetch(async () => {
await suspense();
});
</script>

<template>
<div class="border-border mx-auto max-w-[700px]">
<div v-if="tab === 'following'" class="sticky top-20 z-50">
<NewTweetsIndicator :avatars="timelineFollowingAvatars" @click="handleNewTweetsClick" />
</div>
<ClientOnly fallback="span">
<template #fallback>
<div class="text-primary mt-20 flex shrink-0 items-center justify-center py-4">
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
"for-you": "لك",
"following": "متابعة"
},
"new_tweets": "منشورات جديدة",
"messages": {
"loadingMore": "جارٍ تحميل المزيد من التغريدات...",
"noMoreTweets": "لا توجد تغريدات أخرى"
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@
"for-you": "For You",
"following": "Following"
},
"new_tweets": "New posts",
"messages": {
"loadingMore": "Loading more tweets...",
"noMoreTweets": "No more tweets",
Expand Down
10 changes: 9 additions & 1 deletion shared/types/dm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ export interface DmConversationMessagesResponse {
messages: DmMessage[];
}

export type DmSseEventName = 'dm.unseen_conversations_count' | 'dm.new_message';
export type DmSseEventName =
| 'dm.unseen_conversations_count'
| 'dm.new_message'
| 'timeline.following';

export interface DmUnseenConversationsCountEventData {
count: number;
Expand All @@ -220,7 +223,12 @@ export interface DmNewMessageEventData {
createdAt: string;
}

export interface TimelineFollowingEventData {
authors: string[]; // Array of avatar URLs
}

export interface DmSseEventMap {
'dm.unseen_conversations_count': DmUnseenConversationsCountEventData;
'dm.new_message': DmNewMessageEventData;
'timeline.following': TimelineFollowingEventData;
}
Loading