diff --git a/app/realtime/router.py b/app/realtime/router.py index 34e0533..1a07323 100644 --- a/app/realtime/router.py +++ b/app/realtime/router.py @@ -75,7 +75,14 @@ async def _dispatch_event(db, user_id: int, event: IncomingRealtimeEvent) -> Non payload = SendMessagePayload.model_validate(event.payload) await realtime_gateway.handle_send_message(db, user_id, payload) return - if event.event in {"typing_start", "typing_stop"}: + if event.event in { + "typing_start", + "typing_stop", + "recording_voice_start", + "recording_voice_stop", + "recording_video_start", + "recording_video_stop", + }: payload = ChatEventPayload.model_validate(event.payload) await realtime_gateway.handle_typing_event(db, user_id, payload, event.event) return diff --git a/app/realtime/schemas.py b/app/realtime/schemas.py index 10fcfca..dd4b877 100644 --- a/app/realtime/schemas.py +++ b/app/realtime/schemas.py @@ -15,6 +15,10 @@ RealtimeEventName = Literal[ "message_deleted", "typing_start", "typing_stop", + "recording_voice_start", + "recording_voice_stop", + "recording_video_start", + "recording_video_stop", "message_read", "message_delivered", "user_online", @@ -45,7 +49,18 @@ class MessageStatusPayload(BaseModel): class IncomingRealtimeEvent(BaseModel): - event: Literal["send_message", "typing_start", "typing_stop", "message_read", "message_delivered", "ping"] + event: Literal[ + "send_message", + "typing_start", + "typing_stop", + "recording_voice_start", + "recording_voice_stop", + "recording_video_start", + "recording_video_stop", + "message_read", + "message_delivered", + "ping", + ] payload: dict[str, Any] diff --git a/docs/api-reference.md b/docs/api-reference.md index d8431be..1d50776 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -72,6 +72,15 @@ For `/health/ready` failure: - `message_delivered` - `message_read` +### Realtime chat activity events + +- `typing_start` +- `typing_stop` +- `recording_voice_start` +- `recording_voice_stop` +- `recording_video_start` +- `recording_video_stop` + ## 3. Models (request/response) ## 3.1 Auth diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index dbf8a49..b8f2754 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -21,7 +21,7 @@ Legend: 12. Pinning - `DONE` (message/chat pin-unpin) 13. Reactions - `DONE` 14. Delivery Status - `DONE` (sent/delivered/read + reconnect reconciliation after backend restarts) -15. Typing Realtime - `PARTIAL` (typing start/stop done; voice/video typing signals limited) +15. Typing Realtime - `DONE` (typing start/stop + recording voice/video start/stop via realtime events) 16. Media & Attachments - `DONE` (upload/preview/download/gallery) 17. Voice Messages - `PARTIAL` (record/send/play/seek + global speed 1x/1.5x/2x; recorder now uses mime fallback + chunked capture; UX still being polished) 18. Circle Video Messages - `PARTIAL` (send/play present, recording UX basic) diff --git a/web/src/components/MessageComposer.tsx b/web/src/components/MessageComposer.tsx index c7baa0e..347f7b9 100644 --- a/web/src/components/MessageComposer.tsx +++ b/web/src/components/MessageComposer.tsx @@ -307,6 +307,22 @@ export function MessageComposer() { return wsRef.current; } + function sendRealtimeChatEvent( + eventName: + | "typing_start" + | "typing_stop" + | "recording_voice_start" + | "recording_voice_stop" + | "recording_video_start" + | "recording_video_stop" + ) { + if (!activeChatId) { + return; + } + const ws = getWs(); + ws?.send(JSON.stringify({ event: eventName, payload: { chat_id: activeChatId } })); + } + async function handleSend() { if (!activeChatId || !text.trim() || !me || !canSendInActiveChat) { return; @@ -347,8 +363,7 @@ export function MessageComposer() { setText(""); clearDraft(activeChatId); setReplyToMessage(activeChatId, null); - const ws = getWs(); - ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: activeChatId } })); + sendRealtimeChatEvent("typing_stop"); } catch { removeOptimisticMessage(activeChatId, clientMessageId); setUploadError("Message send failed. Please try again."); @@ -619,6 +634,8 @@ export function MessageComposer() { recordingStartedAtRef.current = Date.now(); setRecordSeconds(0); setRecordingState("recording"); + sendRealtimeChatEvent("typing_stop"); + sendRealtimeChatEvent("recording_voice_start"); return true; } catch { setUploadError("Microphone access denied. Please allow microphone and retry."); @@ -627,6 +644,9 @@ export function MessageComposer() { } function stopRecord(send: boolean) { + if (recordingStateRef.current !== "idle") { + sendRealtimeChatEvent("recording_voice_stop"); + } sendVoiceOnStopRef.current = send; pointerCancelArmedRef.current = false; setDragHint("idle"); @@ -1274,8 +1294,7 @@ export function MessageComposer() { setText(next); if (activeChatId) { setDraft(activeChatId, next); - const ws = getWs(); - ws?.send(JSON.stringify({ event: "typing_start", payload: { chat_id: activeChatId } })); + sendRealtimeChatEvent(next.trim().length > 0 ? "typing_start" : "typing_stop"); } }} /> diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index e3a94e1..2b4b1e7 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -46,6 +46,8 @@ export function MessageList() { const activeChatId = useChatStore((s) => s.activeChatId); const messagesByChat = useChatStore((s) => s.messagesByChat); const typingByChat = useChatStore((s) => s.typingByChat); + const recordingVoiceByChat = useChatStore((s) => s.recordingVoiceByChat); + const recordingVideoByChat = useChatStore((s) => s.recordingVideoByChat); const hasMoreByChat = useChatStore((s) => s.hasMoreByChat); const loadingMoreByChat = useChatStore((s) => s.loadingMoreByChat); const loadMoreMessages = useChatStore((s) => s.loadMoreMessages); @@ -130,6 +132,17 @@ export function MessageList() { const focusedMessageId = activeChatId ? (focusedMessageIdByChat[activeChatId] ?? null) : null; const hasMore = Boolean(activeChatId && hasMoreByChat[activeChatId]); const isLoadingMore = Boolean(activeChatId && loadingMoreByChat[activeChatId]); + const typingCount = activeChatId ? (typingByChat[activeChatId] ?? []).length : 0; + const recordingVoiceCount = activeChatId ? (recordingVoiceByChat[activeChatId] ?? []).length : 0; + const recordingVideoCount = activeChatId ? (recordingVideoByChat[activeChatId] ?? []).length : 0; + const activityLabel = + recordingVideoCount > 0 + ? "recording video..." + : recordingVoiceCount > 0 + ? "recording voice..." + : typingCount > 0 + ? "typing..." + : ""; const selectedMessages = useMemo(() => messages.filter((m) => selectedIds.has(m.id)), [messages, selectedIds]); const channelOnlyDeleteForAll = activeChat?.type === "channel" && !activeChat?.is_saved; const canDeleteAllForSelection = useMemo( @@ -683,7 +696,7 @@ export function MessageList() { })} -