docs(spec): call-preview-panel — only show when peer is in active call
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user