4db65993d5
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
1084 lines
36 KiB
TypeScript
1084 lines
36 KiB
TypeScript
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();
|
||
}
|
||
}
|
||
}
|
||
|