Files
ChatApp/docs/superpowers/specs/2026-05-15-call-preview-panel-design.md
T

7.8 KiB
Raw Blame History

Call Preview Panel — Design

Date: 2026-05-15 Scope: Desktop only (mobile call UI is separate). Status: Approved by user.

Problem

The current VoiceChannelRail component sits at the top of the chat feed and is always visible in group conversations with the empty-state "Niemand drin — sei der Erste · Channel öffnen". This Discord-server-style "voice channel" affordance is wrong for a friends-messenger: there is no concept of named voice rooms, calls are placed person-to-person via the topbar phone icon, and the always-on banner adds noise without value.

Concretely, the screenshot the user objected to shows the band rendered for a 3-person group chat with no active call — there's nothing to "open" because the topbar phone icon is the actual call-start entry point.

Goals

  • Banner appears only when an active call exists in the current conversation and the local user is not already in it.
  • Behavior is identical for 1:1 and group conversations.
  • When visible, the panel shows participants as Discord-DM-style tiles (large avatar + name), with a single prominent "Beitreten" button.
  • Anrufe starten weiterhin über das Phone-Icon in ConversationHeader (startCall); diese Komponente initiiert keine neuen Calls.

Non-Goals

  • Live video preview of in-call participants. (Requires a LiveKit subscribe-only-mode wiring; deferred.)
  • Speaking-indicator rings. (Requires LiveKit data-channel subscription without joining; deferred.)
  • Per-participant mic-muted overlay icons. (Same reason; deferred.)
  • Mobile (React Native) variant.
  • Any change to the actual call session (useCall, joinActiveCall, ringing UI).

Architecture

Component swap

apps/desktop/src/components/VoiceChannelRail.tsx is renamed to CallPreviewPanel.tsx and rewritten. The old skinny one-line band layout is dropped entirely. No backwards-compat shim — the JSX import in ConversationPage.tsx is updated in-place.

Visibility rule

const iAmIn =
  (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'reconnecting')
  && state.conversationId === conversation.id;

const others = presentIds.filter((u) => u !== myId);
const visible = !iAmIn && others.length > 0;

Note: drops the || isGroup clause that produced the always-on banner.

Layout

Renders between ConversationHeader and the message list (same insertion point as today). When mounted, the panel pushes messages down — fine because the panel only mounts when there is an active call worth surfacing.

┌───────────────────────────────────────────────────────────┐
│ 📞  Aktiver Anruf · {n} im Channel        [✕ minimieren]  │
├───────────────────────────────────────────────────────────┤
│   ┌────────┐  ┌────────┐  ┌────────┐                      │
│   │ Avatar │  │ Avatar │  │ Avatar │   responsive grid    │
│   │  Anna  │  │  Ben   │  │  Cara  │                      │
│   └────────┘  └────────┘  └────────┘                      │
│                                                           │
│              [ 📞  Beitreten ]                            │
└───────────────────────────────────────────────────────────┘
  • Tiles: ~120×120px squares. Avatar centered (large, fallback initials), display name beneath in a single line (truncate on overflow).
  • Grid: grid-cols-3 on desktop ≥768px, grid-cols-2 below.
  • More than 8 participants: render the first 7 tiles, replace the eighth with a +N overflow tile.
  • "Beitreten" button: brand-green, ~280px wide, py-3, centered below the grid. disabled while state.kind !== 'idle' (showing SpinnerIcon to signal busy).
  • Optional collapse: a small in the header toggles a local useState<boolean> to render only the header row when minimized. Default expanded. Collapse state is per-mount (not persisted across navigation).

Data flow

Inputs (unchanged from current VoiceChannelRail):

  • useCallPresence(conversation.id)string[] of userIds currently in the room.
  • conversation.members[] → join with userIds to look up displayName + avatarUrl.
  • useCall() → reads state (to derive iAmIn + busy); calls joinActiveCall(conversation.id, 'audio') on click.
  • useAuth() → reads session.user.id to filter "self" out of others.

No new hooks, no new realtime channels. The existing useCallPresence 3-second poll-fallback continues to recover from dropped presence events.

What is removed

  • The empty-state copy ("Niemand drin — sei der Erste") and the "Channel öffnen" button.
  • The isGroup || clause in the visibility rule.
  • i18n keys orphaned by the empty state: app:call.voice_empty and app:call.voice_open. (Cleanup in apps/desktop/src/lib/i18n/* translation files.)

What stays

  • useCallPresence, useCall, joinActiveCall and the realtime call-presence:<conversationId> channel.
  • ConversationHeader phone icon as the call-initiation entry point.
  • Incoming-call UI (incomingHere branch in ConversationPage) and ringtone.
  • The mount-point {conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />} in ConversationPage.tsx — only the import name and the JSX tag change.

Edge Cases

Case Behavior
Local user is already connected iAmIn === true → panel hidden.
Local user is connecting/reconnecting to this conv iAmIn === true → panel hidden (avoids flashing during transition).
Realtime presence drops momentarily 3-second poll fallback in useCallPresence reseeds presentIds; panel may briefly disappear and reappear. Acceptable.
Conversation has 0 accepted members presentIds is empty → panel hidden.
Member in presentIds is not in conversation.members[] (e.g., recently removed peer still finishing leave) Render tile with placeholder name "?" and the Avatar initials fallback. Don't crash.
User clicks "Beitreten" while state.kind === 'connecting' to a different conversation Button is disabled because busy = state.kind !== 'idle'. User must wait or hang up first.
Incoming call to this conversation while the panel is visible incomingHere branch in ConversationPage already takes precedence (!incomingHere && gate at the JSX site) → panel hidden until the user accepts/dismisses the ring.

Testing

Manual smoke (no new unit tests):

  1. Open a 1:1 chat with no active call → panel not rendered.
  2. Open a group chat with no active call → panel not rendered. (Regression test for the bug we're fixing.)
  3. Have peer start a call from another device → panel appears with peer's tile + "Beitreten".
  4. Click "Beitreten" → joins the call; panel disappears (we're now iAmIn).
  5. Hang up → panel reappears with peer still in.
  6. Peer hangs up too → panel disappears.
  7. Resize window narrow → grid collapses to 2 columns.
  8. Group call with 9 participants → 7 avatar tiles + one +2 overflow tile.

The component is view-only over hooks; behavior is fully covered by the existing useCall and useCallPresence test paths plus the manual smoke list above.

Out of Scope (future)

  • Live participant video preview when peer's camera is on.
  • Speaking-indicator rings.
  • Per-tile mic-muted / camera-off overlays.
  • Sound/notification when a call starts in a conversation that's not currently focused (separate notification work).
  • Mobile equivalent.