Files
ChatApp/apps/desktop/src/context/CallContext.tsx
T
byGalax 4db65993d5
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
feat: redesign + avatars + theme + call presence fixes (v0.6.0)
Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
  ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel

Calls:
- Split call UI: IncomingCallPanel, InCallPanel, CallControls,
  CallParticipantTile, ScreenShareViewer
- Active speaker hook (useActiveSpeakers)
- Fix: ActiveCallBanner stayed hidden after hangup while peers in room.
  - useCallPresence: bind presence callbacks only when we own subscribe
    (Supabase forbids .on() after .subscribe() on shared dedup'd channels)
  - useCallPresence: never removeChannel — channel is shared with CallContext
    so tearing it down on ConversationHeader unmount killed live tracking
  - ActiveCallBanner: lastCallConversationId fallback so banner shows
    instantly after hangup, auto-dismiss when room confirmed empty
- Drop unused useAnyActiveCall

Bump tauri version 0.5.0 -> 0.6.0
2026-04-20 00:26:19 +02:00

1084 lines
36 KiB
TypeScript
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.
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { sendEncryptedMessage } from '@chat-app/shared/chat';
import {
type CallKind,
type CallSignal,
fetchLivekitToken,
signalTopic,
} from '@chat-app/shared/rtc';
import type { RealtimeChannel } from '@supabase/supabase-js';
import {
ConnectionState,
type RemoteParticipant,
type RemoteTrack,
type RemoteTrackPublication,
Room,
RoomEvent,
Track,
} from 'livekit-client';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useAuth } from './AuthContext';
import { useConversationsContext } from './ConversationsContext';
import { playEndBeep, playJoinBeep, playLeaveBeep } from '../lib/callSounds';
import { notify } from '../lib/osNotify';
import {
isTauriRuntime,
registerPttShortcut,
unregisterPttShortcut,
} from '../lib/globalShortcut';
import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
import {
getAudioQualityParams,
getAudioSettings,
} from '../lib/audioSettings';
import {
createCallE2EE,
getCallE2EESettings,
isE2EESupported,
} from '../lib/callE2EE';
import {
getPresetParams,
getScreenShareSettings,
} from '../lib/screenShareSettings';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
export type CallState =
| { kind: 'idle' }
| {
kind: 'outgoing';
callId: string;
conversationId: string;
mediaKind: CallKind;
ringingSince: string;
}
| {
kind: 'incoming';
callId: string;
conversationId: string;
fromUserId: string;
mediaKind: CallKind;
}
| {
kind: 'connecting';
callId: string;
conversationId: string;
mediaKind: CallKind;
}
| {
kind: 'connected';
callId: string;
conversationId: string;
mediaKind: CallKind;
startedAt: string;
}
| { kind: 'error'; message: string };
export interface RemoteScreenShare {
track: RemoteTrack;
participantId: string;
participantName: string;
}
// Visual call modes (Discord-style): grid shows all tiles equally, focus pins
// one speaker with others in a strip, fullscreen is cinema mode.
export type CallMode = 'grid' | 'focus' | 'fullscreen';
interface CallContextValue {
state: CallState;
room: Room | null;
remoteParticipants: RemoteParticipant[];
isMuted: boolean;
isE2EEActive: boolean;
isScreenSharing: boolean;
// Zero or more remote screen shares (LiveKit supports multiple simultaneous).
remoteScreenShares: RemoteScreenShare[];
// Remembers the conversation of the last call we left so a sidebar widget
// can show "still live — rejoin" while peers stay in the room.
lastCallConversationId: string | null;
// Clean-Rail UI state — display mode + focused participant id.
callMode: CallMode;
focusedId: string | null;
// Actions:
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
acceptIncoming: () => Promise<void>;
rejectIncoming: () => void;
hangup: () => Promise<void>;
toggleMute: () => void;
toggleScreenShare: () => Promise<void>;
dismissLastCall: () => void;
setCallMode: (mode: CallMode) => void;
setFocusedId: (id: string | null) => void;
}
const CallContext = createContext<CallContextValue | null>(null);
const RING_TIMEOUT_MS = 45_000;
const SOLO_TIMEOUT_MS = 5 * 60_000;
function newCallId(): string {
return crypto.randomUUID();
}
export function CallProvider({ children }: { children: ReactNode }) {
const { session, device } = useAuth();
const { conversations } = useConversationsContext();
const myId = session?.user.id;
const [state, setState] = useState<CallState>({ kind: 'idle' });
const [room, setRoom] = useState<Room | null>(null);
const [remoteParticipants, setRemoteParticipants] = useState<RemoteParticipant[]>([]);
const [isMuted, setIsMuted] = useState(false);
const [isE2EEActive, setIsE2EEActive] = useState(false);
const [isScreenSharing, setIsScreenSharing] = useState(false);
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null);
const signalChannelRef = useRef<RealtimeChannel | null>(null);
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
const ringTimerRef = useRef<number | null>(null);
const soloTimerRef = useRef<number | null>(null);
const roomRef = useRef<Room | null>(null);
// Tracks whether the current call was ever in the connected state — needed
// so hangup/solo-timeout can emit a real duration message vs. "missed".
const everConnectedRef = useRef<boolean>(false);
// Peers we invited who haven't answered yet. The outgoing call only goes
// `declined → idle` once *every* ringer has rejected (not just the first).
// For 1:1 calls the set has one entry so behaviour is unchanged; for
// groups, a single reject doesn't terminate the call while others ring.
const pendingPeersRef = useRef<Set<string>>(new Set());
const stateRef = useRef<CallState>(state);
stateRef.current = state;
// Keep latest conversations accessible from signal-channel closures without
// re-subscribing the channel on every conversations update.
const conversationsRef = useRef(conversations);
conversationsRef.current = conversations;
// --- Helpers -----------------------------------------------------------
// Emit a structured call-event message into the conversation so call
// history shows up in the chat. Caller-side only (to dedupe).
const emitCallEvent = useCallback(
async (
conversationId: string,
status: 'ended' | 'missed' | 'declined',
mediaKind: CallKind,
durationSec: number,
) => {
if (!session?.user.id || !device?.id) return;
try {
const priv = await loadDevicePrivateKey(
devLocalSecretStore,
session.user.id,
device.id,
);
if (!priv) return;
const payload = JSON.stringify({
v: 1,
type: 'call_event',
status,
mediaKind,
durationSec,
});
await sendEncryptedMessage({
client: supabase,
conversationId,
plaintext: payload,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderPrivateKey: priv,
});
} catch (err: unknown) {
console.error('emitCallEvent failed', err);
}
},
[session?.user.id, device?.id],
);
const clearRingTimer = useCallback(() => {
if (ringTimerRef.current !== null) {
window.clearTimeout(ringTimerRef.current);
ringTimerRef.current = null;
}
}, []);
const clearSoloTimer = useCallback(() => {
if (soloTimerRef.current !== null) {
window.clearTimeout(soloTimerRef.current);
soloTimerRef.current = null;
}
}, []);
const disconnectRoom = useCallback(async () => {
const r = roomRef.current;
if (r) {
try {
await r.disconnect();
} catch {
/* ignore */
}
}
roomRef.current = null;
setRoom(null);
setRemoteParticipants([]);
setRemoteScreenShares([]);
setIsScreenSharing(false);
setIsE2EEActive(false);
const pres = presenceChannelRef.current;
if (pres) {
try {
await pres.untrack();
} catch {
/* ignore */
}
// Don't removeChannel — observers (e.g. ActiveCallBanner, RejoinCallBar)
// on the same topic share this instance via Supabase's topic dedupe.
// Removing it would break their presence subscription. Untracking is
// enough: the server drops our presence entry; observers see us leave.
presenceChannelRef.current = null;
}
}, []);
const sendSignal = useCallback(
async (toUserId: string, payload: CallSignal) => {
const ch = supabase.channel(signalTopic(toUserId));
await ch.subscribe();
await ch.send({ type: 'broadcast', event: 'signal', payload });
window.setTimeout(() => {
void supabase.removeChannel(ch);
}, 400);
},
[],
);
const peerUserIdsFor = useCallback(
(conversationId: string): string[] => {
const conv = conversations.find((c) => c.id === conversationId);
if (!conv || !myId) return [];
return conv.members.filter((m) => m.userId !== myId && m.accepted).map((m) => m.userId);
},
[conversations, myId],
);
// Transitions to 'connected' once at least one remote participant is in
// the room. Also cancels the solo-timeout. No-op if already connected.
const markConnectedIfReady = useCallback(
(r: Room, conversationId: string, mediaKind: CallKind, callId: string) => {
const cur = stateRef.current;
if (cur.kind === 'connected') return;
if (r.remoteParticipants.size === 0) return;
clearRingTimer();
clearSoloTimer();
everConnectedRef.current = true;
setState({
kind: 'connected',
callId,
conversationId,
mediaKind,
startedAt: new Date().toISOString(),
});
},
[clearRingTimer, clearSoloTimer],
);
// --- LiveKit join/leave ------------------------------------------------
const joinRoom = useCallback(
async (conversationId: string, mediaKind: CallKind, callId: string) => {
const { token, url } = await fetchLivekitToken(supabase, conversationId);
// Discord-style dynamic quality: VP9 + SVC L3T3_KEY emits 3 spatial ×
// 3 temporal layers from a single encode. LiveKit's congestion control
// drops temporal/spatial layers per-subscriber when uplink degrades,
// so a 4K60 publisher downshifts smoothly to 720p30 on weak links
// without a full renegotiate. `adaptiveStream` pauses video downlink
// off-screen; `dynacast` stops publishing layers nobody subscribes to;
// `degradationPreference: balanced` tells WebRTC to trade framerate vs
// resolution dynamically based on the encoder's CPU + bandwidth.
// Discord-style dynamic quality: VP9 + SVC L3T3_KEY emits 3 spatial ×
// 3 temporal layers from a single encode. LiveKit's congestion control
// drops temporal/spatial layers per-subscriber when uplink degrades,
// so a 4K60 publisher downshifts smoothly to 720p30 on weak links
// without a full renegotiate. `adaptiveStream` pauses video downlink
// off-screen; `dynacast` stops publishing layers nobody subscribes to;
// `degradationPreference: balanced` tells WebRTC to trade framerate vs
// resolution dynamically based on the encoder's CPU + bandwidth.
// Audio: `red` doubles Opus packets for packet-loss concealment;
// `dtx` is off in hifi so music doesn't get zeroed out between notes;
// `forceStereo` publishes stereo when the hifi preset captures stereo.
const ssCfg = getScreenShareSettings();
const ssParams = getPresetParams(ssCfg.preset);
const aParams = getAudioQualityParams(getAudioSettings().quality);
const e2eeCfg = getCallE2EESettings();
const e2eeUsable = e2eeCfg.enabled && isE2EESupported();
const e2eeBundle = e2eeUsable ? await createCallE2EE(conversationId) : null;
const r = new Room({
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: 'vp9',
scalabilityMode: 'L3T3_KEY',
degradationPreference: 'balanced',
backupCodec: true,
audioPreset: {
maxBitrate: aParams.bitrateKbps * 1000,
priority: 'high',
},
red: true,
dtx: aParams.stereo ? false : true,
forceStereo: aParams.stereo,
screenShareEncoding: {
maxBitrate: ssParams.bitrateKbps * 1000,
maxFramerate: ssParams.framerate,
priority: 'high',
},
},
...(e2eeBundle
? {
e2ee: {
keyProvider: e2eeBundle.keyProvider,
worker: e2eeBundle.worker,
},
}
: {}),
});
roomRef.current = r;
setRoom(r);
r.on(RoomEvent.ConnectionStateChanged, (cs) => {
if (cs === ConnectionState.Disconnected) {
// Server / network tore us out — reset state cleanly. Remember the
// conversation so the sidebar "still live — rejoin" widget stays
// visible: observers will poll presence and detect any remaining
// peers, which is the only cue this side gets that a call is live.
clearRingTimer();
clearSoloTimer();
const wasInCall =
stateRef.current.kind === 'connected' ||
stateRef.current.kind === 'connecting' ||
stateRef.current.kind === 'outgoing';
if (wasInCall) {
setLastCallConversationId(conversationId);
}
// Untrack our presence entry so observers see us leave. Keep the
// channel alive — observers share it via Supabase topic dedupe.
const pres = presenceChannelRef.current;
if (pres) {
try {
void pres.untrack();
} catch {
/* ignore */
}
presenceChannelRef.current = null;
}
if (stateRef.current.kind !== 'idle' && stateRef.current.kind !== 'error') {
setState({ kind: 'idle' });
}
roomRef.current = null;
setRoom(null);
setRemoteParticipants([]);
setRemoteScreenShares([]);
setIsScreenSharing(false);
setIsE2EEActive(false);
}
});
r.on(RoomEvent.ParticipantConnected, () => {
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
void playJoinBeep();
markConnectedIfReady(r, conversationId, mediaKind, callId);
});
r.on(RoomEvent.ParticipantDisconnected, () => {
const remaining = Array.from(r.remoteParticipants.values());
setRemoteParticipants(remaining);
void playLeaveBeep();
// Alone in the room while connected — start the solo-timeout.
if (
stateRef.current.kind === 'connected' &&
remaining.length === 0 &&
soloTimerRef.current === null
) {
soloTimerRef.current = window.setTimeout(() => {
void (async () => {
soloTimerRef.current = null;
const cur = stateRef.current;
if (cur.kind === 'connected') {
const durationSec = Math.max(
0,
Math.floor((Date.now() - new Date(cur.startedAt).getTime()) / 1000),
);
void emitCallEvent(cur.conversationId, 'ended', cur.mediaKind, durationSec);
}
await disconnectRoom();
void playEndBeep();
// Room was empty by timeout — nothing to rejoin into.
setLastCallConversationId(null);
setState({ kind: 'idle' });
})();
}, SOLO_TIMEOUT_MS);
}
});
r.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
attachTrack(track, publication, participant);
if (
track.kind === Track.Kind.Video &&
(track.source === Track.Source.ScreenShare ||
publication.source === Track.Source.ScreenShare)
) {
const identity = participant.identity;
const name = participant.name || identity;
setRemoteScreenShares((prev) => {
if (prev.some((s) => s.track.sid === track.sid)) return prev;
return [...prev, { track, participantId: identity, participantName: name }];
});
}
});
r.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
detachTrack(track, publication, participant);
if (track.kind === Track.Kind.Video) {
setRemoteScreenShares((prev) => prev.filter((s) => s.track.sid !== track.sid));
}
});
// Track my own screen-share state via LocalTrack events so the toggle
// stays in sync if the user stops sharing via the browser's native UI.
r.on(RoomEvent.LocalTrackPublished, (publication) => {
if (publication.source === Track.Source.ScreenShare) {
setIsScreenSharing(true);
}
});
r.on(RoomEvent.LocalTrackUnpublished, (publication) => {
if (publication.source === Track.Source.ScreenShare) {
setIsScreenSharing(false);
}
});
await r.connect(url, token);
if (e2eeBundle) {
try {
await r.setE2EEEnabled(true);
setIsE2EEActive(true);
} catch (e2eErr: unknown) {
console.error('setE2EEEnabled failed — falling back to clear-media', e2eErr);
setIsE2EEActive(false);
}
} else {
setIsE2EEActive(false);
}
try {
await r.localParticipant.setMicrophoneEnabled(true, {
echoCancellation: aParams.echoCancellation,
noiseSuppression: aParams.noiseSuppression,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
});
} catch (micErr: unknown) {
console.error('setMicrophoneEnabled failed', micErr);
}
if (mediaKind === 'video') {
try {
await r.localParticipant.setCameraEnabled(true);
} catch (camErr: unknown) {
console.error('setCameraEnabled failed', camErr);
}
}
setIsMuted(false);
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
// If peers are already in the room, transition immediately — otherwise
// stay in outgoing/connecting and wait for ParticipantConnected.
// Runs BEFORE presence setup so a slow/failing presence channel can't
// leave the acceptor stuck in "connecting…".
markConnectedIfReady(r, conversationId, mediaKind, callId);
// Advertise "we're in this call" on the presence channel so other
// conversation members can show a Join-button even after leaving.
// Supabase realtime dedupes channels by topic: if any component (e.g.
// an observer banner) already subscribed to this topic, we get the
// existing channel back. In that case .subscribe() would throw — so we
// branch on channel.state and just track().
if (myId) {
const pres = supabase.channel('call-presence:' + conversationId, {
config: { presence: { key: myId, enabled: true } },
});
presenceChannelRef.current = pres;
const track = () => {
void pres.track({ userId: myId, joinedAt: new Date().toISOString() });
};
if (pres.state === 'joined') {
track();
} else if (pres.state === 'closed') {
pres.subscribe((status) => {
if (status === 'SUBSCRIBED') track();
});
} else {
// 'joining' — wait briefly for join to complete before tracking.
const waitId = window.setInterval(() => {
if (pres.state === 'joined') {
window.clearInterval(waitId);
track();
}
}, 100);
window.setTimeout(() => window.clearInterval(waitId), 5000);
}
}
},
[
markConnectedIfReady,
clearRingTimer,
clearSoloTimer,
emitCallEvent,
disconnectRoom,
myId,
],
);
// --- Public actions ----------------------------------------------------
const startCall = useCallback(
async (conversationId: string, mediaKind: CallKind = 'audio') => {
if (!myId) return;
if (stateRef.current.kind !== 'idle') return;
const callId = newCallId();
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({
kind: 'outgoing',
callId,
conversationId,
mediaKind,
ringingSince: new Date().toISOString(),
});
const peers = peerUserIdsFor(conversationId);
pendingPeersRef.current = new Set(peers);
await Promise.all(
peers.map((to) =>
sendSignal(to, {
type: 'invite',
callId,
conversationId,
fromUserId: myId,
kind: mediaKind,
sentAt: new Date().toISOString(),
}),
),
);
// Caller joins the room immediately so acceptors hop straight into a
// live room. Transition to 'connected' happens in ParticipantConnected.
try {
await joinRoom(conversationId, mediaKind, callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
return;
}
clearRingTimer();
ringTimerRef.current = window.setTimeout(() => {
void (async () => {
// Only trigger if still alone (nobody joined the room yet).
const cur = stateRef.current;
if (cur.kind !== 'outgoing' || cur.callId !== callId) return;
const peers2 = peerUserIdsFor(conversationId);
await Promise.all(
peers2.map((to) =>
sendSignal(to, { type: 'cancel', callId, byUserId: myId }),
),
);
void emitCallEvent(conversationId, 'missed', mediaKind, 0);
await disconnectRoom();
setState({ kind: 'idle' });
})();
}, RING_TIMEOUT_MS);
},
[myId, peerUserIdsFor, sendSignal, clearRingTimer, emitCallEvent, joinRoom, disconnectRoom],
);
// Re-join an already-running call without ringing peers.
const joinActiveCall = useCallback(
async (conversationId: string, mediaKind: CallKind = 'audio') => {
if (!myId) return;
if (stateRef.current.kind !== 'idle') return;
const callId = newCallId();
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({ kind: 'connecting', callId, conversationId, mediaKind });
try {
await joinRoom(conversationId, mediaKind, callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
}
},
[myId, joinRoom, disconnectRoom],
);
const acceptIncoming = useCallback(async () => {
const s = stateRef.current;
if (s.kind !== 'incoming' || !myId) return;
const { callId, conversationId, mediaKind } = s;
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({ kind: 'connecting', callId, conversationId, mediaKind });
try {
await joinRoom(conversationId, mediaKind, callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
}
}, [myId, joinRoom, disconnectRoom]);
const rejectIncoming = useCallback(() => {
const s = stateRef.current;
if (s.kind !== 'incoming' || !myId) return;
void sendSignal(s.fromUserId, { type: 'reject', callId: s.callId, byUserId: myId });
setState({ kind: 'idle' });
}, [myId, sendSignal]);
const hangup = useCallback(async () => {
const s = stateRef.current;
clearRingTimer();
clearSoloTimer();
if (s.kind === 'outgoing' && myId) {
// Caller cancelled before anyone picked up — dismiss other sides' rings.
const peers = peerUserIdsFor(s.conversationId);
void Promise.all(
peers.map((to) =>
sendSignal(to, { type: 'cancel', callId: s.callId, byUserId: myId }),
),
);
if (!everConnectedRef.current) {
void emitCallEvent(s.conversationId, 'missed', s.mediaKind, 0);
}
}
let nextLastCallId: string | null = null;
if (s.kind === 'connected') {
const r = roomRef.current;
const amLastOut = !r || r.remoteParticipants.size === 0;
if (amLastOut) {
const durationSec = Math.max(
0,
Math.floor((Date.now() - new Date(s.startedAt).getTime()) / 1000),
);
void emitCallEvent(s.conversationId, 'ended', s.mediaKind, durationSec);
} else {
// Peers still in room — keep sidebar rejoin affordance.
nextLastCallId = s.conversationId;
}
}
// Await disconnect BEFORE dropping state so the tracker presence channel
// is removed before any RejoinCallBar observer tries to subscribe to the
// same topic (Supabase realtime dedupes channels by topic).
await disconnectRoom();
void playEndBeep();
setLastCallConversationId(nextLastCallId);
setState({ kind: 'idle' });
}, [
myId,
peerUserIdsFor,
sendSignal,
disconnectRoom,
clearRingTimer,
clearSoloTimer,
emitCallEvent,
]);
const dismissLastCall = useCallback(() => {
setLastCallConversationId(null);
}, []);
const toggleMute = useCallback(() => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const shouldEnable = !lp.isMicrophoneEnabled;
void lp.setMicrophoneEnabled(shouldEnable).then(() => {
setIsMuted(!shouldEnable);
});
}, []);
const toggleScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const nextOn = !lp.isScreenShareEnabled;
try {
const ssParams = getPresetParams(getScreenShareSettings().preset);
await lp.setScreenShareEnabled(nextOn, {
audio: false,
// Omitting `resolution` lets the browser return native source size —
// best possible input quality. Fixed presets pass explicit dims so
// the encoder has a predictable target.
...(ssParams.dims
? {
resolution: {
width: ssParams.dims.width,
height: ssParams.dims.height,
frameRate: ssParams.framerate,
},
}
: {}),
contentHint: 'detail',
});
setIsScreenSharing(nextOn);
} catch (err: unknown) {
console.error('setScreenShareEnabled failed', err);
// User cancelled or permission denied — leave state as-is.
}
}, []);
// --- Push-to-talk ------------------------------------------------------
// While PTT is active + we're in a connected call, the mic is held off
// except while the configured key is pressed. Under Tauri we also register
// the key as an OS-level global shortcut so PTT keeps working while the
// user is focused on another window (Discord-style). The window listener
// stays as a fallback for the web build and for the case where the global
// shortcut registration fails (collision with another app).
useEffect(() => {
if (state.kind !== 'connected') return;
let settings = getPttSettings();
let activeKeyDown = false;
let globalRegisteredFor: string | null = null;
const setMic = (on: boolean) => {
const r = roomRef.current;
if (!r) return;
void r.localParticipant.setMicrophoneEnabled(on).then(() => setIsMuted(!on));
};
const pressPtt = () => {
if (activeKeyDown) return;
activeKeyDown = true;
setMic(true);
};
const releasePtt = () => {
if (!activeKeyDown) return;
activeKeyDown = false;
setMic(false);
};
const isTypingTarget = (el: EventTarget | null): boolean => {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
if (el.isContentEditable) return true;
return false;
};
const onKeyDown = (e: KeyboardEvent) => {
if (!settings.enabled) return;
if (e.code !== settings.key) return;
if (isTypingTarget(e.target)) return;
if (e.repeat) return;
pressPtt();
};
const onKeyUp = (e: KeyboardEvent) => {
if (!settings.enabled) return;
if (e.code !== settings.key) return;
releasePtt();
};
const ensureGlobalShortcut = () => {
if (!isTauriRuntime()) return;
if (!settings.enabled) {
if (globalRegisteredFor) {
const key = globalRegisteredFor;
globalRegisteredFor = null;
void unregisterPttShortcut(key);
}
return;
}
if (globalRegisteredFor === settings.key) return;
// Hotkey changed — unregister old, register new.
const oldKey = globalRegisteredFor;
globalRegisteredFor = settings.key;
if (oldKey) void unregisterPttShortcut(oldKey);
void registerPttShortcut(settings.key, pressPtt, releasePtt);
};
const applyPttState = () => {
if (settings.enabled) {
activeKeyDown = false;
setMic(false);
} else {
setMic(true);
}
ensureGlobalShortcut();
};
applyPttState();
const unsubSettings = subscribePttSettings((next) => {
settings = next;
applyPttState();
});
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
unsubSettings();
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
if (globalRegisteredFor) {
void unregisterPttShortcut(globalRegisteredFor);
globalRegisteredFor = null;
}
};
}, [state.kind]);
// --- Outgoing-channel: listen for accept/reject on our own invite ------
// and incoming invites from peers.
useEffect(() => {
if (!myId) return;
const me: string = myId; // narrowed capture for the inner closure
const topic = signalTopic(me);
const channel = supabase
.channel(topic, { config: { broadcast: { self: false } } })
.on('broadcast', { event: 'signal' }, (msg) => {
const payload = msg.payload as CallSignal;
handleSignal(payload);
});
signalChannelRef.current = channel;
void channel.subscribe();
return () => {
signalChannelRef.current = null;
void supabase.removeChannel(channel);
};
function handleSignal(p: CallSignal) {
const cur = stateRef.current;
switch (p.type) {
case 'invite': {
if (cur.kind !== 'idle') {
// Busy — auto-reject.
void sendSignal(p.fromUserId, {
type: 'reject',
callId: p.callId,
byUserId: me,
});
return;
}
setState({
kind: 'incoming',
callId: p.callId,
conversationId: p.conversationId,
fromUserId: p.fromUserId,
mediaKind: p.kind,
});
const conv = conversationsRef.current.find((c) => c.id === p.conversationId) ?? null;
const callerName =
conv?.members.find((m) => m.userId === p.fromUserId)?.profile?.displayName ??
conv?.peer?.displayName ??
'…';
const isGroup = conv?.type === 'group';
void notify({
title: isGroup
? (conv?.name ?? 'Gruppenanruf')
: 'Eingehender Anruf',
body: isGroup
? callerName + ' ruft die Gruppe'
: callerName + ' ruft dich an',
});
break;
}
case 'accept':
case 'end': {
// No-op. Participant join/leave is tracked via LiveKit events so
// calls stay active while at least one person remains in the room.
break;
}
case 'reject': {
// Only relevant while I'm still the outgoing-caller who hasn't
// reached the room. Once someone joined (state = connected), a late
// reject from another ringing peer doesn't end the call.
if (
cur.kind !== 'outgoing' ||
cur.callId !== p.callId ||
everConnectedRef.current
) {
break;
}
// Remove the rejecter from the pending set. For group calls, keep
// ringing as long as at least one peer hasn't answered — only the
// last reject collapses the call to `declined`.
pendingPeersRef.current.delete(p.byUserId);
if (pendingPeersRef.current.size > 0) break;
clearRingTimer();
clearSoloTimer();
void emitCallEvent(cur.conversationId, 'declined', cur.mediaKind, 0);
void disconnectRoom();
setState({ kind: 'idle' });
break;
}
case 'cancel': {
// Caller cancelled before anyone picked up — receiver dismisses the
// incoming toast. Only hits when we're still in 'incoming' state.
if (cur.kind === 'incoming' && cur.callId === p.callId) {
const conv = conversationsRef.current.find((c) => c.id === cur.conversationId) ?? null;
const callerName =
conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
conv?.peer?.displayName ??
'…';
void notify({
title: 'Verpasster Anruf',
body: callerName + ' hat aufgelegt',
});
setState({ kind: 'idle' });
}
break;
}
}
}
}, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]);
const setCallMode = useCallback((mode: CallMode) => {
setCallModeState(mode);
}, []);
const setFocusedId = useCallback((id: string | null) => {
setFocusedIdState(id);
}, []);
// Reset UI call-mode state when the call leaves any active phase so the next
// call starts fresh at grid/unfocused.
useEffect(() => {
if (state.kind === 'idle' || state.kind === 'error') {
setCallModeState('grid');
setFocusedIdState(null);
}
}, [state.kind]);
// Global Esc: drop out of fullscreen cinema back to grid while in an active
// call. Doesn't hangup and doesn't fire when other dialogs would consume it.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (callMode !== 'fullscreen') return;
if (state.kind !== 'connected' && state.kind !== 'connecting') return;
setCallModeState('grid');
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [callMode, state.kind]);
const value = useMemo<CallContextValue>(
() => ({
state,
room,
remoteParticipants,
isMuted,
isE2EEActive,
isScreenSharing,
remoteScreenShares,
lastCallConversationId,
callMode,
focusedId,
startCall,
joinActiveCall,
acceptIncoming,
rejectIncoming,
hangup,
toggleMute,
toggleScreenShare,
dismissLastCall,
setCallMode,
setFocusedId,
}),
[
state,
room,
remoteParticipants,
isMuted,
isE2EEActive,
isScreenSharing,
remoteScreenShares,
lastCallConversationId,
callMode,
focusedId,
startCall,
joinActiveCall,
acceptIncoming,
rejectIncoming,
hangup,
toggleMute,
toggleScreenShare,
dismissLastCall,
setCallMode,
setFocusedId,
],
);
return <CallContext.Provider value={value}>{children}</CallContext.Provider>;
}
export function useCall(): CallContextValue {
const ctx = useContext(CallContext);
if (!ctx) throw new Error('useCall must be used inside <CallProvider>');
return ctx;
}
function attachTrack(
track: RemoteTrack,
_publication: RemoteTrackPublication,
_participant: RemoteParticipant,
): void {
if (track.kind === Track.Kind.Audio) {
const audio = track.attach();
if (audio instanceof HTMLAudioElement) {
audio.autoplay = true;
audio.setAttribute('playsinline', 'true');
audio.setAttribute('data-livekit-track', track.sid ?? '');
document.body.appendChild(audio);
}
}
// Video is handled later in M2.6/M3 by a dedicated <video> element.
}
function detachTrack(
track: RemoteTrack,
_publication: RemoteTrackPublication,
_participant: RemoteParticipant,
): void {
if (track.kind === Track.Kind.Audio) {
const els = track.detach();
for (const el of els) {
el.remove();
}
}
}