diff --git a/resources/js/pages/Chat/Index.vue b/resources/js/pages/Chat/Index.vue index 08b3bc09..6e734ffb 100644 --- a/resources/js/pages/Chat/Index.vue +++ b/resources/js/pages/Chat/Index.vue @@ -107,6 +107,20 @@ 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 isPresenceSubscribed = ref(false); +const presenceMembers = ref< + Map +>(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 +257,40 @@ const filteredMentionUsers = computed(() => { .slice(0, 5); }); +let lastTypingSentAt = 0; + +const broadcastTyping = () => { + if (!currentUser.value || !isPresenceSubscribed.value) { + return; + } + + const now = Date.now(); + + if (now - lastTypingSentAt < 1500) { + return; + } + + const echo = getEcho( + props.chatState.pusher_key, + props.chatState.pusher_cluster, + ); + + if (echo) { + const userId = Number(currentUser.value.id); + echo.join(presenceChannelName.value).whisper('typing', { + id: userId, + name: currentUser.value.name, + username: currentUser.value.username, + }); + lastTypingSentAt = now; + } +}; + +const handleInput = () => { + checkMentionTrigger(); + broadcastTyping(); +}; + const checkMentionTrigger = () => { if (!messageInputRef.value) { showMentionSuggestions.value = false; @@ -495,6 +543,20 @@ const setupRealtime = () => { .stopListening('.settings.updated') .listen('.message.sent', (e: { message: ChatMessageItem }) => { if (e && e.message) { + if (e.message.user?.id !== undefined) { + const senderId = Number(e.message.user.id); + + if (!isNaN(senderId) && typingUsers.value.has(senderId)) { + const existing = typingUsers.value.get(senderId); + + if (existing) { + clearTimeout(existing.timeout); + } + + typingUsers.value.delete(senderId); + } + } + if (!messages.value.some((m) => m.id === e.message.id)) { messages.value.push(e.message); @@ -651,15 +713,116 @@ const setupPresenceChannel = () => { } echo.join(presenceChannelName.value) - .here((users: unknown[]) => { - activeUsersCount.value = users.length; - }) - .joining(() => { - activeUsersCount.value++; - }) - .leaving(() => { + .here( + ( + users: Array<{ + id: number | string; + name: string; + username?: string; + }>, + ) => { + isPresenceSubscribed.value = true; + activeUsersCount.value = users.length; + presenceMembers.value.clear(); + users.forEach((u) => { + const uid = Number(u.id); + + if (!isNaN(uid)) { + presenceMembers.value.set(uid, { + id: uid, + name: u.name, + username: u.username, + }); + } + }); + }, + ) + .joining( + (user: { + id: number | string; + name: string; + username?: string; + }) => { + activeUsersCount.value++; + const uid = Number(user.id); + + if (!isNaN(uid)) { + presenceMembers.value.set(uid, { + id: uid, + name: user.name, + username: user.username, + }); + } + }, + ) + .leaving((user: { id?: number | string }) => { activeUsersCount.value = Math.max(0, activeUsersCount.value - 1); + + if (user?.id !== undefined) { + const uid = Number(user.id); + + if (!isNaN(uid)) { + presenceMembers.value.delete(uid); + + if (typingUsers.value.has(uid)) { + const existing = typingUsers.value.get(uid); + + if (existing) { + clearTimeout(existing.timeout); + } + + typingUsers.value.delete(uid); + } + } + } }) + .listenForWhisper( + 'typing', + ( + e: { id?: number | string; name?: string; username?: string }, + metadata?: { user_id?: number | string }, + ) => { + const rawId = metadata?.user_id ?? e?.id; + + if (rawId === undefined || rawId === null) { + return; + } + + const senderId = Number(rawId); + const currentUserId = currentUser.value?.id + ? Number(currentUser.value.id) + : null; + + if ( + isNaN(senderId) || + (currentUserId !== null && senderId === currentUserId) + ) { + return; + } + + // Prefer authenticated presence member info from backend presence channel + const member = presenceMembers.value.get(senderId); + const name = member?.name || e.name || 'Someone'; + const username = member?.username || e.username; + + const existing = typingUsers.value.get(senderId); + + if (existing) { + clearTimeout(existing.timeout); + } + + const timeout = setTimeout(() => { + typingUsers.value.delete(senderId); + }, 3000); + + typingUsers.value.set(senderId, { + id: senderId, + name, + username, + timeout, + }); + }, + ) .error(() => { // Silently ignore presence auth errors (e.g. guest users) }); @@ -1232,6 +1395,14 @@ onMounted(() => { }); onUnmounted(() => { + isPresenceSubscribed.value = false; + presenceMembers.value.clear(); + + typingUsers.value.forEach((user) => { + clearTimeout(user.timeout); + }); + typingUsers.value.clear(); + if (cooldownInterval) { clearInterval(cooldownInterval); } @@ -1757,6 +1928,103 @@ onUnmounted(() => {