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.
This commit is contained in:
2026-07-02 09:33:51 -04:00
parent 08dfc4e400
commit 7d67d6f360
2 changed files with 44 additions and 1 deletions
+23
View File
@@ -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<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const lastTypingRef = useRef<number>(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() {
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
</div>
)}
{(() => {
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 <div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic">{text}</div>;
})()}
{error && (
<div className="px-3 pb-1">
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>