Navigation

Calls

Discord-style persistent per-room drop-in audio/video plus standalone share-link calls (like Google Meet). Room members join/leave room calls freely; /calls starts a roomless call joinable by anyone with the link. Media runs through the LiveKit SFU — the server generates access tokens and keeps a participant map for observers; LiveKit handles all WebRTC signaling, track publication, simulcast, and bandwidth estimation.

Sub-pages: call view UI · screenshare · picture-in-picture · per-user volume · virtual backgrounds · blocked call audio. Voice preferences applied to calls: voice & video settings.

The session model

A call is anchored to a callSessionsInMessage row, not a route:

  • id: text PK — a 12-char alphanumeric token (createId(CALL_ID_LENGTH), crypto-secure); the id is the shareable link token.
  • roomId — set for room calls (unique FK → rooms, cascade); null-scoped standalone calls persist ownership on userId (creator).
  • The session row is the persistent 1:1 anchor per room and survives restarts; participants do not (in-memory callSessionParticipantMap).

CallParticipant.id is the auth session.id, not user.id — the LiveKit AccessToken uses identity: session.id, so each device is its own participant (separate tracks, separate mute state). Using user.id would make multi-device join last-write-wins and let device A's leave evict device B.

The call lifetime boundary

Call membership is anchored to activeCallSessionId, never the viewed route. activeCallSessionId answers "what call am I in?"; currentRoomCallSessionId answers "what call belongs to the room I'm looking at?" — they can differ, and that is valid. Navigating rooms swaps the observer only.

