Skip to content

Commit 6a1911b

Browse files
committed
add typing indicators
1 parent 362e362 commit 6a1911b

3 files changed

Lines changed: 246 additions & 5 deletions

File tree

client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatBoxComp.tsx

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { EditorContext } from "comps/editorState";
2828
import { trans } from "i18n";
2929

3030
import { useChatStore } from "./useChatStore";
31-
import type { ChatMessage, ChatRoom, RoomMember, SyncMode } from "./chatDataStore";
31+
import type { ChatMessage, ChatRoom, RoomMember, SyncMode, TypingUser } from "./chatDataStore";
3232

3333
// ─── Event definitions ──────────────────────────────────────────────────────
3434

@@ -211,6 +211,46 @@ const EmptyChat = styled.div`
211211
gap: 4px;
212212
`;
213213

214+
const TypingIndicatorWrapper = styled.div`
215+
display: flex;
216+
align-items: center;
217+
gap: 8px;
218+
padding: 4px 0;
219+
align-self: flex-start;
220+
`;
221+
222+
const TypingDots = styled.span`
223+
display: inline-flex;
224+
align-items: center;
225+
gap: 3px;
226+
background: #e8e8e8;
227+
border-radius: 12px;
228+
padding: 8px 12px;
229+
230+
span {
231+
width: 6px;
232+
height: 6px;
233+
border-radius: 50%;
234+
background: #999;
235+
animation: typingBounce 1.4s infinite ease-in-out both;
236+
}
237+
238+
span:nth-child(1) { animation-delay: 0s; }
239+
span:nth-child(2) { animation-delay: 0.2s; }
240+
span:nth-child(3) { animation-delay: 0.4s; }
241+
242+
@keyframes typingBounce {
243+
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
244+
40% { transform: scale(1); opacity: 1; }
245+
}
246+
`;
247+
248+
const TypingLabel = styled.span`
249+
font-size: 12px;
250+
color: #999;
251+
font-style: italic;
252+
`;
253+
214254
const SearchResultBadge = styled.span`
215255
font-size: 10px;
216256
background: #e6f7ff;
@@ -257,6 +297,8 @@ const ChatBoxView = React.memo((props: any) => {
257297
const [isSearchMode, setIsSearchMode] = useState(false);
258298
const [createForm] = Form.useForm();
259299
const messagesEndRef = useRef<HTMLDivElement>(null);
300+
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
301+
const isTypingRef = useRef(false);
260302

261303
// Auto-scroll on new messages
262304
useEffect(() => {
@@ -265,14 +307,30 @@ const ChatBoxView = React.memo((props: any) => {
265307

266308
// ── Handlers ───────────────────────────────────────────────────────────
267309

310+
const clearTypingTimeout = useCallback(() => {
311+
if (typingTimeoutRef.current) {
312+
clearTimeout(typingTimeoutRef.current);
313+
typingTimeoutRef.current = null;
314+
}
315+
}, []);
316+
317+
const handleStopTyping = useCallback(() => {
318+
clearTypingTimeout();
319+
if (isTypingRef.current) {
320+
isTypingRef.current = false;
321+
chat.stopTyping();
322+
}
323+
}, [chat.stopTyping, clearTypingTimeout]);
324+
268325
const handleSend = useCallback(async () => {
269326
if (!draft.trim()) return;
327+
handleStopTyping();
270328
const ok = await chat.sendMessage(draft);
271329
if (ok) {
272330
setDraft("");
273331
onEvent("messageSent");
274332
}
275-
}, [draft, chat.sendMessage, onEvent]);
333+
}, [draft, chat.sendMessage, onEvent, handleStopTyping]);
276334

277335
const handleKeyDown = useCallback(
278336
(e: React.KeyboardEvent) => {
@@ -284,6 +342,39 @@ const ChatBoxView = React.memo((props: any) => {
284342
[handleSend],
285343
);
286344

345+
const handleInputChange = useCallback(
346+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
347+
const value = e.target.value;
348+
setDraft(value);
349+
350+
if (!value.trim()) {
351+
handleStopTyping();
352+
return;
353+
}
354+
355+
if (!isTypingRef.current) {
356+
isTypingRef.current = true;
357+
chat.startTyping();
358+
}
359+
360+
clearTypingTimeout();
361+
typingTimeoutRef.current = setTimeout(() => {
362+
handleStopTyping();
363+
}, 2000);
364+
},
365+
[chat.startTyping, handleStopTyping, clearTypingTimeout],
366+
);
367+
368+
// Cleanup typing timeout on unmount
369+
useEffect(() => {
370+
return () => {
371+
clearTypingTimeout();
372+
if (isTypingRef.current) {
373+
chat.stopTyping();
374+
}
375+
};
376+
}, [chat.stopTyping, clearTypingTimeout]);
377+
287378
const handleCreateRoom = useCallback(
288379
async (values: { roomName: string; roomType: "public" | "private"; description?: string }) => {
289380
const room = await chat.createRoom(values.roomName.trim(), values.roomType, values.description);
@@ -495,13 +586,27 @@ const ChatBoxView = React.memo((props: any) => {
495586
);
496587
})
497588
)}
589+
{chat.typingUsers.length > 0 && (
590+
<TypingIndicatorWrapper>
591+
<TypingDots>
592+
<span />
593+
<span />
594+
<span />
595+
</TypingDots>
596+
<TypingLabel>
597+
{chat.typingUsers.length === 1
598+
? `${chat.typingUsers[0].userName} is typing...`
599+
: `${chat.typingUsers.length} people are typing...`}
600+
</TypingLabel>
601+
</TypingIndicatorWrapper>
602+
)}
498603
<div ref={messagesEndRef} />
499604
</MessagesArea>
500605

501606
<InputBar>
502607
<StyledTextArea
503608
value={draft}
504-
onChange={(e) => setDraft(e.target.value)}
609+
onChange={handleInputChange}
505610
onKeyDown={handleKeyDown}
506611
placeholder={chat.ready ? "Type a message..." : "Connecting..."}
507612
disabled={!chat.ready || !chat.currentRoom}

client/packages/lowcoder/src/comps/comps/chatBoxComponentv2/chatDataStore.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ export interface RoomMember {
3030
joinedAt: number;
3131
}
3232

33+
export interface TypingUser {
34+
userId: string;
35+
userName: string;
36+
roomId: string;
37+
startedAt: number;
38+
}
39+
3340
export type ChatStoreListener = () => void;
3441

3542
export type SyncMode = "local" | "collaborative" | "hybrid";
@@ -60,6 +67,10 @@ export interface IChatStore {
6067

6168
sendMessage(roomId: string, authorId: string, authorName: string, text: string): Promise<ChatMessage>;
6269
getMessages(roomId: string, limit?: number): Promise<ChatMessage[]>;
70+
71+
startTyping(roomId: string, userId: string, userName: string): void;
72+
stopTyping(roomId: string, userId: string): void;
73+
getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[];
6374
}
6475

6576
// ─── ALASql local store ──────────────────────────────────────────────────────
@@ -70,6 +81,7 @@ export class LocalChatStore implements IChatStore {
7081
private dbName: string;
7182
private ready = false;
7283
private listeners = new Set<ChatStoreListener>();
84+
private typingMap = new Map<string, TypingUser>();
7385

7486
constructor(applicationId: string) {
7587
this.dbName = `ChatV2_${applicationId.replace(/[^a-zA-Z0-9_]/g, "_")}`;
@@ -236,6 +248,36 @@ export class LocalChatStore implements IChatStore {
236248
return rows.slice(-limit);
237249
}
238250

251+
// ── Typing ─────────────────────────────────────────────────────────────
252+
253+
private typingKey(roomId: string, userId: string) { return `${roomId}::${userId}`; }
254+
255+
startTyping(roomId: string, userId: string, userName: string): void {
256+
this.typingMap.set(this.typingKey(roomId, userId), { userId, userName, roomId, startedAt: Date.now() });
257+
this.notify();
258+
}
259+
260+
stopTyping(roomId: string, userId: string): void {
261+
if (this.typingMap.delete(this.typingKey(roomId, userId))) {
262+
this.notify();
263+
}
264+
}
265+
266+
getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] {
267+
const now = Date.now();
268+
const result: TypingUser[] = [];
269+
for (const [key, entry] of this.typingMap) {
270+
if (entry.roomId !== roomId) continue;
271+
if (excludeUserId && entry.userId === excludeUserId) continue;
272+
if (now - entry.startedAt > 5000) {
273+
this.typingMap.delete(key);
274+
continue;
275+
}
276+
result.push(entry);
277+
}
278+
return result;
279+
}
280+
239281
// ── Internal ───────────────────────────────────────────────────────────
240282

241283
private assert(): void {
@@ -251,6 +293,7 @@ export class YjsChatStore implements IChatStore {
251293
private messagesMap: Y.Map<any> | null = null;
252294
private roomsMap: Y.Map<any> | null = null;
253295
private membersMap: Y.Map<any> | null = null;
296+
private typingYMap: Y.Map<any> | null = null;
254297
private listeners = new Set<ChatStoreListener>();
255298
private ready = false;
256299
private wsConnected = false;
@@ -299,12 +342,14 @@ export class YjsChatStore implements IChatStore {
299342
this.messagesMap = ydoc.getMap("messages");
300343
this.roomsMap = ydoc.getMap("rooms");
301344
this.membersMap = ydoc.getMap("members");
345+
this.typingYMap = ydoc.getMap("typing");
302346

303347
// React to any Yjs mutation → notify listeners
304348
const onChange = () => this.notify();
305349
this.messagesMap.observe(onChange);
306350
this.roomsMap.observe(onChange);
307351
this.membersMap.observe(onChange);
352+
this.typingYMap.observe(onChange);
308353

309354
if (wsProvider) {
310355
wsProvider.on("status", (e: { status: string }) => {
@@ -336,6 +381,7 @@ export class YjsChatStore implements IChatStore {
336381
this.messagesMap = null;
337382
this.roomsMap = null;
338383
this.membersMap = null;
384+
this.typingYMap = null;
339385
this.listeners.clear();
340386
this.ready = false;
341387
}
@@ -487,6 +533,35 @@ export class YjsChatStore implements IChatStore {
487533
return msgs.slice(-limit);
488534
}
489535

536+
// ── Typing ─────────────────────────────────────────────────────────────
537+
538+
private typingKey(roomId: string, userId: string) { return `${roomId}::${userId}`; }
539+
540+
startTyping(roomId: string, userId: string, userName: string): void {
541+
this.typingYMap?.set(this.typingKey(roomId, userId), { userId, userName, roomId, startedAt: Date.now() } as TypingUser);
542+
}
543+
544+
stopTyping(roomId: string, userId: string): void {
545+
this.typingYMap?.delete(this.typingKey(roomId, userId));
546+
}
547+
548+
getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] {
549+
if (!this.typingYMap) return [];
550+
const now = Date.now();
551+
const result: TypingUser[] = [];
552+
this.typingYMap.forEach((v: any, key: string) => {
553+
const entry = v as TypingUser;
554+
if (entry.roomId !== roomId) return;
555+
if (excludeUserId && entry.userId === excludeUserId) return;
556+
if (now - entry.startedAt > 5000) {
557+
this.typingYMap!.delete(key);
558+
return;
559+
}
560+
result.push(entry);
561+
});
562+
return result;
563+
}
564+
490565
// ── Internal ───────────────────────────────────────────────────────────
491566

492567
private assert(): void {
@@ -600,6 +675,30 @@ export class HybridChatStore implements IChatStore {
600675
}
601676

602677
async getMessages(roomId: string, limit?: number) { return this.reader.getMessages(roomId, limit); }
678+
679+
// ── Typing (prefer Yjs for real-time sync, fallback to local) ─────────
680+
681+
startTyping(roomId: string, userId: string, userName: string): void {
682+
if (this.yjs.isReady()) {
683+
this.yjs.startTyping(roomId, userId, userName);
684+
} else {
685+
this.local.startTyping(roomId, userId, userName);
686+
}
687+
}
688+
689+
stopTyping(roomId: string, userId: string): void {
690+
if (this.yjs.isReady()) {
691+
this.yjs.stopTyping(roomId, userId);
692+
} else {
693+
this.local.stopTyping(roomId, userId);
694+
}
695+
}
696+
697+
getTypingUsers(roomId: string, excludeUserId?: string): TypingUser[] {
698+
return this.yjs.isReady()
699+
? this.yjs.getTypingUsers(roomId, excludeUserId)
700+
: this.local.getTypingUsers(roomId, excludeUserId);
701+
}
603702
}
604703

605704
// ─── Helpers & cache ─────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)