Skip to content

Real-time

Tomoda's frontend opens two WebSocket streams, each with a single client living in a service singleton:

Hub Backend endpoint Client owner Scope
Chat session /ws/chats/:id services/chatService.ts (ChatService) One open chat room at a time
Client hub /ws/client contexts/WebSocketContext.tsx (useWsSubscription) One per signed-in user, across every device the user has open

The chat hub is per-room and connects on demand; the client hub is app-level and connects once on cold launch. Server-side this matches the two backend hubs documented in Real-time architecture.

The chat hub client is covered first; see Client hub (/ws/client) below for the app-level stream.

Chat session hub

Real-time chat in Tomoda runs over a per-room WebSocket connection. The client lives entirely in frontend/services/chatService.ts as a singleton ChatService instance.

Connection URL

The base WebSocket URL comes from EXPO_PUBLIC_WS_URL (see API Client):

Environment URL
Production wss://api.tomoda.life/ws
Local (web / iOS sim) ws://127.0.0.1:8080/ws
Local (Android emulator) ws://10.0.2.2:8080/ws

Per-room URL pattern:

${WS_URL}/chats/${chatId}?token=${jwt}

The JWT is passed as a query parameter because the WebSocket constructor (both browser and React Native) does not accept custom headers. The backend authenticates the upgrade request from the query string.

Lifecycle

A single ChatService instance is exported and lives for the app's lifetime. It connects when a chat room is opened and disconnects when the user leaves.

import { chatService } from '@/services/chatService';

useEffect(() => {
  chatService.connect(chatId);
  const off = chatService.onMessage(handleMessage);
  return () => { off(); chatService.disconnect(); };
}, [chatId]);
  • Open: when the user enters a chat room screen (/connect/chat/:id).
  • Close: when the screen unmounts; disconnect() sends close code 1000 ("Client disconnect").
  • Heartbeat: while open, the client sends {"type":"ping"} every 30 seconds and expects pong.
  • Reconnect: on abnormal close, exponential back-off (1s, 2s, 4s, ... capped at 10s) for up to 5 attempts.

Inbound event types

ChatService.handleMessage() dispatches the following server-sent types:

Type Payload Triggers
new_message ChatMessage Render incoming message; append to local cache
message_updated { message_id, chat_id, content, edited_at } Replace in list, show "edited" hint
message_deleted { message_id, chat_id, new_last_message? } Remove message; update room's last-message preview
messages_expired { chat_id, message_ids[] } Remove disappearing messages whose TTL elapsed
reaction_update { message_id, chat_id, reactions: Record<emoji, userId[]> } Re-render reaction bar
mark_read { user_id, chat_id, last_read_at, ... } Update read receipts
user_joined { user_id, user_name, online_count } Presence indicator
user_left { user_id, online_count } Presence indicator
pong Heartbeat ack (no UI effect)
error { message } Surface via errorCallbacks

Outbound commands

The client sends framed JSON messages of the form { "type": "...", "data": {...} }:

Type Sent via Body
send_message sendMessage(content, replyToId?) { chat_id, content, reply_to_id? }
edit_message editMessage(id, content) { message_id, content }
delete_message deleteMessage(id) { message_id }
add_reaction sendReaction(id, emoji) { message_id, emoji }
ping heartbeat

markRead(chatId) is a REST call (POST /chats/:id/read), not a WS message — it's idempotent and survives socket drops.

Close codes

Decoded in WS_CLOSE_CODES for diagnostics:

Code Meaning
1000 Normal close (client disconnect)
1001 Server going away
1006 Connection lost (no close frame; network or crash)
1008 Policy violation (likely bad token)
1011 Server error
4001 Unauthorized
4003 Forbidden
4004 Room not found

The browser onerror callback is intentionally empty — the WebSocket spec hides error details from JS. The close code + reason is the canonical signal.

Platform notes

Both targets use the same code pathnew WebSocket(url):

  • On native (iOS / Android), Tomoda uses React Native's built-in WebSocket implementation.
  • On web, react-native-web re-exports the DOM WebSocket.

There is no platform branch in ChatService for transport.

Where it sits in the stack

connect/chat/[id].tsx
connect(chatId)
chatService singleton WS client
WS upgrade ?token=JWT
Backend WS Hub
while open
inbound: new_message · message_updated · reaction_update · …
outbound: send_message · edit · delete · ping
The screen drives the lifecycle: connect on mount, disconnect on unmount (close code 1000). Between those, chatService fans server events to subscribers and forwards user actions as framed JSON. A 30-second ping/pong keeps the socket warm.

Client hub (/ws/client)

The app-level WebSocket the user holds open for as long as they're signed in. Where the chat hub is per-room and short-lived, the client hub is per-user and persistent: notifications, friend-graph nudges, chat-room previews, and event-detail invalidations all ride it so the surface stays live without polling.

Connection lifecycle

AppWebSocketProvider (mounted in app/_layout.tsx for any authed shell) opens the socket on first render after auth resolves and keeps it warm for the session. URL pattern:

${WS_URL}/client?token=${jwt}

Same JWT-in-query convention as the chat hub. The provider exponential-backs-off on abnormal close (1s, 2s, 4s, ... up to 10s, capped at 5 attempts before going dormant until the next focus).

Subscribing

Components consume the stream via useWsSubscription, a small hook in the same context module. Multiple subscribers can listen to the same event type; the provider dispatches each inbound envelope to every matching subscription synchronously.

useWsSubscription<{ notification: NotificationRow }>('notification:new', (data) => {
  // update local cache with the freshly emitted inbox row
});

Inbound event types

The same set the backend emits on /ws/client. The notifications context is the central consumer for the notification:* events; the others land directly in their owning context.

Event type Consumer What it updates
notification:new NotificationsContext Prepend / merge into the inbox cache; bump unreadCount
notification:unread NotificationsContext Authoritative unread count from server (drives the tab bell badge)
notification:resolved NotificationsContext Morph the row (Accept → Message CTA, Approve → Manage CTA) or remove it (decline / reject); see Backend → Notifications
notification:transient NotificationsContext Render a transient toast / banner for non-inbox kinds (chat)
chat:new_message ChatUnreadContext Update the unread total + chat-list preview; the open chat (if any) still gets the message over the per-room ChatService
chat:mark_read ChatUnreadContext Multi-device read-receipt sync
friend:graph_changed FriendsContext Invalidate the friend cache (re-fetches on next read)
event:updated Event detail screens Patch the in-flight event detail when the host edits allowlisted fields
item.parsed Item board (components/planner/ItemSheet.tsx) Replace a captured item's placeholder with its parsed result once the backend finishes unfurling

Why two streams instead of one

The two hubs share a Redis pub/sub primitive on the backend but are deliberately split by scope on the wire so the client lifecycle stays tractable. The chat session client owns ephemeral, high-frequency, room-scoped traffic; the client hub owns long-lived, low-frequency, user-scoped traffic. Splitting also means the client hub keeps delivering notifications when no chat is open, and the per-room socket can drop / reconnect without affecting the inbox.