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:
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
|||||||
import { useMessageStore } from "../stores/message.ts";
|
import { useMessageStore } from "../stores/message.ts";
|
||||||
import { useChannelStore } from "../stores/channel.ts";
|
import { useChannelStore } from "../stores/channel.ts";
|
||||||
import { useServerStore } from "../stores/server.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 { useMemberStore } from "../stores/member.ts";
|
||||||
import { useThreadStore } from "../stores/thread.ts";
|
import { useThreadStore } from "../stores/thread.ts";
|
||||||
import { GiphyPicker, type Gif } from "./GiphyPicker";
|
import { GiphyPicker, type Gif } from "./GiphyPicker";
|
||||||
@@ -119,6 +121,9 @@ export function ChatArea() {
|
|||||||
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
||||||
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
|
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
|
||||||
const sendMessage = useMessageStore((s) => s.sendMessage);
|
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 membersByServer = useMemberStore((s) => s.membersByServer);
|
||||||
const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []);
|
const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
@@ -134,6 +139,7 @@ export function ChatArea() {
|
|||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const lastTypingRef = useRef<number>(0);
|
||||||
|
|
||||||
const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage);
|
const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage);
|
||||||
const clearSelectedMessages = useMessageStore((s) => s.clearSelectedMessages);
|
const clearSelectedMessages = useMessageStore((s) => s.clearSelectedMessages);
|
||||||
@@ -200,6 +206,16 @@ export function ChatArea() {
|
|||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
const cursor = e.target.selectionStart ?? value.length;
|
const cursor = e.target.selectionStart ?? value.length;
|
||||||
setInput(value);
|
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 beforeCursor = value.slice(0, cursor);
|
||||||
const atIndex = beforeCursor.lastIndexOf("@");
|
const atIndex = beforeCursor.lastIndexOf("@");
|
||||||
if (atIndex === -1) {
|
if (atIndex === -1) {
|
||||||
@@ -401,6 +417,13 @@ export function ChatArea() {
|
|||||||
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
|
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div className="px-3 pb-1">
|
<div className="px-3 pb-1">
|
||||||
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
|
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback } from "react";
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
|
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
|
||||||
import { useAuthStore } from "../stores/auth.ts";
|
import { useAuthStore } from "../stores/auth.ts";
|
||||||
|
import { useTypingStore } from "../stores/typing.ts";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
@@ -41,10 +42,13 @@ export function DMChat() {
|
|||||||
const sendMessage = useConversationStore((s) => s.sendMessage);
|
const sendMessage = useConversationStore((s) => s.sendMessage);
|
||||||
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
|
const typingUsers = useTypingStore((s) => s.typingUsers);
|
||||||
|
const sendTypingStart = useTypingStore((s) => s.sendTypingStart);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastTypingRef = useRef<number>(0);
|
||||||
|
|
||||||
const id = conversationId || activeId;
|
const id = conversationId || activeId;
|
||||||
const conversation = conversations.find((c) => c.id === id);
|
const conversation = conversations.find((c) => c.id === id);
|
||||||
@@ -120,6 +124,13 @@ export function DMChat() {
|
|||||||
))}
|
))}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
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 <div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic">{text}</div>;
|
||||||
|
})()}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-3 pb-1">
|
<div className="px-3 pb-1">
|
||||||
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
|
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
|
||||||
@@ -130,7 +141,16 @@ export function DMChat() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => 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..."
|
placeholder="type a message..."
|
||||||
className="terminal-input w-full"
|
className="terminal-input w-full"
|
||||||
disabled={!id}
|
disabled={!id}
|
||||||
|
|||||||
Reference in New Issue
Block a user