The Chat began with a useful constraint: preserve what people could do, but do not preserve the architecture that made the existing product difficult to change.
The previous application combined a Rails backend, a hybrid iOS shell, and a separate native calling layer. It had accumulated years of behavior worth keeping, but its boundaries made reliability work harder than it needed to be. Rebuilding it meant treating the old product as behavioral evidence—not as code to translate.
One product, two real clients
The replacement is a Phoenix application with PostgreSQL, Oban, Channels, Presence, and LiveView on the server and web. The iOS application is native SwiftUI with a local SQLite database through GRDB. Both clients use the same versioned HTTP and realtime contracts.
That shared contract matters more than visual parity. A message, reaction, call, attachment, unread count, or workspace change needs one canonical meaning even when the web client is mounted, the phone is offline, a push is delayed, or a realtime event is replayed.
The server owns identity, authorization, durable state, and event ordering. Each client owns a projection of that state and the work required to repair it.
Messaging that can recover
Realtime interfaces often work beautifully on a developer's desk and become ambiguous on a real phone. Radios change. Apps suspend. Websocket events arrive late or not at all. A foreground push can beat the message it describes.
The Chat treats those conditions as expected operating states. The native client writes canonical projections to SQLite, sends optimistic messages through a durable outbox, and reconciles acknowledgements using stable client identifiers. Replayed events are safe. Overlapping repair requests are coalesced. Background and foreground notifications can prompt the same idempotent synchronization path.
The message timeline also keeps three different concerns separate:
- how much history SQLite retains;
- where the server's older-page boundary sits;
- how many rich SwiftUI rows should be rendered at once.
That distinction sounds small. It prevents a short network response from being mistaken for the complete history of a room, and it lets locally cached messages appear immediately without forcing the interface to render thousands of views.
Voice without a managed media platform
Voice and video use direct WebRTC with Phoenix signaling and self-hosted coturn. Phoenix authorizes calls and relays versioned offer, answer, and ICE messages; it never treats a client's claim about media state as authorization evidence.
On iOS, that system has to agree with CallKit, PushKit, the audio session, application lifecycle, and the peer connection. Call waiting made the boundary especially clear. Holding one call, accepting another, then returning to the first is not a screen transition. It is a distributed state change involving two devices, two system call records, server call state, signaling membership, audio ownership, and a fresh media negotiation.
The recovery path now uses explicit resume negotiation and ICE restart instead of hoping an old peer connection will become valid again.
Transcription with a privacy boundary
Call and voice-memo transcription run through native speech recognition on the device. Raw microphone audio is ephemeral: it is not uploaded and does not touch disk for transcription. The server receives only finalized, time-indexed text after consent and capability have been established.
This was not added as a privacy label after implementation. It shaped the architecture. The browser is deliberately unable to create transcripts, and future desktop support requires a native client with a verifiably local recognizer.
The result
The Chat is a working product, but the more important result is a system whose failures can be named. The code distinguishes committed state from client projection, notification delivery from message delivery, an answered call from audible media, and cached history from the rendered window.
Those distinctions make the software easier to debug, test, and extend. They also make the interface calmer: recovery happens automatically when it can, and the user is asked to intervene only when the system has actually run out of options.