825160ee46
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
6.9 KiB
TypeScript
154 lines
6.9 KiB
TypeScript
import type { ConversationSummary } from '@chat-app/shared/chat';
|
|
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useCall } from '../context/CallContext';
|
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
|
import { CallPreviewModal } from './CallPreviewModal';
|
|
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
|
import { LockIcon, PhoneIcon, PhoneOffIcon, VideoIcon } from './icons';
|
|
|
|
interface Props {
|
|
conversation: ConversationSummary;
|
|
}
|
|
|
|
const PULSE_BG: Record<AvatarColorKey, string> = {
|
|
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
|
|
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
|
|
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
|
|
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
|
|
};
|
|
|
|
// Docked incoming-call panel: replaces the chat header, chat + composer stay
|
|
// visible and usable below (per user decision). Full-bleed overlay lives in
|
|
// the shell-level toast for cross-conversation ring notifications.
|
|
export function IncomingCallPanel({ conversation }: Props) {
|
|
const { t } = useTranslation(['app']);
|
|
const { state, acceptIncoming, rejectIncoming } = useCall();
|
|
const { friendships } = useFriendshipsContext();
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
|
|
if (state.kind !== 'incoming' || state.conversationId !== conversation.id) return null;
|
|
|
|
const isGroup = conversation.type === 'group';
|
|
const callerProfile = conversation.members.find((m) => m.userId === state.fromUserId)?.profile ?? null;
|
|
const callerName =
|
|
callerProfile?.displayName ??
|
|
friendships.find((f) => f.peer.userId === state.fromUserId)?.peer.displayName ??
|
|
'?';
|
|
const groupName = isGroup ? (conversation.name ?? t('app:chats.new_group')) : null;
|
|
const title = isGroup ? groupName ?? callerName : callerName;
|
|
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
|
const avatarUrl = isGroup
|
|
? (conversation.avatarUrl ?? callerProfile?.avatarUrl ?? null)
|
|
: callerProfile?.avatarUrl ?? null;
|
|
const colorKey = colorKeyFor(state.fromUserId);
|
|
const avatarTone = PULSE_BG[colorKey];
|
|
const mediaLabel =
|
|
state.mediaKind === 'video' ? 'Video Call' : 'Voice Call';
|
|
|
|
return (
|
|
<section
|
|
role="region"
|
|
aria-label={t('app:call.incoming_title')}
|
|
className="relative overflow-hidden border-b border-line bg-surface-3"
|
|
>
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(79,70,229,0.10),transparent_60%),radial-gradient(circle_at_70%_80%,rgba(109,115,255,0.08),transparent_55%)]" />
|
|
<div className="relative mx-auto flex max-w-[480px] flex-col items-center px-6 py-8 text-center">
|
|
<p className="text-[12px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
|
{t('app:call.incoming_title')}
|
|
</p>
|
|
<div className="relative my-4 flex h-[112px] w-[112px] items-center justify-center">
|
|
{/* Discord-style: animated pulse rings when motion is OK; one
|
|
static ring + a faint outer glow when prefers-reduced-motion. */}
|
|
<span
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent motion-safe:animate-pulse-ring motion-reduce:opacity-70"
|
|
/>
|
|
<span
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent motion-safe:animate-pulse-ring motion-reduce:hidden"
|
|
style={{ animationDelay: '1s' }}
|
|
/>
|
|
{avatarUrl ? (
|
|
<img
|
|
src={avatarUrl}
|
|
alt={title}
|
|
className="h-24 w-24 rounded-full border-[3px] border-surface-3 object-cover"
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<div
|
|
className={
|
|
'flex h-24 w-24 items-center justify-center rounded-full border-[3px] border-surface-3 font-display text-4xl font-bold ' +
|
|
avatarTone
|
|
}
|
|
>
|
|
{letter}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="font-display text-2xl font-bold tracking-tight text-fg">{title}</p>
|
|
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-fg-muted">
|
|
<LockIcon className="h-3.5 w-3.5" />
|
|
<span>E2E verschlüsselt · {mediaLabel}</span>
|
|
{isGroup && (
|
|
<>
|
|
<span className="text-fg-muted/50" aria-hidden="true">·</span>
|
|
<span>
|
|
{t('app:call.incoming_group_from', {
|
|
name: callerName,
|
|
defaultValue: callerName + ' ruft an',
|
|
})}
|
|
</span>
|
|
</>
|
|
)}
|
|
</p>
|
|
<div className="mt-5 flex w-full max-w-[440px] flex-wrap gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={rejectIncoming}
|
|
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-4 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
|
|
>
|
|
<PhoneOffIcon className="h-4 w-4" />
|
|
<span>{t('app:call.decline')}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void acceptIncoming('audio')}
|
|
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-4 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
|
>
|
|
<PhoneIcon className="h-4 w-4" />
|
|
<span>
|
|
{state.mediaKind === 'video'
|
|
? t('app:call.accept_audio', { defaultValue: 'Nur Audio' })
|
|
: t('app:call.accept')}
|
|
</span>
|
|
</button>
|
|
{state.mediaKind === 'video' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPreview(true)}
|
|
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-accent px-4 py-3.5 text-sm font-semibold text-accent-fg shadow-accept-btn transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
>
|
|
<VideoIcon className="h-4 w-4" />
|
|
<span>{t('app:call.accept_video', { defaultValue: 'Mit Video' })}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showPreview && (
|
|
<CallPreviewModal
|
|
title={title}
|
|
onJoin={() => {
|
|
setShowPreview(false);
|
|
void acceptIncoming('video');
|
|
}}
|
|
onCancel={() => setShowPreview(false)}
|
|
/>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|