| .. | .. |
|---|
| 8 | 8 | } from "react"; |
|---|
| 9 | 9 | import { Message, WsIncoming, WsSession } from "../types"; |
|---|
| 10 | 10 | import { useConnection } from "./ConnectionContext"; |
|---|
| 11 | | -import { playAudio, encodeAudioToBase64, saveBase64Audio } from "../services/audio"; |
|---|
| 11 | +import { playAudio, encodeAudioToBase64, saveBase64Audio, canAutoplay } from "../services/audio"; |
|---|
| 12 | 12 | import { requestNotificationPermissions, notifyIncomingMessage } from "../services/notifications"; |
|---|
| 13 | 13 | |
|---|
| 14 | 14 | function generateId(): string { |
|---|
| .. | .. |
|---|
| 119 | 119 | sendTextMessage: (text: string) => void; |
|---|
| 120 | 120 | sendVoiceMessage: (audioUri: string, durationMs?: number) => void; |
|---|
| 121 | 121 | sendImageMessage: (imageBase64: string, caption: string, mimeType: string) => void; |
|---|
| 122 | + deleteMessage: (id: string) => void; |
|---|
| 122 | 123 | clearMessages: () => void; |
|---|
| 124 | + isTyping: boolean; |
|---|
| 123 | 125 | sessions: WsSession[]; |
|---|
| 124 | 126 | activeSessionId: string | null; |
|---|
| 125 | 127 | requestSessions: () => void; |
|---|
| .. | .. |
|---|
| 147 | 149 | const [messages, setMessages] = useState<Message[]>([]); |
|---|
| 148 | 150 | // Unread counts for non-active sessions |
|---|
| 149 | 151 | const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({}); |
|---|
| 152 | + // Typing indicator from server |
|---|
| 153 | + const [isTyping, setIsTyping] = useState(false); |
|---|
| 150 | 154 | |
|---|
| 151 | 155 | const { |
|---|
| 152 | 156 | status, |
|---|
| .. | .. |
|---|
| 197 | 201 | if (status === "connected") { |
|---|
| 198 | 202 | needsSync.current = true; |
|---|
| 199 | 203 | sendCommand("sync", activeSessionId ? { activeSessionId } : undefined); |
|---|
| 204 | + } else if (status === "disconnected") { |
|---|
| 205 | + setIsTyping(false); |
|---|
| 200 | 206 | } |
|---|
| 201 | 207 | // eslint-disable-next-line react-hooks/exhaustive-deps — only fire on status change |
|---|
| 202 | 208 | }, [status, sendCommand]); |
|---|
| .. | .. |
|---|
| 270 | 276 | onMessageReceived.current = async (data: WsIncoming) => { |
|---|
| 271 | 277 | switch (data.type) { |
|---|
| 272 | 278 | case "text": { |
|---|
| 279 | + setIsTyping(false); |
|---|
| 273 | 280 | const msg: Message = { |
|---|
| 274 | 281 | id: generateId(), |
|---|
| 275 | 282 | role: "assistant", |
|---|
| .. | .. |
|---|
| 283 | 290 | break; |
|---|
| 284 | 291 | } |
|---|
| 285 | 292 | case "voice": { |
|---|
| 293 | + setIsTyping(false); |
|---|
| 286 | 294 | let audioUri: string | undefined; |
|---|
| 287 | 295 | if (data.audioBase64) { |
|---|
| 288 | 296 | try { |
|---|
| .. | .. |
|---|
| 302 | 310 | }; |
|---|
| 303 | 311 | addMessageToActive(msg); |
|---|
| 304 | 312 | notifyIncomingMessage("PAILot", data.content ?? "Voice message"); |
|---|
| 305 | | - if (msg.audioUri) { |
|---|
| 313 | + if (msg.audioUri && canAutoplay()) { |
|---|
| 306 | 314 | playAudio(msg.audioUri).catch(() => {}); |
|---|
| 307 | 315 | } |
|---|
| 308 | 316 | break; |
|---|
| .. | .. |
|---|
| 356 | 364 | case "transcript": { |
|---|
| 357 | 365 | // Voice → text reflection: replace voice bubble with transcribed text |
|---|
| 358 | 366 | updateMessageContent(data.messageId, data.content); |
|---|
| 367 | + break; |
|---|
| 368 | + } |
|---|
| 369 | + case "typing": { |
|---|
| 370 | + setIsTyping(data.typing); |
|---|
| 371 | + break; |
|---|
| 372 | + } |
|---|
| 373 | + case "status": { |
|---|
| 374 | + // Connection status update — ignore for now |
|---|
| 359 | 375 | break; |
|---|
| 360 | 376 | } |
|---|
| 361 | 377 | case "error": { |
|---|
| .. | .. |
|---|
| 440 | 456 | [wsImageSend, addMessageToActive, updateMessageStatus] |
|---|
| 441 | 457 | ); |
|---|
| 442 | 458 | |
|---|
| 459 | + const deleteMessage = useCallback((id: string) => { |
|---|
| 460 | + setMessages((prev) => { |
|---|
| 461 | + const next = prev.filter((m) => m.id !== id); |
|---|
| 462 | + setActiveSessionId((sessId) => { |
|---|
| 463 | + if (sessId) { |
|---|
| 464 | + messagesMapRef.current[sessId] = next; |
|---|
| 465 | + debouncedSave(messagesMapRef.current); |
|---|
| 466 | + } |
|---|
| 467 | + return sessId; |
|---|
| 468 | + }); |
|---|
| 469 | + return next; |
|---|
| 470 | + }); |
|---|
| 471 | + }, []); |
|---|
| 472 | + |
|---|
| 443 | 473 | const clearMessages = useCallback(() => { |
|---|
| 444 | 474 | setMessages([]); |
|---|
| 445 | 475 | setActiveSessionId((id) => { |
|---|
| .. | .. |
|---|
| 515 | 545 | sendTextMessage, |
|---|
| 516 | 546 | sendVoiceMessage, |
|---|
| 517 | 547 | sendImageMessage, |
|---|
| 548 | + deleteMessage, |
|---|
| 518 | 549 | clearMessages, |
|---|
| 550 | + isTyping, |
|---|
| 519 | 551 | sessions, |
|---|
| 520 | 552 | activeSessionId, |
|---|
| 521 | 553 | requestSessions, |
|---|