A lightweight desktop app for one friend group. Discord, minus everything you don't use.
Discord's core loop for a small group is really just three things: talk to one friend, talk to everyone, and see who's around. Everything else — servers, roles, channels, bots, nitro, threads — is overhead a five-to-fifteen-person group doesn't need and pays for in RAM and CPU while gaming. BaatCheet is that stripped loop, native-fast, with your existing Discord identity carried over.
- "Continue with Discord" — standard OAuth2 authorization code flow (+ PKCE, since this is a public desktop client with no safe place to store a client secret).
- On first login: pull username, global display name, and avatar from Discord's
/users/@meendpoint so migration feels instant — no separate signup, no new avatar to upload. - Store a long-lived session (refresh token) so friends aren't re-authenticating constantly; Discord access tokens expire in ~1 week and refresh silently in the background.
- No password system to build or secure — Discord is the sole identity provider.
- 1:1 text chat, persisted so history is there next time you open the app.
- 1:1 voice call, peer-to-peer (no server relay needed for 2 people — cheaper and lower latency).
- Simple call UI: incoming call toast/notification, accept/decline, mute, deafen, leave.
- One single shared space for the whole group — no channel switching, no server list.
- Side-by-side layout: group voice call on one side (anyone can join/leave freely, like a Discord voice channel that's always "open"), group text chat on the other (always-on, persists history).
- Joining voice is a single click; you're dropped straight into the shared call already in progress.
- Collapsible sidebar listing every friend: online/offline (and idle, if you want a third state later).
- "Now playing" / custom status line under each name, matching what Discord already shows for a familiar feel.
- Real-time updates — no refresh, no polling delay when a friend joins or logs off.
- Discord-derived visual language: dark base theme, the same rough type scale and spacing rhythm, familiar iconography for mute/deafen/call — but pared down. Fewer nested panels than Discord, since there's no server/channel hierarchy to render.
This is the feature that shapes every other decision below: the app must sit idle at low CPU/RAM while a game is running, and voice must not introduce audio stutter or spike CPU. This single requirement is why the shell choice (§3.1) matters more than almost anything else in this plan.
| Layer | Choice | Why |AA
|---|---|---|
| Desktop shell | Tauri (Rust core + OS-native webview) | Ships without a bundled Chromium — apps are typically single-digit-to-low-tens of MB and idle far lighter on RAM/CPU than an Electron equivalent. This is the single biggest lever for your "don't touch my FPS" requirement. |
| Frontend framework | React + TypeScript + Vite (or Next.js with output: 'export' for static output) | You already know this stack. Tauri just wraps whatever static frontend you point it at — no server-side rendering is needed or useful for a desktop app, so a plain Vite SPA is actually a slightly better fit than full Next.js here. |
| Styling | Tailwind CSS | Matches what you already use on RoutReach; fast to hand-tune a Discord-like dark theme. |
| Auth | Discord OAuth2 (Authorization Code + PKCE), custom-implemented | For a closed friend group, a full auth vendor (Clerk etc.) is unnecessary weight — you only need one provider and no user management UI. Tauri can register a custom URI scheme (baatcheet://callback) so the OAuth redirect comes straight back into the app after the system browser step. |
| Realtime data (chat, presence, session) | Convex (self-hosted) | You're already running this on your Oracle VM behind Coolify. Its reactive subscriptions give you live presence and chat updates with no manual websocket/polling code — a natural reuse of infra you already operate. |
| Voice — 1:1 calls | WebRTC, direct peer-to-peer | No server relay needed for two people; lowest latency, zero extra infra cost. |
| Voice — group lobby | LiveKit (self-hosted SFU) | Mesh WebRTC breaks down past a handful of participants since every peer uploads N-1 streams. An SFU centralizes that. LiveKit is open-source, self-hostable, and ships official React client SDKs — deployable as another service on the same Coolify VM. |
| STUN/TURN | coturn (self-hosted) or LiveKit's bundled TURN | Needed for the peer-to-peer DM calls and as fallback for LiveKit when direct UDP is blocked (e.g. strict NAT/firewalls). |
| Native notifications | Tauri's notification API | Incoming call / DM alerts without a heavyweight in-app toast system. |
| Updates | Tauri's built-in updater | Ship patches without asking friends to manually redownload. |
Phase 1 implements the Discord OAuth2 + PKCE authentication flow. This validates the trickiest infra piece (custom URI redirect handling) before any feature work begins.
Implementation details:
- PKCE flow end-to-end: App generates a code verifier + challenge (S256), opens the system browser to Discord's
/oauth2/authorizewithredirect_uri=baatcheet://callback,response_type=code,scope=identify. After Discord consent, the browser redirects tobaatcheet://callback?code=...; the Tauri deep-link plugin captures it. - Token exchange: App exchanges the auth code + verifier for an access/refresh token pair at Discord's
/oauth2/token. No client secret (PKCE public client). - Profile fetch: On first login, call
GET /users/@meand pull id, username, global display name, avatar hash → derive avatar URL. - Session persistence: Refresh token, access token, and
expires_atpersisted in the OS keychain (Windows Credential Manager / macOS Keychain / Linux Secret Service) via thekeyringcrate. Never plaintext on disk, never in localStorage. - Session restore on cold start: If a valid refresh token exists, restore the session silently (no browser step, no re-consent). Only fall back to "Continue with Discord" if no token or refresh fails.
- Silent background refresh: Proactive timer scheduled shortly before the access token's
expires_at(60s margin). On any Discord API 401, refresh once and retry the request. If refresh itself fails (revoked/401), clear the session and fall back to login. - Convex
userstable: User profile persisted to a Convexuserstable on first login (Discord id as the natural key). Updated on subsequent logins if any field changed. Public mutation (no Convex auth in Phase 1). - Log out: Clears the stored refresh token + access token from keychain; returns to the "Continue with Discord" screen. Does NOT call Discord's
/oauth2/revokeendpoint (out of scope for v1).
Dev-only commands (debug builds only):
dev_set_expires_in(seconds)— set a short expiry for testing the proactive refresh timerdev_corrupt_refresh()— corrupt the refresh token to test the refresh failure path
Manual smoke tests (see specs/2026-07-06-auth-discord-oauth2-pkce/validation.md):
- Cold-start login with avatar
- Kill + reopen restores session without re-consent
- Token near expiry triggers silent refresh
- Log out clears session cleanly
Phase 2 proves the realtime data layer and gives the first sign of "who's around": each online client maintains a live presence doc in Convex; everyone else sees appear/disappear + status text in a collapsible sidebar, with no refresh and no polling.
Implementation details:
presencetable (separate fromusers): A new Convexpresencetable keyed byuserId(FK tousers) + denormalizeddiscordId. Keeping presence separate avoids thrashing the rarely-changingusersprofile doc with ~10s heartbeat writes. Indexes:byUser,byDiscordId.- Binary Online/Offline + self-set status:
online: boolean+status: string(free-text "now playing"/custom line). No Idle/DND in v1. Status persists across log out (onlyonlineflips), so the user doesn't retype on next login. Status is self-set in BaatCheet — Discord'sidentifyscope can't read rich presence. - Heartbeat + TTL sweep (crash-resilient offline detection): The client writes
lastSeenevery ~10s. A Convex cronsweepOfflineruns every ~5s and flips any presence doc whoselastSeenis older than 30s toonline:false. Graceful close (window close / log out) setsonline:falseimmediately. Result: online appears within ~1s (reactive subscription); crash-disconnect resolves within ~30–35s (30s staleness + up to 5s until next sweep). - Reactive presence list (no polling, no manual websocket): The sidebar subscribes to a live
listPresencequery (joined withusersprofile). Appear/disappear and status edits propagate automatically via Convex reactive subscriptions. - Collapsible friends sidebar: Left rail listing every
usersdoc (one pre-existing friend group — no friend-request flow). Entries grouped online-first (alpha by displayName, fallback username), then offline. Each row shows avatar, name, status text, and an online/offline dot. Collapsible to a narrow icon rail; collapse state persists across relaunch via localStorage (a UI pref, not a credential). The sidebar is the only new chrome — the main area stays the Phase-1 post-auth screen. - Graceful close: A Tauri
onCloseRequestedlistener firessetOfflinebefore the window closes (best-effort, 2s timeout). The TTL sweep is the backstop if the mutation doesn't land in time. - Log-out teardown: The log-out path now stops the heartbeat + calls
setOfflinebefore clearing tokens. TheusePresenceunmount cleanup is the backstop for Rust-initiated teardown (refresh-failure →discord:needs-login). - Public mutations (known v1 limitation): Presence mutations are public (no Convex auth middleware), keyed by
userId/discordId. A misbehaving client could spoof another user's presence. Acceptable for v1 (≤10 trusted friends, Convex Cloud dev backend); hardening (Convex auth or signed writes) is deferred.
Manual smoke tests (see specs/2026-07-06-presence/validation.md):
- Two clients see each other appear/disappear live, with status, within ~1s (the Phase-2 DoD)
- Crash-disconnect → offline within ~30–35s (TTL sweep path)
- Status set on A → visible on B live; log-out flips offline and stops the heartbeat
Phase 3 is the first usable chat: a 1:1 text conversation between two friends backed by Convex — messages sent from client A appear on client B live (no refresh, no polling), and reopening the DM shows full history. It lands the 2-pane app shell (friends/DM sidebar | DM thread) that Phase 5 retargets at the group lobby.
Implementation details:
- Generic
conversations+messagestables (Decision D1): aconversationsdoc carriestype("dm"now;"group"lands Phase 5),participantIds(exactly 2 for a DM, stored sorted), and a canonical sortedkey("userIdA__userIdB") so both participants resolve to the same conversation doc via a single upsert/lookup path (indexbyKey). A separatemessagestable is keyed byconversationId+ ordered bycreatedAt(indexbyConversation). Phase 5's group lobby is atype:"group"doc reusing the samemessagestable — a UI retarget, not a schema migration. - DM lifecycle: clicking a friend in the sidebar calls
getOrCreateDM(myUserId, peerUserId)(computes the canonicalkey, inserts if missing, returns the_id) and swaps the main pane to that DM thread. The sidebar gains a reorderable "Direct Messages" section (listMyDMs, sorted bylastMessageAtdesc, with last-message preview) above the Phase-2 friends list (which is preserved and now DM-launchable). - Sending + history:
sendMessage(conversationId, senderId, body)inserts amessagesdoc AND patchesconversations.lastMessageAtin one transaction (so the DM list reorders live). History is a single reactivelistMessages(conversationId)subscription returning the full ordered history — no pagination, no lazy-loading in v1 (Decision D5 — YAGNI for a ≤10-person group; revisit only if subscription size becomes a problem). Messages persist forever for v1. - Typing indicators (Decision D3): a
typingtable (conversationId,userId,lastTyped) + asetTypingmutation (debounced ~300ms client-side, not per keystroke) + a recency-filteredlistTypingquery (lastTyped > now - 3000, self-excluded). The DM thread shows "… is typing" for the peer. Stale docs are invisible by recency — no cron is required for v1 (the table stays tiny; a sweep can be added if it grows). - 2-pane app shell (Decision D2): the Phase-2 collapsible sidebar becomes DM-selectable; the main pane swaps from the Phase-1 post-auth placeholder to the active DM thread (or an empty state). There is no narrow icon rail — in Phase 3 there is only one place to chat (1:1 DMs), so a DM-vs-group switcher has nothing to switch between; the icon rail defers to Phase 5 when the group lobby becomes the second surface. The last-opened DM persists across relaunch via localStorage (a UI pref — an id, not a credential) and is cleared on logout.
- Message UI: a composer (textarea + Send button; Enter sends, Shift+Enter newline) at the bottom; a scrollable message list above with bubbles (own right-aligned, peer left-aligned with avatar + name); auto-scroll to bottom on new message and on conversation switch. Light Tailwind styling only (Phase 7 owns the polished Discord-derived theme).
- Public mutations (known v1 limitation — Decision D4):
sendMessage,setTyping, andgetOrCreateDMare public (no Convex auth middleware), keyed byconversationId/userId/senderId. A misbehaving client could spoof another user's messages or typing. Acceptable for v1 (≤10 trusted friends, Convex Cloud dev backend); hardening (Convex auth or signed writes) is deferred. Inherits Phase 1 D-impl-3 / Phase 2 D7.
Manual smoke tests (see specs/2026-07-06-dm-text/validation.md):
- Send A→B live + B→A live (the Phase-3 DoD, first half) — messages propagate in both directions within ~1s, no manual refresh, both clients resolve to the same
conversationsdoc - Reopening the DM shows full history (the Phase-3 DoD, second half) — full history restored on DM reopen and on app relaunch; no truncation (no pagination in v1)
- Typing indicator A→B live — appears within ~1s, disappears ~3s after the peer stops typing (recency filter, no cron)
- DM list reorders by
lastMessageAt+ sidebar selection round-trips — a new message reorders the sender's DM to the top live; the last selection persists across relaunch
Phase 4 is the first voice: prove direct peer-to-peer WebRTC audio between two friends, with coturn as TURN fallback. A calls B from the DM thread → B sees an incoming-call toast → B accepts → two-way audio works → either side can mute/deafen/leave cleanly. The roadmap calls this "smaller surface area than group voice — good place to learn the WebRTC API," so Phase 4 uses the raw WebRTC API directly (no SFU, no wrapper library); the LiveKit SFU for group voice is Phase 6.
Implementation details:
callstable in Convex (Decision D3): acallsdoc carriescallerId,calleeId,status("calling"|"accepted"|"rejected"|"ended"|"missed"),offerSdp,answerSdp(nullable),callerIceCandidates/calleeIceCandidates(JSON-encodedRTCIceCandidateInitarrays),startedAt,connectedAt,endedAt,endReason. Indexed bybyCallee(incoming-call toast subscription) andbyCaller. Both sides subscribe to the single call doc viagetCall(callId)for state transitions + ICE trickle.- Signaling over Convex (Decision D1): exchange the SDP offer/answer + trickled ICE candidates via Convex docs that both sides subscribe to — reusing the Phase-2/3 reactive subscription + public-mutation pattern. No new WebSocket infra; signaling state lives in the
callsdoc. - Raw WebRTC layer (Decision D4): a thin in-repo wrapper (
src/webrtc/peerConnection.ts) over the browserRTCPeerConnection— nosimple-peer/peerjsdependency. Handlesonicecandidate→addIceCandidatemutation,ontrack→ remote<audio>element,createOffer/createAnswer/setLocalDescription/setRemoteDescription/addIceCandidate,close. ICE servers config read fromVITE_ICE_SERVERS(Vite-embedded JSON ofRTCIceServer[]) — STUN + coturn TURN (Decision D2). - coturn provisioning (Decision D2): a single self-hosted coturn on the Coolify VM, shared with Phase 6 (group voice + LiveKit fallback). Static long-term-mechanism credentials for v1 (Decision D5). The ICE servers JSON is provided to the frontend via
VITE_ICE_SERVERS(env, not in the repo). - Call UI (Decision D12): a call button (phone icon) in the DM thread header — clicking it starts a 1:1 voice call with the DM peer. The call button is disabled when the peer is offline (Decision D11). The incoming-call toast + floating call controls are rendered by
AuthenticatedLayout(independent of the active DM, so the user can browse DMs mid-call). Mute = local audio trackenabled = false; deafen = remote audio element muted AND local track muted (Discord semantics). - Busy + offline handling (Decision D11): call button is disabled when the peer's
presence.online === false. If an incoming call arrives while the callee is already in a call, the callee's client auto-rejects (no toast shown) so the caller gets a fast busy signal. - Public mutations (known v1 limitation — Decision D7):
startCall,answerCall,rejectCall,endCall,addIceCandidate,markMissedare all public (no Convex auth middleware), keyed bycallerId/calleeId/callId. A misbehaving client could spoof a call, hijack another user's call doc, or inject ICE candidates. Acceptable for v1 (≤10 trusted friends, Convex dev backend); hardening deferred. - Platform scope (Decision D9): Phase 4 is validated on Windows (WebView2) only. No macOS
NSMicrophoneUsageDescriptionor Linux WebKitGTK permission handling is wired. Cross-platform mic hardening defers. - Deferred: OS notifications for incoming calls (Decision D6 — in-app toast is the DoD surface), video, screen share, call recording, call history UI, time-limited coturn credentials, call quality stats.
Manual smoke tests (see specs/2026-07-07-voice-1-1/validation.md):
- A calls B → B sees toast → accept → two-way audio (the Phase-4 DoD, first half) — A→B call reaches two-way audio within a few seconds of Accept; the toast → controls transition is live on both sides
- Mute / deafen round-trip (the Phase-4 DoD, second half) — mute and deafen toggle cleanly on both sides with the correct speaker/mic semantics; the UI state reflects the toggles
- Either side leaves cleanly (the Phase-4 DoD, third half) — either side can leave; both clients tear down cleanly (UI removed, tracks stopped, mic indicator cleared); a subsequent call works
- Reject path — the reject path tears down both sides cleanly without ever establishing a peer connection; the
callsdoc records the rejection - Busy + offline callee handling — call button is disabled when peer offline; incoming call while in-call is auto-rejected (no toast), giving the caller a fast busy signal
- coturn TURN fallback across NAT (conditional — requires a strict-NAT test client) — the call connects via a TURN
relaycandidate on a strict-NAT network; two-way audio works
Phase 5 is the group text half of the centerpiece: one shared, always-on "lobby" room for the whole friend group. Any member posts to the lobby → all members see it live; history persists across restarts. This is the roadmap's Phase 5 and the text half of the "Talk to everyone" loop. The key insight: zero schema change — Phase 3's generic conversations/messages/typing tables were designed for this. The lobby is a conversations doc with type: "group", key: "group:lobby", reusing messages + typing verbatim.
Implementation details:
- No schema change (Decision D1):
convex/schema.tsis untouched. Theconversationstable already supportstype: "group"(it'sv.string()),participantIdswith >2 entries (it'sv.array(v.id("users"))), andkey: "group:lobby"(it'sv.string()). This validates Phase 3 D1's forward-looking generic design. - Lobby Convex functions (
convex/lobby.ts):getOrCreateLobby(userId)mutation (looks up bykey === "group:lobby"viabyKeyindex; inserts if missing; adds self toparticipantIdsif found) +getLobbyreactive query (returns the single lobby doc or null). Both public (Decision D7 — v1 spoofing limitation accepted). - Icon rail (Decision D3): the deferred Phase-3 D2 surface lands now. A narrow (~56px) leftmost vertical rail with "Lobby" (hash icon) and "Direct Messages" (person icon). Selecting "Lobby" shows the group text thread; selecting "DMs" shows the selected DM. The sidebar (friends/DMs/presence) persists in both modes. Clicking a friend/DM in the sidebar while in "Lobby" mode switches to "DMs" mode + opens that DM (cross-navigation).
- Shared chat components (
src/components/chat/):MessageBubble+Composerextracted fromDMThreadinto shared files. BothDMThreadandLobbyThreadimport them — single source of truth for bubble/composer rendering. - Chat hook generalization (Decision D4):
useDMThreadrenamed touseChatThread(src/hooks/useChatThread.ts). The hook was already generic (keyed onconversationId+myUserId); the rename makes the name honest. BothDMThreadandLobbyThreaduse it. - LobbyThread (
src/components/LobbyThread.tsx): group text thread with "Lobby" header + group icon + live "N online" count (from the existing presence subscription). Multi-person typing indicator ("X and Y are typing…", "X, Y and N others are typing…"). No call button (group voice is Phase 6 — Decision D8). - View mode state + persistence (Decision D10):
AuthenticatedLayoutowns aviewModestate ("lobby" | "dms"). Last-selected mode persists across relaunch via localStorage (baatcheet.viewmode). Default on fresh login:"lobby"(the centerpiece). Cleared on logout (no cross-user leakage). - Lobby auto-creation on login (Decision D6):
getOrCreateLobby(userId)called once per session whenpresence.userIdbecomes available (ref-guarded). The lobby is always-on. - Floating call overlay persists across view modes (Decision D8): the Phase-4 call overlay (
CallControls+IncomingCallToast) renders outside the view-mode conditional — a 1:1 call works while browsing the lobby. - Public mutations (known v1 limitation — Decision D7):
getOrCreateLobbyis public (no Convex auth middleware), keyed byuserId. A misbehaving client could create a rogue lobby. Acceptable for v1 (≤10 trusted friends, Convex dev backend); hardening deferred. - Deferred: group voice / LiveKit SFU (Phase 6), side-by-side lobby layout (Phase 6), multiple channels/rooms (not in v1), message editing/deletion/reactions/attachments (not in v1), full Discord-derived theme polish (Phase 7).
Manual smoke tests (see specs/2026-07-08-hangout-lobby-text/validation.md):
- Post to lobby → all members see it live (the Phase-5 DoD, first half) — any group member posts to the lobby → all members viewing the lobby see it live within ~1s
- History persists across restarts (the Phase-5 DoD, second half) — full lobby history restored on app restart; no data loss
- Typing indicator in group context — multi-person format renders correctly (1, 2, and 3+ typers); self excluded
- Navigation round-trip (lobby ↔ DMs, view-mode persistence) — icon rail switches between lobby and DMs; cross-navigation from sidebar works; view mode persists across relaunch and is cleared on logout
- No regression in Phase 2/3/4 — presence, DM text, and 1:1 voice all still work; floating call overlay persists across view-mode switches
Rich messaging extends the text chat with image uploads, clickable links with OpenGraph preview cards, an Apple-style emoji picker, and a GIPHY-powered GIF picker. All features work in both DM and lobby threads.
Implementation details:
- Schema extension:
messagestable gains two optional fields:attachments(array of image/GIF objects) andlinkPreview(OG metadata object or null). Bothv.optional()for backward compatibility with existing messages. - Image uploads (
convex/storage.ts):generateUploadUrlmutation produces a short-lived upload URL. The client POSTs the file directly to Convex file storage, receives astorageId, and passes it tosendMessageas an image attachment.listMessagesresolvesstorageId→ URL viactx.storage.getUrl(). - Link previews (
convex/linkPreviews.ts):fetchLinkPreviewinternal action fetches a URL server-side (no CORS), parses HTML for OpenGraph meta tags (og:title,og:description,og:image,og:site_name), and stores the result on the message viastoreLinkPreviewinternal mutation. Scheduled bysendMessagewhen a URL is detected in the body. Preview card appears reactively ~1-3s after the message. - Rich text rendering (
src/components/chat/RichContent.tsx): Parses message text for URLs (vialinkifyjs) and renders them as clickable links that open in the system browser (via Tauriopenerplugin). Preserveswhitespace-pre-wrap break-words. - Link preview card (
src/components/chat/LinkPreviewCard.tsx): Renders a Discord-style preview card below the message text with thumbnail, title, description, and domain. Clickable — opens URL in system browser. - Emoji picker (
src/components/chat/EmojiPicker.tsx): Wraps@emoji-mart/reactPicker withset="apple"andtheme="dark". Selected emoji's native Unicode character is inserted at the textarea cursor position. - GIF picker (
src/components/chat/GifPicker.tsx): Custom GIPHY search UI with trending GIFs by default, debounced search, and a grid of thumbnails. Uses rawfetchcalls to the GIPHY API (no SDK dependency). GIFs are referenced by GIPHY CDN URL (no Convex storage). RequiresVITE_GIPHY_API_KEYenv var — GIF button hidden if key is missing. - Composer toolbar: Three icon buttons (emoji, GIF, image upload) above the textarea. Image upload uses
<input type="file" accept="image/*">(no Tauri plugin needed). Pending image/GIF preview shown above the textarea with remove button. useChatThreadextended:send(body, attachments?)handles image upload flow (generateUploadUrl → POST → storageId) before callingsendMessage. GIF attachments passed by URL directly.useComposerStatehook (src/hooks/useComposerState.ts): Shared state management for pending image, pending GIF, and emoji insertion. Used by bothDMThreadandLobbyThread.- New dependencies:
@emoji-mart/react(7KB),@emoji-mart/data(28MB unpacked, tree-shakeable),linkifyjs(259KB, zero deps). - New env var:
VITE_GIPHY_API_KEY— GIPHY beta key (client-safe, Vite-embedded).
Manual smoke tests:
- Send an image — select a file, preview appears, send → image renders in the bubble for both sender and receiver
- Send a message with a URL — link is clickable, preview card appears ~1-3s later with title/description/thumbnail
- Use the emoji picker — click emoji button, picker opens with Apple-style emojis, select one → inserted at cursor
- Use the GIF picker — click GIF button, trending GIFs load, search for a term, select a GIF → GIF renders in the bubble
- No regression — all Phase 2/3/4/5 features still work
Phase 6 is the voice half of the centerpiece: one always-open group voice room for the whole friend group, backed by a self-hosted LiveKit SFU. Any member clicks "Join Voice" in the lobby → dropped straight into the in-progress call (no invite/accept dance); mute/deafen/leave reuse the Phase-4 semantics; leaving and rejoining is one click with no call-teardown dance. This is the roadmap's Phase 6 and the voice half of the "Talk to everyone" loop.
Implementation details:
- LiveKit SFU (Decision D1): a self-hosted LiveKit server on your Ubuntu VM is the group voice backend. The single shared voice room is named
"lobby"(mirrors the text lobby'skey: "group:lobby"). LiveKit auto-starts the room on first connect + auto-empties on last disconnect — no create/destroy lifecycle. Seedocs/livekit-deployment.mdfor the full deploy guide. livekit-clientSDK only (Decision D2-deviated):livekit-client(core) +livekit-server-sdk(token mint) — 2 deps. The@livekit/components-reactprovider layer was dropped in favor of a hook-driven design: theuseGroupVoicehook owns theRoomlifecycle + drives the roster fromRoomEventlisteners + attaches audio tracks manually viatrack.attach()(mirrorsuseCallowningPeerCall).- Token mint via Convex action (Decision D3):
convex/livekit.ts→mintToken(userId)is a public Convex action (the codebase's first publicaction) that readsLIVEKIT_API_KEY+LIVEKIT_API_SECRETfrom the Convex deployment environment (set viabunx convex env add, NOT.env.local) and mints a JWT scoped to room"lobby". The API secret never reaches the client. - No schema change (Decision D16): voice room state lives in LiveKit (D4), not Convex.
convex/schema.tsis untouched. Aconvex/users_internal.tsfile provides aninternalQueryfor the action to load user docs (actions don't have direct DB access). - Single-click join (Decision D5): a "Join Voice" / "Leave Voice" button in the
LobbyThreadheader. Click →mintToken→room.connect()→ you're in. No toast/invite flow (contrast with 1:1 Phase 4). - Side-by-side layout (Decision D6): when connected to group voice, the lobby view splits inside
<main>:VoiceStage(left,w-72) +LobbyThread(right,flex-1). When not in voice, full-width text (Phase-5 behavior unchanged). Audio persists across view-mode switches (theRoomlives in the hook, not the component). - VoiceStage component (
src/components/voice/VoiceStage.tsx): fixed-width column showing the live participant roster (avatar + name + green speaking ring + muted mic icon), mute/deafen/leave controls bar, and a hidden audio container for attached<audio>elements. - Mute/deafen reuse (Decision D7): Discord semantics inherited from Phase 4. Mute =
localParticipant.setMicrophoneEnabled(false). Deafen = mute mic + mute all attached<audio>elements (remote playback). - Mutual exclusivity (Decision D9): one active voice connection per user. Joining group voice leaves any active 1:1 call; starting/accepting a 1:1 call leaves group voice. Enforced in
AuthenticatedLayoutwrapper callbacks (hooks stay decoupled). - coturn as LiveKit TURN (Decision D10): LiveKit's server config points at the Phase-4 coturn as its TURN server. Participants behind strict NAT relay through coturn. Server config concern, not client code.
- Teardown (Decision D12):
handleLogout+onCloseRequestedcallgroupVoice.leave()BEFOREcall.leave()+goOffline(). Race-timeout-guarded (3s). - Public token-mint (known v1 limitation — Decision D13):
mintTokenis public (no Convex auth); a misbehaving client could pass another user'suserIdand join as them. Acceptable for v1 (≤10 trusted friends). - Platform scope (Decision D14): Windows-only (inherits Phase 4 D9). WebView2 mic access validated in Phase 4.
- Deferred: video, screen share, call recording, push-to-talk, VAD tuning, multiple voice rooms, voice permissions, 1:1 via LiveKit, OS notifications for voice events, Convex auth for token minting, full theme polish, formal idle-CPU/RAM profiling under gaming load.
Manual smoke tests (see specs/2026-07-09-hangout-lobby-voice-livekit/validation.md):
- 3+ join/leave freely, audio stable (the Phase-6 DoD, first half) — 3+ members join/leave freely; audio stays stable with 3 concurrent speakers; roster updates live
- Mute / deafen round-trip in group (the Phase-6 DoD, second half) — mute and deafen toggle cleanly in the group context with Discord semantics; UI state reflects the toggles; muted mic icon propagates to other clients
- Leave + rejoin one-click, no teardown dance (the Phase-6 DoD, third half) — leaving and rejoining is one click each way; rapid rejoin cycles are clean with no stale state
- Side-by-side layout + roster + speaking indicators — voice left + text right when in voice; roster shows participants with avatars/names/speaking-ring/mute-icons; text thread works alongside voice; audio persists across view-mode switches
- Mutual exclusivity with 1:1 call — joining group voice leaves any active 1:1 call; starting a 1:1 call leaves group voice; only one voice connection at a time
- coturn TURN fallback across NAT (conditional — requires a strict-NAT test client) — the strict-NAT client connects via a TURN relay candidate through coturn
- Teardown on logout + window close — group voice disconnects before 1:1 call + goOffline; OS mic indicator clears; other clients see the departure live
- No regression in Phase 2/3/4/5 — presence, DM text, 1:1 voice, and lobby text all still work
- Auth first. Get Discord OAuth2 + PKCE working end-to-end in a bare Tauri shell — this validates the trickiest infra piece (custom URI redirect handling) before any UI exists.
- Presence + DM text, backed by Convex. This proves the realtime data layer and gives you a usable (if ugly) app quickly.
- 1:1 voice via direct WebRTC — smaller surface area than group voice, good place to learn the API.
- Hangout lobby UI (text half) — reuses the DM chat components against a single shared "room" instead of a friend pair.
- Hangout lobby voice via LiveKit — the most infra-heavy step, saved for when the rest of the app already works.
- Theme pass + performance profiling — measure idle CPU/RAM with a game running before calling it done; this was the whole point.
- Group size ceiling — LiveKit scales far past a friend group's needs, but worth deciding roughly how many concurrent voice participants you're designing for, since it affects VM sizing.
- Message history retention — forever, or rolling window? Affects Convex storage growth over time.
- Push notifications when the app is fully closed (not just backgrounded) — out of scope for v1 unless a friend specifically wants it, since it adds a whole separate delivery mechanism (APNs/FCM-equivalent) that a LAN-friends app usually doesn't need.