Leave happens only on: explicit Leave Call, moderation (KickFromCall/KickFromRoom/TimeoutUser/CreateBan on the call's room), real session loss (logout, tab close, LiveKit participant_left webhook), or /calls/[id] unmount (that page is the call context — except when popped out to picture-in-picture).

OwnerResponsibilityMust not do
callStore.leaveCall()the only normal path that leaves, disconnects LiveKit, and resets active staterun implicitly during room navigation
useCallSubscribables()observes the viewed room's session; updates currentRoomCallSessionId / participantsremove the active participant or disconnect LiveKit
useCallIdSubscribables()owns /calls/[id] membership; validates session, subscribes, leaves on unmount (unless popped out)reuse room-navigation cleanup semantics
LeftSideBar/StatusBar.vuesurfaces any active call (room or standalone) and links back to itdecide membership from currentRoomCallSessionId

Join flow

sequenceDiagram
    participant A as Client (joining)
    participant T as tRPC callSession router
    participant LK as LiveKit SFU
    participant B as Other clients

    A->>T: joinCallByRoomId({ roomId }) / joinCall({ id })
    T->>T: create/reuse session · update callSessionParticipantMap
    T-->>A: { liveKitUrl, liveKitToken }
    A->>LK: room.connect(url, token)
    LK-->>T: webhook participant_joined (backup path)
    T->>B: callEventEmitter → onJoinCall subscription
    Note over A,B: audio/video/screenshare tracks flow through the SFU

The LiveKit webhook (server/api/webhooks/livekit.post.ts, validated with WebhookReceiver) is the backup for clients that disconnect without calling leaveCall (tab crash, network drop) — never the primary path for in-app route changes. It is also the door a token cannot get around: a room call's participant_joined is checked against the room's membership and any timeout (checkIsCallConnectionAdmitted), and a connection the room no longer admits is disconnected rather than added, since LiveKit accepts a removed member's old token until it expires.

Standalone knock lobby

Every /calls/[id] visitor sees prejoin (verify mic/camera) first. The creator (persisted callSessionsInMessage.userId) gets Join now; non-creators get Request to join → knockCall puts them in callKnockerMap, the creator admits (admitKnocker → one-time entry in callAdmittedParticipantMap) or dismisses, and only then does joinCall({ id }) succeed. Admitting, dismissing and the onKnockCall feed all pass one guard, requireCallDoorkeeper: the caller must be the creator, and must be in the call themselves — a creator who has left admits nobody.

flowchart TD
  VISIT["visitor opens the call link"] --> ISCREATOR{"session creator?"}
  ISCREATOR -- yes --> JOIN["joinCall — LiveKit connection data"]
  ISCREATOR -- no --> KNOCK["knockCall — added to callKnockerMap"]
  KNOCK --> NOTIFY["onKnockCall — the creator's lobby list"]
  NOTIFY --> DECIDE{"creator decides"}
  DECIDE -- admit --> ADMITTED["admitKnocker — one-time callAdmittedParticipantMap entry"]
  DECIDE -- dismiss --> DISMISSED["dismissKnocker — dropped from callKnockerMap"]
  ADMITTED --> JOIN
  DISMISSED --> WAIT["stays on prejoin"]

The creator's lobby list is optimistic: admitting or dismissing removes that knocker at once, and each knocker is its own write target so a host can work down the queue without the writes queueing. A rejected one therefore puts back only its own knocker — reinstating the list as it stood would resurrect one already admitted beside it and drop whoever onKnockCall delivered meanwhile.

Ephemeral server state

Everything about a live call except its anchor row lives in module-level maps under server/services/message/call/, each keyed by callSessionId and each lost on restart. There are four of them rather than one record per session, and the split is load-bearing rather than incidental:

  • callSessionParticipantMap — the participants, keyed by auth session id. Created by the first join and deleted when the last participant leaves, so its presence is what "this call is live" means, and requireJoinedCallSession reads nothing else.
  • callKnockerMap and callAdmittedParticipantMap — the standalone waiting room. They are torn down with the participant map, because a call nobody is in has nobody left to admit anyone.
  • callStartTimeMap — set by the first joiner and read by the last leaver, after the other three are already gone, to word the call-duration system message. It outlives the teardown on purpose, which is exactly why it cannot be a field of a record deleted as a unit.
flowchart TD
  JOIN["first join — joinCallAsParticipant"] --> CREATE["callSessionParticipantMap entry plus callStartTimeMap entry"]
  CREATE --> KNOCK["knockCall and admitKnocker fill callKnockerMap and callAdmittedParticipantMap"]
  CREATE --> LEAVE["leaveCallAsParticipant — deleteCallParticipant"]
  KNOCK --> LEAVE
  LEAVE --> LAST{"was that the last participant?"}
  LAST -->|"no"| KEEP["every map stays — the call is still live"]
  LAST -->|"yes"| TEARDOWN["participants, knockers and admitted dropped together — knockerDismissed emitted to each"]
  TEARDOWN --> DURATION["callStartTimeMap read for the duration, then dropped"]
  DURATION --> SUMMARY["MessageType.Call system message carrying the duration"]

Every teardown path funnels through leaveCallAsParticipant — the explicit leave, the LiveKit participant_left webhook and the moderation actions alike — so the start time is never orphaned by a call that ended some other way.

A restart does not end the calls

Losing these maps is survivable, not correct. LiveKit is an external SFU reached over LIVEKIT_URL; this process mints tokens and receives webhooks but holds no media connection, so a call in progress is entirely unaffected by the process going away and its participants stay connected to the SFU throughout. What the restart destroys is only this side's picture of them, and three things follow from that:

  • requireJoinedCallSession reads the participant map and nothing else, so every pre-restart participant is locked out of the call procedures until they rejoin, while still being in the call.
  • Their eventual participant_left webhook finds no map entry, deleteCallParticipant returns false, and leaveCallAsParticipant returns early — no leaveCall event, no duration message.
  • The next join reads isFirstJoiner from that same empty map and re-stamps callStartTimeMap, so the duration the call finally reports is measured from the restart rather than from the call.

Rebuilding the maps from LiveKit's own view of each room is the fix, and it waits on a trigger worth having — call state reconciliation.

Procedures

All in server/trpc/routers/call/index.ts, registered as callSession; the waiting-room procedures live in server/trpc/routers/call/knocker.ts and merge in under a knocker key:

ProcedureAuthPurpose
createCallauthedcreate a standalone roomless session (/calls)
readCallSessionId({ roomId })memberroom observer entry; returns "" if none (never null)
readCallSession({ id })authedstandalone validation for /calls/[id]
joinCallByRoomId({ roomId })membercreate/reuse room session; returns LiveKit connection data
joinCall({ id })authedstandalone join — creator or admitted knocker only
leaveCall({ callSessionId })authedremove from participant map; last leaver posts the MessageType.Call system message
readCallParticipantMap({ callSessionId })authedinitial participant map for observers
setMuted / setCameraEnabledauthedsync state to the server map; broadcast
setHandRaisedauthed / moderatorraise own hand; lowering another's needs MuteMembers on the call's room
knocker.knockCall / knocker.admitKnocker / knocker.dismissKnockerauthed / creatorstandalone waiting room — admitting and dismissing need the creator, in the call
onJoinCall / onLeaveCall / onSetMuted / onSetCameraEnabled / onSetHandRaisedauthedsubscriptions keyed by callSessionId
knocker.onKnockCall / knocker.onKnockerAdmitted / knocker.onKnockerDismissedcreator / authedwaiting-room subscriptions keyed by callSessionId — the knock feed is the creator's

Tokens grant canPublishSources: [Microphone, Camera, ScreenShare, ScreenShareAudio] with room: callSessionId and metadata: { userId }.

Client stores

store/message/room/call/index.ts is the orchestration root (session boundaries, tRPC + SDK coordination); focused state lives in smaller stores so the LiveKit bridge never imports the root:

StoreOwns
call/index.tsactiveCallSessionId, currentRoomCallSessionId, callRoomId (admin-action room checks)
call/participant.tscallSessionParticipantsMap (keyed by session id), speakingIds, join notice
call/media.tsdeafen, force-mute, camera, screenshare + pin state, virtual background, isPoppedOut, streams, per-user volume
call/knocker.tsknockingCallSessionId, pre-join options, knocker queue
liveKit.tsthe LiveKit Room media bridge: connect, track events, device switching, mic processor, blocked audio

DM calls work identically — call procedures accept RoomType.DirectMessage; membership via usersToRooms gates access. The first joiner posts the MessageType.Call "started a call" system message, and call end writes the call-duration variant.

Key files

FileRole
packages/db-schema/src/schema/message/callSessionsInMessage.tssession anchor table
apps/web/server/trpc/routers/call/index.tsall call procedures (registered as callSession)
apps/web/server/trpc/routers/call/knocker.tswaiting-room procedures (merged under knocker)
apps/web/server/services/message/call/participant/knocker/admitted maps + CRUD
apps/web/server/api/webhooks/livekit.post.tswebhook backup for join/leave
apps/web/app/store/message/room/call/root + participant + media + knocker stores
apps/web/app/store/message/room/liveKit.tsLiveKit Room bridge
apps/web/app/pages/calls/standalone lobby (index.vue) + call route ([id].vue)

Notes

  • An SFU rather than a mesh. Mesh WebRTC costs each participant an upload per peer, which video and screenshare make unsustainable past a handful of people; routing through LiveKit also means the app owns no signaling procedures of its own.
  • Hosting: LiveKit Cloud free tier (5,000 participant-minutes/month) now; self-hosted LiveKit on Azure Container Apps (~$5–15/month, scales to zero) when usage exceeds ~10,000 participant-minutes/month.
  • Virtual backgrounds: presets plus uploaded per-user slots via @livekit/track-processors; selecting one turns the camera on, and camera-off resets the processor. See virtual backgrounds.

Details

Command palette

Keyboard shortcuts