From 4aa01fb4afb019a41aa3a536c453005e866c04de Mon Sep 17 00:00:00 2001 From: Tajim Date: Sun, 30 Aug 2026 20:47:40 +0600 Subject: [PATCH 1/2] feat(chat): add typing indicator with clickable usernames and fix onboarding dark mode inputs --- resources/js/pages/Chat/Index.vue | 198 ++++++++++++++++++++++++- resources/js/pages/auth/Onboarding.vue | 6 +- routes/channels.php | 4 +- 3 files changed, 201 insertions(+), 7 deletions(-) diff --git a/resources/js/pages/Chat/Index.vue b/resources/js/pages/Chat/Index.vue index 08b3bc09..336a5eef 100644 --- a/resources/js/pages/Chat/Index.vue +++ b/resources/js/pages/Chat/Index.vue @@ -107,6 +107,16 @@ const chatChannelName = computed( const presenceChannelName = computed(() => chatChannelName.value); const activeUsersCount = ref(0); +interface TypingUser { + id: number; + name: string; + username?: string; + timeout: ReturnType; +} +const typingUsers = ref>(new Map()); + +const typingUsersList = computed(() => Array.from(typingUsers.value.values())); + const maxMessagesLimit = ref(props.chatState.max_messages ?? 200); const maxLengthLimit = ref(props.chatState.max_length ?? 280); const messages = ref(props.chatState.messages || []); @@ -243,6 +253,40 @@ const filteredMentionUsers = computed(() => { .slice(0, 5); }); +let lastTypingSentAt = 0; + +const broadcastTyping = () => { + if (!currentUser.value) { + return; + } + + const now = Date.now(); + + if (now - lastTypingSentAt < 1500) { + return; + } + + lastTypingSentAt = now; + + const echo = getEcho( + props.chatState.pusher_key, + props.chatState.pusher_cluster, + ); + + if (echo) { + echo.join(presenceChannelName.value).whisper('typing', { + id: currentUser.value.id, + name: currentUser.value.name, + username: currentUser.value.username, + }); + } +}; + +const handleInput = () => { + checkMentionTrigger(); + broadcastTyping(); +}; + const checkMentionTrigger = () => { if (!messageInputRef.value) { showMentionSuggestions.value = false; @@ -495,6 +539,19 @@ const setupRealtime = () => { .stopListening('.settings.updated') .listen('.message.sent', (e: { message: ChatMessageItem }) => { if (e && e.message) { + if ( + e.message.user?.id && + typingUsers.value.has(e.message.user.id) + ) { + const existing = typingUsers.value.get(e.message.user.id); + + if (existing) { + clearTimeout(existing.timeout); + } + + typingUsers.value.delete(e.message.user.id); + } + if (!messages.value.some((m) => m.id === e.message.id)) { messages.value.push(e.message); @@ -657,9 +714,44 @@ const setupPresenceChannel = () => { .joining(() => { activeUsersCount.value++; }) - .leaving(() => { + .leaving((user: { id?: number }) => { activeUsersCount.value = Math.max(0, activeUsersCount.value - 1); + + if (user?.id && typingUsers.value.has(user.id)) { + const existing = typingUsers.value.get(user.id); + + if (existing) { + clearTimeout(existing.timeout); + } + + typingUsers.value.delete(user.id); + } }) + .listenForWhisper( + 'typing', + (e: { id?: number; name?: string; username?: string }) => { + if (!e?.id || e.id === currentUser.value?.id) { + return; + } + + const existing = typingUsers.value.get(e.id); + + if (existing) { + clearTimeout(existing.timeout); + } + + const timeout = setTimeout(() => { + typingUsers.value.delete(e.id!); + }, 3000); + + typingUsers.value.set(e.id, { + id: e.id, + name: e.name || 'Someone', + username: e.username, + timeout, + }); + }, + ) .error(() => { // Silently ignore presence auth errors (e.g. guest users) }); @@ -1232,6 +1324,11 @@ onMounted(() => { }); onUnmounted(() => { + typingUsers.value.forEach((user) => { + clearTimeout(user.timeout); + }); + typingUsers.value.clear(); + if (cooldownInterval) { clearInterval(cooldownInterval); } @@ -1757,6 +1854,103 @@ onUnmounted(() => {