Chat & Calls
Conversations, direct messages, typing, receipts, and 1:1 calls.
Last updated Thu Jul 16 2026 00:00:00 GMT+0000 (Coordinated Universal Time)
TL;DR -Beyond live rooms, people direct-message and call each other. Messages are saved reliably first and streamed live second; non-friends pay coins to open a chat; and photos or videos can be locked behind a coin price the sender earns from.
Who this is for -Product & Business: the plain framing, the paywall, and paid media. Design: message actions and media viewers. Backend & Mobile: the data model, endpoints, and the atomic money transactions.
Direct messages and calls are the private, one-to-one side of Social Live. If live rooms are the public square, this is where two people talk after they've met. You can text, send photos and videos, and jump on a 1:1 audio or video call.
Two ideas shape how messaging works. First, money is woven in: messaging a stranger costs coins (so people can't spam), and you can put a price on a photo so viewers pay to unlock it -with the money flowing to you as earnings. Second, reliability comes first: every message is written to the database before it's streamed, so a dropped connection can delay when you see a message but never lose it.
In plain terms
- Friends message for free. Friends means you follow each other.
- Messaging a stranger costs coins. Pay once and it opens the chat both ways.
- You can lock a photo or video behind a price. The recipient pays coins to reveal it, and you earn from that.
- Messages can be replied-to, edited, or recalled (recall wipes it for both people, WhatsApp-style).
- Calls are 1:1 audio or video, built on the same tech as live rooms.
- Blocking wins over everything -a blocked pair can't message or call, no matter what.
Everything below is the technical detail beneath these behaviors.
Data model
Three record types hold the whole feature: the conversation, the individual messages, and any calls placed within it.
Conversation(+ConversationKind: direct, and room for growth) withConversationParticipantrows per member.MessagewithMessageType:TEXT,IMAGE,GIFT,SYSTEM-gift messages let gifting appear inline in DMs.CallSessionrecords withCallKind(audio/video) andCallStatuslifecycle (ringing → active → ended/missed/declined).
Message flow
A message is saved before it's broadcast. That "save first" order is the whole reliability story: the live socket makes delivery fast, but the database makes it certain.
- Sender POSTs the message over REST (durable write, content validated).
- ChatGateway fans it out to the conversation's socket room.
- Recipients' inbox lists update; unread counts derive from participant read markers.
- Typing indicators (
chat:typing_started/stopped) and read receipts are socket-only -ephemeral, never persisted.
The REST-first ordering means a dropped socket can delay display, never existence: on reconnect the client refetches the thread and is complete.
Text encoding
Text is handled as UTF-8 all the way through, so emoji and smart quotes never get mangled.
Message text is UTF-8 end to end. The client's ApiClient sends explicit
UTF-8 bytes (see Networking for the Latin-1
incident this prevents); PostgreSQL stores UTF-8 natively; emoji and smart
punctuation round-trip correctly.
Media messages
Tapping an image, GIF, or video opens it fullscreen. The viewers pass the right credentials for our own media and none for third-party media.
Tapping an image or GIF bubble opens it fullscreen in a pinch-zoom photo
viewer (drag down to dismiss); tapping a video bubble plays it fullscreen
with play/pause and a scrubber (video_player plugin). Both viewers pass
the bearer token for our access-controlled /chat/media/... URLs and no
headers for third-party media (e.g. Giphy) -same rule as the inline
thumbnails. Local not-yet-uploaded files open from disk.
Edit, recall & reply
Long-pressing a message gives you the usual actions -reply with a quote, copy, edit your own text, or recall a message to remove it for both people.
Long-pressing a message opens an action sheet: Reply, Copy (any text), Edit (own text messages) and Recall (own messages, removes the content for both sides -WhatsApp-style).
Reply attaches a quote to the next message. The send carries replyToId
(POST .../messages); the server resolves the target row (same conversation)
and denormalizes a snapshot -replyToId / replyToSenderId /
replyToPreview (media → label, text truncated to 120 chars) -so the quote
renders without a join. The serializer emits replyTo { id, senderId, isMine, preview }; the composer shows a cancelable quote bar and the reply bubble
renders the quote above its own text. An unknown or foreign replyToId is
silently dropped.
Endpoints:
PATCH /chat/conversations/:id/messages/:messageId (edit, keyword-filter
screened like sends, stamps editedAt, peers see an "edited" tag) and
POST .../messages/:messageId/recall (stamps recalledAt). Both fan out
over chat:message_updated. Recalled rows keep their stored content in
the database so moderation reports can still snapshot it -the API
serializer strips body/media for both participants.
Who can message whom
Friends chat for free; a stranger is behind a coin paywall. Paying to unlock opens the conversation in both directions.
Friends (mutual followers) always message for free. A non-friend is behind a
coin paywall: opening the thread (POST /chat/conversations) returns a
structured 403 { code: 'dm_locked', priceCoins }, which the client turns
into an "Unlock · N coins" prompt. Paying calls POST /chat/unlock/:userId,
which -in a single Prisma $transaction, keyed by
dm_unlock:<payer>:<peer> -debits coins (bonus coins first, then paid),
writes a DM_UNLOCK_SPEND LedgerTransaction, and records a
DirectMessageUnlock row. The unlock is stored payer→peer but opens
messaging both ways (the peer replies for free). The price is
admin-configurable (Settings → Messaging → DM unlock price,
dm_unlock_price_coins, default 100); 0 disables the paywall. Blocks
still take precedence -a blocked pair can never message or unlock.
Locked paid media
A sender can put a coin price on a photo or video. The recipient sees a lock card and pays to reveal it; the sender earns diamonds from that payment, just like a gift.
A sender can put a coin price on a photo/video DM: the send carries
unlockPriceCoins (POST .../messages, ignored on text). The serializer
withholds the media from the recipient -locked: true, mediaUrl: null,
unlockPriceCoins exposed -while the sender always sees their own. The
recipient's bubble renders a lock card with an "Unlock for N coins" button.
Unlocking (POST .../messages/:messageId/unlock) runs one atomic Prisma
$transaction, keyed by paid_media_unlock:<viewer>:<message>:
- the viewer's coins are debited (bonus coins first, then paid) —
PAID_MEDIA_UNLOCK_SPENDledger row; - the sender earns diamonds on the paid-coin portion (full creator share,
like a host-targeted gift -bonus-coin spends earn nothing) -a
DIAMOND_EARNINGrow plus aDiamondSettlementheld for the standard settlement window; - a
PaidMediaUnlockrow records the reveal so it stays unlocked on reload.
Replaying an already-unlocked message is a no-op that still returns the
media. Senders can't unlock their own media; recalled or free messages
can't be unlocked. listMessages resolves the viewer's unlocks in one
lookup per page.
Blocking & safety interplay
Blocking is enforced deep in the service layer, so it holds everywhere -messaging, calling, and inbox visibility.
UserBlock edges are enforced at the service layer: blocked users cannot
message or call each other, and blocked content drops out of inbox lists.
Reports on messages flow into the moderation queue
(see Moderation).
Calls
Calls are 1:1 audio or video. They use the same media stack as live rooms but as a direct peer-to-peer session, with a CallSession record as the source of truth.
1:1 calls use the same WebRTC stack as live rooms but peer-to-peer style
sessions negotiated over the signaling socket, with CallSession as the
authoritative record (who called whom, when, outcome). Missed/declined
calls generate notifications.
Client modules
On the app side, chat and calls are two modules that share one socket connection rather than each opening their own.
chat/-inbox and thread screens, message composer, GIF picker (gif_repository).calls/-call screens and session handling.- Both consume the shared
signalingsocket; neither opens its own connection.
Related pages
- Live rooms -the public side, with in-room DMs.
- Safety & moderation -reporting, blocking, and keyword filtering.
- Business: Gifting -the diamond earnings model paid media reuses.
- Development guide: Networking -the UTF-8 client details.