docs(plan): call-preview-panel implementation plan
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user