Social LiveDocumentationPLATFORM DOCS

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) with ConversationParticipant rows per member.
  • Message with MessageType: TEXT, IMAGE, GIFT, SYSTEM -gift messages let gifting appear inline in DMs.
  • CallSession records with CallKind (audio/video) and CallStatus lifecycle (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.

  1. Sender POSTs the message over REST (durable write, content validated).
  2. ChatGateway fans it out to the conversation's socket room.
  3. Recipients' inbox lists update; unread counts derive from participant read markers.
  4. 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_SPEND ledger 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_EARNING row plus a DiamondSettlement held for the standard settlement window;
  • a PaidMediaUnlock row 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 signaling socket; neither opens its own connection.