7.8 KiB
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-3on desktop ≥768px,grid-cols-2below. - More than 8 participants: render the first 7 tiles, replace the eighth with a
+Noverflow tile. - "Beitreten" button: brand-green, ~280px wide,
py-3, centered below the grid.disabledwhilestate.kind !== 'idle'(showingSpinnerIconto signal busy). - Optional collapse: a small
✕in the header toggles a localuseState<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 updisplayName+avatarUrl.useCall()→ readsstate(to deriveiAmIn+busy); callsjoinActiveCall(conversation.id, 'audio')on click.useAuth()→ readssession.user.idto filter "self" out ofothers.
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_emptyandapp:call.voice_open. (Cleanup inapps/desktop/src/lib/i18n/*translation files.)
What stays
useCallPresence,useCall,joinActiveCalland the realtimecall-presence:<conversationId>channel.ConversationHeaderphone icon as the call-initiation entry point.- Incoming-call UI (
incomingHerebranch inConversationPage) and ringtone. - The mount-point
{conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />}inConversationPage.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):
- Open a 1:1 chat with no active call → panel not rendered.
- Open a group chat with no active call → panel not rendered. (Regression test for the bug we're fixing.)
- Have peer start a call from another device → panel appears with peer's tile + "Beitreten".
- Click "Beitreten" → joins the call; panel disappears (we're now
iAmIn). - Hang up → panel reappears with peer still in.
- Peer hangs up too → panel disappears.
- Resize window narrow → grid collapses to 2 columns.
- Group call with 9 participants → 7 avatar tiles + one
+2overflow 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.