feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
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>
This commit is contained in:
@@ -1,18 +1,15 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { PresenceState } from '@chat-app/shared/supabase';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import type { PeerPresence } from '../lib/usePeerPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import {
|
||||
GridIcon,
|
||||
InfoIcon,
|
||||
PhoneIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
UsersIcon,
|
||||
VideoIcon,
|
||||
} from './icons';
|
||||
@@ -29,12 +26,21 @@ interface Props {
|
||||
conversation: ConversationSummary | null;
|
||||
peerPresence: PeerPresence | null;
|
||||
onInfoClick?: () => void;
|
||||
onMediaClick?: () => void;
|
||||
onProfileClick?: (ev: React.MouseEvent) => void;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
|
||||
export function ConversationHeader({ conversation, peerPresence, onInfoClick, onSearchClick }: Props) {
|
||||
export function ConversationHeader({
|
||||
conversation,
|
||||
peerPresence,
|
||||
onInfoClick,
|
||||
onMediaClick,
|
||||
onProfileClick,
|
||||
onSearchClick,
|
||||
}: Props) {
|
||||
if (!conversation) {
|
||||
return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
|
||||
return <header className="h-[65px] border-b border-line px-6 py-3" aria-busy="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -43,9 +49,12 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick, on
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
{...(onInfoClick ? { onInfoClick } : {})}
|
||||
{...(onMediaClick ? { onMediaClick } : {})}
|
||||
{...(onProfileClick ? { onProfileClick } : {})}
|
||||
{...(onSearchClick ? { onSearchClick } : {})}
|
||||
/>
|
||||
<ActiveCallBanner conversationId={conversation.id} />
|
||||
{/* Voice-channel rail rendered separately in ConversationPage so it
|
||||
can sit between the message surface and the in-call dock. */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -54,16 +63,27 @@ interface HeaderBarProps {
|
||||
conversation: ConversationSummary;
|
||||
peerPresence: PeerPresence | null;
|
||||
onInfoClick?: () => void;
|
||||
onMediaClick?: () => void;
|
||||
onProfileClick?: (ev: React.MouseEvent) => void;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
|
||||
function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: HeaderBarProps) {
|
||||
function HeaderBar({
|
||||
conversation,
|
||||
peerPresence,
|
||||
onInfoClick,
|
||||
onMediaClick,
|
||||
onProfileClick,
|
||||
onSearchClick,
|
||||
}: HeaderBarProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
const isDm = conversation.type === 'dm';
|
||||
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
|
||||
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
|
||||
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
|
||||
const peerAvatar = isDm
|
||||
? (conversation.peer?.avatarUrl ?? null)
|
||||
: (conversation.avatarUrl ?? null);
|
||||
|
||||
// Hide presence when peer chose invisible — reciprocal privacy.
|
||||
const peerState = peerPresence?.state ?? null;
|
||||
@@ -80,8 +100,16 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
|
||||
})();
|
||||
|
||||
return (
|
||||
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
|
||||
<div className="relative">
|
||||
<header className="discord-chat-surface flex min-h-[65px] items-center gap-3 border-b border-line bg-surface-3 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
data-user-popover-trigger={isDm ? true : undefined}
|
||||
onClick={isDm ? onProfileClick : undefined}
|
||||
disabled={!isDm || !onProfileClick}
|
||||
aria-label={isDm ? 'Profil öffnen' : title}
|
||||
title={isDm ? 'Profil öffnen' : title}
|
||||
className="relative shrink-0 rounded-full text-left transition enabled:cursor-pointer enabled:hover:ring-2 enabled:hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-default"
|
||||
>
|
||||
{isDm ? (
|
||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
||||
) : peerAvatar ? (
|
||||
@@ -100,9 +128,12 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{!isDm && <UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />}
|
||||
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||
</div>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{isDm ? (
|
||||
<>
|
||||
@@ -127,6 +158,11 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
|
||||
icon={SearchIcon}
|
||||
{...(onSearchClick ? { onClick: onSearchClick } : {})}
|
||||
/>
|
||||
<HeaderActionButton
|
||||
label="Medien & Dateien"
|
||||
icon={GridIcon}
|
||||
{...(onMediaClick ? { onClick: onMediaClick } : {})}
|
||||
/>
|
||||
<CallHeaderButton conversationId={conversation.id} kind="audio" />
|
||||
<CallHeaderButton conversationId={conversation.id} kind="video" />
|
||||
{!isDm && onInfoClick && (
|
||||
@@ -158,7 +194,7 @@ function HeaderActionButton({
|
||||
const toneClass =
|
||||
tone === 'accent'
|
||||
? 'text-accent hover:bg-accent/10'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg';
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg dark:hover:bg-[#383a40]';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -201,62 +237,3 @@ function CallHeaderButton({
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveCallBanner({ conversationId }: { conversationId: string }) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
const { state, joinActiveCall, lastCallConversationId, dismissLastCall } = useCall();
|
||||
const active = useCallPresence(conversationId);
|
||||
|
||||
const myId = session?.user.id;
|
||||
const iAmIn =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'outgoing') &&
|
||||
state.conversationId === conversationId;
|
||||
|
||||
const othersIn = active.filter((u) => u !== myId);
|
||||
// Fallback signal: I just left this conv with peers still inside. Covers the
|
||||
// brief window after hangup where presence may not have re-synced yet (the
|
||||
// realtime channel can churn while ConversationHeader remounts).
|
||||
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
|
||||
|
||||
// Once presence confirms the room is empty, drop the "just left" hint so the
|
||||
// banner hides cleanly instead of sticking forever. Grace window handles the
|
||||
// brief gap between hangup and presence re-sync so we don't flicker.
|
||||
useEffect(() => {
|
||||
if (!justLeft) return;
|
||||
if (othersIn.length > 0) return; // still live — keep banner
|
||||
const id = window.setTimeout(() => dismissLastCall(), 3000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [justLeft, othersIn.length, dismissLastCall]);
|
||||
|
||||
if (iAmIn) return null;
|
||||
if (othersIn.length === 0 && !justLeft) return null;
|
||||
|
||||
const count = Math.max(othersIn.length, justLeft ? 1 : 0);
|
||||
const busy = state.kind !== 'idle';
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-emerald-500/30 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-700 dark:text-emerald-100">
|
||||
<PhoneIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
|
||||
<span className="flex-1">
|
||||
{t('app:call.active_in_conv', {
|
||||
defaultValue: 'Active call · {{count}} in room',
|
||||
count,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void joinActiveCall(conversationId, 'audio')}
|
||||
disabled={busy}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs 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-3.5 w-3.5" />
|
||||
) : (
|
||||
<PhoneIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{t('app:call.join', { defaultValue: 'Join' })}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user