From 7d67d6f36018e5e9bddc256973c1e2c4d5becce0 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 2 Jul 2026 09:33:51 -0400 Subject: [PATCH] feat: typing indicators in channel and DM chats Wired existing typing store (backend WS broadcast + frontend _handleTypingEvent with 3s auto-expiry) into ChatArea and DMChat. Typing events throttled to once per 3s. Shows 'X is typing...' or 'X and N others are typing...' below the messages, above the input. --- web/src/components/ChatArea.tsx | 23 +++++++++++++++++++++++ web/src/components/DMChat.tsx | 22 +++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 119bb6c..8b6e0ff 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { useMessageStore } from "../stores/message.ts"; import { useChannelStore } from "../stores/channel.ts"; import { useServerStore } from "../stores/server.ts"; +import { useTypingStore } from "../stores/typing.ts"; +import { useAuthStore } from "../stores/auth.ts"; import { useMemberStore } from "../stores/member.ts"; import { useThreadStore } from "../stores/thread.ts"; import { GiphyPicker, type Gif } from "./GiphyPicker"; @@ -119,6 +121,9 @@ export function ChatArea() { const fetchMessages = useMessageStore((s) => s.fetchMessages); const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages); const sendMessage = useMessageStore((s) => s.sendMessage); + const typingUsers = useTypingStore((s) => s.typingUsers); + const sendTypingStart = useTypingStore((s) => s.sendTypingStart); + const currentUser = useAuthStore((s) => s.user); const membersByServer = useMemberStore((s) => s.membersByServer); const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []); const [input, setInput] = useState(""); @@ -134,6 +139,7 @@ export function ChatArea() { const bottomRef = useRef(null); const scrollContainerRef = useRef(null); const inputRef = useRef(null); + const lastTypingRef = useRef(0); const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage); const clearSelectedMessages = useMessageStore((s) => s.clearSelectedMessages); @@ -200,6 +206,16 @@ export function ChatArea() { const value = e.target.value; const cursor = e.target.selectionStart ?? value.length; setInput(value); + + // ponytail: throttle typing indicator to once per 3s + if (activeChannelId && value.length > 0) { + const now = Date.now(); + if (now - lastTypingRef.current > 3000) { + sendTypingStart(activeChannelId); + lastTypingRef.current = now; + } + } + const beforeCursor = value.slice(0, cursor); const atIndex = beforeCursor.lastIndexOf("@"); if (atIndex === -1) { @@ -401,6 +417,13 @@ export function ChatArea() { setShowGifPicker(false)} /> )} + {(() => { + const chTyping = activeChannelId ? (typingUsers[activeChannelId] || []).filter(u => u.userId !== currentUser?.id) : []; + if (chTyping.length === 0) return null; + const names = chTyping.map(u => u.username); + const text = names.length === 1 ? `${names[0]} is typing...` : names.length === 2 ? `${names[0]} and ${names[1]} are typing...` : `${names[0]} and ${names.length - 1} others are typing...`; + return
{text}
; + })()} {error && (

ERR: {error}

diff --git a/web/src/components/DMChat.tsx b/web/src/components/DMChat.tsx index 6588974..b807fa5 100644 --- a/web/src/components/DMChat.tsx +++ b/web/src/components/DMChat.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { useParams } from "react-router-dom"; import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts"; import { useAuthStore } from "../stores/auth.ts"; +import { useTypingStore } from "../stores/typing.ts"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -41,10 +42,13 @@ export function DMChat() { const sendMessage = useConversationStore((s) => s.sendMessage); const fetchConversations = useConversationStore((s) => s.fetchConversations); const currentUser = useAuthStore((s) => s.user); + const typingUsers = useTypingStore((s) => s.typingUsers); + const sendTypingStart = useTypingStore((s) => s.sendTypingStart); const [input, setInput] = useState(""); const [error, setError] = useState(null); const bottomRef = useRef(null); const scrollContainerRef = useRef(null); + const lastTypingRef = useRef(0); const id = conversationId || activeId; const conversation = conversations.find((c) => c.id === id); @@ -120,6 +124,13 @@ export function DMChat() { ))}
+ {(() => { + const convTyping = id ? (typingUsers[id] || []).filter(u => u.userId !== currentUser?.id) : []; + if (convTyping.length === 0) return null; + const names = convTyping.map(u => u.username); + const text = names.length === 1 ? `${names[0]} is typing...` : names.length === 2 ? `${names[0]} and ${names[1]} are typing...` : `${names[0]} and ${names.length - 1} others are typing...`; + return
{text}
; + })()} {error && (

ERR: {error}

@@ -130,7 +141,16 @@ export function DMChat() { setInput(e.target.value)} + onChange={(e) => { + setInput(e.target.value); + if (id && e.target.value.length > 0) { + const now = Date.now(); + if (now - lastTypingRef.current > 3000) { + sendTypingStart(id); + lastTypingRef.current = now; + } + } + }} placeholder="type a message..." className="terminal-input w-full" disabled={!id}