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

301 lines
12 KiB
Markdown
Raw 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 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the always-on "Sprach-Channel" banner with a Discord-DM-style preview panel that only renders when peers are in an active call, showing them as large avatar tiles with a single "Beitreten" button.
**Architecture:** `VoiceChannelRail.tsx` is renamed to `CallPreviewPanel.tsx` and rewritten with a new tile-grid layout. Visibility rule drops the `|| isGroup` clause so groups behave identically to 1:1 chats. All call-session wiring (`useCall`, `useCallPresence`, `joinActiveCall`, `ConversationHeader`'s phone icon for starting calls) stays unchanged.
**Tech Stack:** TypeScript, React 18, Tailwind, react-i18next, existing `useCallPresence` realtime hook.
**Spec:** `docs/superpowers/specs/2026-05-15-call-preview-panel-design.md`
---
## File Overview
**New files:**
- `apps/desktop/src/components/CallPreviewPanel.tsx` — the new panel.
**Deleted files:**
- `apps/desktop/src/components/VoiceChannelRail.tsx` — replaced by `CallPreviewPanel.tsx`.
**Modified files:**
- `apps/desktop/src/pages/ConversationPage.tsx` — change the import + JSX tag.
**i18n note:** No JSON resource changes needed. The orphaned keys (`voice_empty`, `voice_open`, `voice_channel`, `voice_count`) only existed as inline `defaultValue:` strings inside the deleted component — they were never in the locale files. The new component reuses the existing `app:call.active_in_conv` and `app:call.join` keys (present in both `de/app.json` and `en/app.json`).
---
## Task 1: Create `CallPreviewPanel.tsx`
**Files:**
- Create: `apps/desktop/src/components/CallPreviewPanel.tsx`
- [ ] **Step 1: Create the file**
`apps/desktop/src/components/CallPreviewPanel.tsx`:
```tsx
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import { Avatar } from './Avatar';
import { PhoneIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
conversation: ConversationSummary;
}
const MAX_TILES = 7;
/**
* Discord-DM-style call preview panel. Renders only while peers are in the
* conversation's active call and the local user is NOT in it. Provides large
* avatar tiles plus a single "Beitreten" call-to-action. Calls are still
* STARTED via the topbar phone icon (`ConversationHeader.startCall`); this
* component never initiates — only joins.
*/
export function CallPreviewPanel({ conversation }: Props) {
const { t } = useTranslation(['app']);
const { session } = useAuth();
const { state, joinActiveCall } = useCall();
const presentIds = useCallPresence(conversation.id);
const [collapsed, setCollapsed] = useState(false);
const myId = session?.user.id ?? null;
const iAmIn =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'reconnecting') &&
state.conversationId === conversation.id;
const others = presentIds.filter((u) => u !== myId);
if (iAmIn || others.length === 0) return null;
const visibleTiles = others.slice(0, MAX_TILES);
const overflow = Math.max(0, others.length - MAX_TILES);
const busy = state.kind !== 'idle';
const handleJoin = () => {
if (busy) return;
void joinActiveCall(conversation.id, 'audio');
};
return (
<div className="border-b border-line bg-surface-3/70">
<div className="flex items-center gap-2 px-5 py-2 text-xs">
<PhoneIcon className="h-3.5 w-3.5 text-emerald-500" />
<span className="font-semibold uppercase tracking-[0.12em] text-fg-muted">
{t('app:call.active_in_conv', {
defaultValue: 'Laufender Anruf · {{count}} im Raum',
count: others.length,
})}
</span>
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
aria-label={collapsed ? 'Anrufvorschau ausklappen' : 'Anrufvorschau einklappen'}
className="ml-auto inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
{!collapsed && (
<div className="flex flex-col items-center gap-4 px-5 pb-4 pt-1">
<div className="grid w-full max-w-2xl grid-cols-2 gap-3 md:grid-cols-3">
{visibleTiles.map((id) => {
const member = conversation.members.find((m) => m.userId === id);
const name = member?.profile?.displayName ?? '?';
return (
<div
key={id}
title={name}
className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-2 p-3"
>
<div className="h-16 w-16 overflow-hidden rounded-full">
<Avatar
url={member?.profile?.avatarUrl ?? null}
displayName={name}
className="h-full w-full text-base"
/>
</div>
<span className="line-clamp-1 text-xs font-medium text-fg">{name}</span>
</div>
);
})}
{overflow > 0 && (
<div className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-line bg-surface-2 p-3 text-fg-muted">
<span className="text-xl font-semibold">+{overflow}</span>
<span className="text-xs">weitere</span>
</div>
)}
</div>
<button
type="button"
onClick={handleJoin}
disabled={busy}
className="inline-flex w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<PhoneIcon className="h-4 w-4" />
)}
<span>{t('app:call.join', { defaultValue: 'Beitreten' })}</span>
</button>
</div>
)}
</div>
);
}
```
- [ ] **Step 2: Verify icon imports resolve**
```
cd D:/Programmieren/ChatApp-Electron/chat-app
grep -n "export const PhoneIcon\|export function PhoneIcon\|export const SpinnerIcon\|export function SpinnerIcon\|export const XIcon\|export function XIcon" apps/desktop/src/components/icons.tsx
```
Expected: all three icons exported. (Earlier tasks confirmed `PhoneIcon`, `SpinnerIcon`; `XIcon` is also used by other modals — verify.) If `XIcon` is missing under that name, swap the import to whatever the close-icon export is in `icons.tsx` (e.g. `CloseIcon`) and report the substitution.
- [ ] **Step 3: Typecheck**
```
pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -10
```
Expected: zero errors. (After the encryption-UX rollout the desktop typecheck is clean.)
- [ ] **Step 4: Commit**
```bash
cd D:/Programmieren/ChatApp-Electron/chat-app
git add apps/desktop/src/components/CallPreviewPanel.tsx
git commit -m "feat(desktop): add CallPreviewPanel — Discord-DM-style join surface"
```
---
## Task 2: Wire the new panel in `ConversationPage.tsx` and delete the old rail
**Files:**
- Modify: `apps/desktop/src/pages/ConversationPage.tsx`
- Delete: `apps/desktop/src/components/VoiceChannelRail.tsx`
- [ ] **Step 1: Swap the import**
In `apps/desktop/src/pages/ConversationPage.tsx`, replace this line (around line 26):
```ts
import { VoiceChannelRail } from '../components/VoiceChannelRail';
```
with:
```ts
import { CallPreviewPanel } from '../components/CallPreviewPanel';
```
- [ ] **Step 2: Swap the JSX usage**
In the same file, replace the JSX line (around line 657):
```tsx
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}
```
with:
```tsx
{conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />}
```
- [ ] **Step 3: Delete the dead component**
```
rm apps/desktop/src/components/VoiceChannelRail.tsx
```
- [ ] **Step 4: Sweep for stragglers**
```
grep -rn "VoiceChannelRail" apps/desktop/src
```
Expected: no hits. If anything remains, clean it up.
- [ ] **Step 5: Typecheck**
```
pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -10
```
Expected: zero errors.
- [ ] **Step 6: Build the desktop renderer once to surface any tailwind/runtime issues**
```
pnpm --filter @chat-app/desktop build 2>&1 | tail -15
```
Expected: build succeeds. Tailwind classes used here (`bg-surface-3/70`, `border-line`, `bg-emerald-600`, `bg-surface-2`, `text-fg`, `text-fg-muted`, `aspect-square`, `line-clamp-1`, `grid-cols-2 md:grid-cols-3`) are all already used elsewhere in the desktop app and should be in the existing tailwind config.
- [ ] **Step 7: Commit**
```bash
cd D:/Programmieren/ChatApp-Electron/chat-app
git add apps/desktop/src/pages/ConversationPage.tsx
git rm apps/desktop/src/components/VoiceChannelRail.tsx
git commit -m "refactor(desktop): replace VoiceChannelRail with CallPreviewPanel
Drops the always-on 'Sprach-Channel' banner. The preview panel renders
only when peers are in the active call (1:1 and group identical).
Calls are still started via the topbar phone icon."
```
---
## Task 3: Manual smoke pass (USER)
This task is for the human operator after the previous two land. Run a fresh dev build and walk through the spec's smoke checklist:
- [ ] 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).
- [ ] Have a peer start a call → panel appears, peer's avatar tile + "Beitreten" visible.
- [ ] Click "Beitreten" → joins the call; panel disappears (you're now `iAmIn`).
- [ ] Hang up → panel reappears with peer still in.
- [ ] Peer hangs up too → panel disappears.
- [ ] Resize the window narrow → grid collapses to 2 columns.
- [ ] Group call with 9 participants → 7 avatar tiles + one `+2` overflow tile.
- [ ] Click the `✕` in the panel header → tiles collapse, header stays. Click again → tiles re-expand.
If any step fails, capture the exact behavior and reopen the relevant task.
---
## Self-Review
**1. Spec coverage:**
- Visibility rule (`!iAmIn && others.length > 0`, no `|| isGroup`) — Task 1.
- Discord-DM tile layout, ~120×120px tiles, 2/3-column responsive grid, `+N` overflow at 8 — Task 1.
- "Aktiver Anruf · n im Channel" header reusing `app:call.active_in_conv` — Task 1.
- Centered "Beitreten" CTA, disabled when busy, `SpinnerIcon` while connecting — Task 1.
- Optional collapse via `useState<boolean>`, default expanded — Task 1.
- Mount point unchanged (`!incomingHere &&` gate retained) — Task 2.
- Old empty state and `Channel öffnen` removed by deleting the rail — Task 2.
- Manual smoke list — Task 3.
**2. Placeholder scan:** Clean. Every code block is complete; every command has expected output; the only `defaultValue:` strings are in the i18n calls (not placeholders, just fallback copy).
**3. Type consistency:** `Props { conversation: ConversationSummary }` matches the type used by the deleted rail and consumed by `ConversationPage.tsx`. `useCallPresence` returns `string[]` per `apps/desktop/src/lib/useCallPresence.ts`. `useCall().state.kind` values (`idle`, `connecting`, `connected`, `reconnecting`) match the existing `CallContext` discriminated union. `joinActiveCall(convId, 'audio')` matches the existing `useCall` API.
No gaps; plan is complete.