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 code1000("Client disconnect"). - Heartbeat: while open, the client sends
{"type":"ping"}every 30 seconds and expectspong. - 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 path — new WebSocket(url):
- On native (iOS / Android), Tomoda uses React Native's built-in WebSocket implementation.
- On web,
react-native-webre-exports the DOMWebSocket.
There is no platform branch in ChatService for transport.
Where it sits in the stack¶
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.
Cross-links¶
- API Client — REST counterpart and
WS_URLresolution - Backend WebSocket Hub — server-side implementation
- Real-time architecture — system-level real-time design
- Notifications service — inbox model + WS event semantics