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

127 lines
7.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```ts
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.