diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 7300b20..ed2dad0 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -1,7 +1,7 @@ import { Button } from "@tensamin/ui"; import { ArrowLeft, House, Phone, Settings, User } from "lucide-react"; import { useLocation, useNavigate, useSearch } from "@tanstack/react-router"; -import { useCall } from "@tensamin/call/context"; +import { joinCall, useCall } from "@tensamin/call/store"; import Wrapper from "@tensamin/user/wrapper"; import { Skeleton } from "@tensamin/ui"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@tensamin/ui"; @@ -13,7 +13,7 @@ import { WindowControls as Controls } from "@tensamin/ui"; export default function Navbar({ forMobile }: { forMobile: boolean }) { const navigate = useNavigate(); - const { joinCall, state } = useCall(); + const callState = useCall((state) => state.state); const { contacts } = useTTP(); const { pathname } = useLocation(); @@ -73,9 +73,9 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) { <> {currentCalls.length === 0 ? ( ) : currentCalls.length === 1 ? ( ); } diff --git a/packages/call/src/components/buttons/leave.tsx b/packages/call/src/components/buttons/leave.tsx new file mode 100644 index 0000000..8165fca --- /dev/null +++ b/packages/call/src/components/buttons/leave.tsx @@ -0,0 +1,21 @@ +import { LeaveIcon } from "@livekit/components-react"; +import { Button } from "@tensamin/ui"; +import { disconnect } from "../../store"; + +export default function LeaveButton({ + className, + iconSize, +}: { + className?: string; + iconSize?: number; +}) { + return ( + + ); +} diff --git a/packages/call/src/components/buttons/mute.tsx b/packages/call/src/components/buttons/mute.tsx index da4b2c4..75125fa 100644 --- a/packages/call/src/components/buttons/mute.tsx +++ b/packages/call/src/components/buttons/mute.tsx @@ -1,17 +1,27 @@ import { Button } from "@tensamin/ui"; -import { useCall } from "../../context"; +import { toggleMute, useCall } from "../../store"; import { Mic, MicOff } from "lucide-react"; -export default function MuteButton({ className }: { className?: string }) { - const { room } = useCall(); - const micEnabled = room.localParticipant.isMicrophoneEnabled; +export default function MuteButton({ + className, + iconSize, +}: { + className?: string; + iconSize?: number; +}) { + const micEnabled = useCall((state) => state.micEnabled); return ( ); } diff --git a/packages/call/src/components/buttons/screenshare.tsx b/packages/call/src/components/buttons/screenshare.tsx index a346375..8c4f5e1 100644 --- a/packages/call/src/components/buttons/screenshare.tsx +++ b/packages/call/src/components/buttons/screenshare.tsx @@ -1,39 +1,47 @@ import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui"; import { MonitorDot, ScreenShare } from "lucide-react"; -import { useCall } from "../../context"; +import { setScreenShareEnabled, useCall } from "../../store"; export default function ScreenshareButton({ className, + iconSize, }: { className?: string; + iconSize?: number; }) { - const { room } = useCall(); - - const isScreensharing = room.localParticipant.isScreenShareEnabled; + const isScreensharing = useCall((state) => state.screenShareEnabled); return ( - {isScreensharing ? : } + {isScreensharing ? ( + + ) : ( + + )} } /> diff --git a/packages/call/src/components/sidebarBox.tsx b/packages/call/src/components/sidebarBox.tsx index 41f90a6..f02394f 100644 --- a/packages/call/src/components/sidebarBox.tsx +++ b/packages/call/src/components/sidebarBox.tsx @@ -1,5 +1,5 @@ import { Lock, LockOpen } from "lucide-react"; -import { useCall } from "../context"; +import { openCallPage, useCall } from "../store"; import { Button, Card, @@ -10,16 +10,16 @@ import { TooltipTrigger, useIsMobile, } from "@tensamin/ui"; -import { LeaveIcon } from "@livekit/components-react"; import ScreenshareButton from "./buttons/screenshare"; import MuteButton from "./buttons/mute"; import DeafButton from "./buttons/deaf"; import { Room, Track } from "livekit-client"; import { useEffect, useState } from "react"; import { ResponsiveContainer, AreaChart, Area } from "recharts"; +import LeaveButton from "./buttons/leave"; export default function SidebarBox() { - const { state, disconnect } = useCall(); + const state = useCall((store) => store.state); const isMobile = useIsMobile(); return state === "closed" ? null : ( @@ -32,14 +32,7 @@ export default function SidebarBox() { - + @@ -47,9 +40,9 @@ export default function SidebarBox() { } function ConnectionBar() { - const { state, room, openCallPage, callId } = useCall(); - const encrypted = - room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted; + const state = useCall((store) => store.state); + const isEncrypted = useCall((store) => store.isEncrypted); + const callId = useCall((store) => store.callId); return ( @@ -59,7 +52,7 @@ function ConnectionBar() { onClick={() => openCallPage(callId || "")} size="lg" variant={ - state === "open" && encrypted ? "subtleDefault" : "destructive" + state === "open" && isEncrypted ? "subtleDefault" : "destructive" } className="flex justify-between items-center" > @@ -71,7 +64,7 @@ function ConnectionBar() { - {encrypted ? ( + {isEncrypted ? ( ) : ( @@ -85,11 +78,9 @@ function ConnectionBar() { } export function TinyPingGraph() { - const { room } = useCall(); + const room = useCall((store) => store.room); - const [mapData, setMapData] = useState>( - () => new Map([[Date.now(), 0]]), - ); + const [mapData, setMapData] = useState>(() => new Map()); const data = Array.from(mapData, ([time, ping]) => ({ time, ping })); @@ -102,7 +93,9 @@ export function TinyPingGraph() { setMapData((prev) => { const next = new Map(prev); - next.set(now, ping || 0); + if (ping != null && ping > 0) { + next.set(now, ping); + } for (const time of next.keys()) { if (time < cutoff) next.delete(time); @@ -128,48 +121,52 @@ export function TinyPingGraph() { "linear-gradient(to right, transparent 0%, var(--primary) 15%, var(--primary) 85%, transparent 100%)", }} > - - - - - - - - - + {data.length > 1 && ( + + + + + + + + + - - - + + + + )} } /> - {data.at(-1)?.ping} ms + + {data.length > 0 ? `${data.at(-1)?.ping} ms` : "Measuring ping..."} + ); } @@ -197,5 +194,11 @@ async function getPing(room: Room): Promise { } }); - return Math.round(bestRtt || 0); + if (bestRtt == null || bestRtt <= 0) return; + + const roundedRtt = Math.round(bestRtt); + + if (roundedRtt <= 0) return; + + return roundedRtt; } diff --git a/packages/call/src/components/top.tsx b/packages/call/src/components/top.tsx new file mode 100644 index 0000000..ae1786f --- /dev/null +++ b/packages/call/src/components/top.tsx @@ -0,0 +1,20 @@ +import { useCall } from "../store"; + +export default function TopBar() { + const room = useCall((state) => state.room); + + const users = new Array(room.remoteParticipants.size).fill(0).map((_, i) => { + const participant = Array.from(room.remoteParticipants.values())[i]; + return participant.identity; + }); + + return ( +
+
+ {users.map((user) => ( +
{user}
+ ))} +
+
+ ); +} diff --git a/packages/call/src/context.tsx b/packages/call/src/context.tsx deleted file mode 100644 index 031ffca..0000000 --- a/packages/call/src/context.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { createContext, useContext, useEffect, useMemo, useState } from "react"; -import { log, toast } from "@tensamin/shared/log"; -import { useLocation, useNavigate } from "@tanstack/react-router"; -import { useTTP } from "@tensamin/ttp"; -import z from "zod"; -import { ttp } from "@tensamin/shared/data"; - -import { LiveKitRoom, useLocalParticipant } from "@livekit/components-react"; -import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter"; -import { ExternalE2EEKeyProvider, LocalAudioTrack, Room } from "livekit-client"; -import { useCrypto } from "@tensamin/crypto/context"; -import { useStorage } from "@tensamin/storage/context"; -import { useUser } from "@tensamin/user/context"; - -export const context = createContext(undefined); - -export default function Provider(props: { children: React.ReactNode }) { - const navigate = useNavigate(); - const { send } = useTTP(); - const { getSharedSecret, decryptText } = useCrypto(); - const { load } = useStorage(); - const { get } = useUser(); - const location = useLocation(); - const search = new URLSearchParams(location.searchStr); - - const [state, setState] = useState< - "closed" | "closing" | "connecting" | "open" | "encrypting" - >("closed"); - - const [callId, setCallId] = useState( - location.pathname.startsWith("/call") ? search.get("id") : null, - ); - const [callSecret, setCallSecret] = useState(null); - const [livekitToken, setLivekitToken] = useState(null); - - const getCallToken = async (callId: string) => { - const response = await send("call_token", { - call_id: callId, - }).catch((err) => { - log(1, "call", "red", "Failed to get call secret", err); - throw err; - }); - return response.data.call_token; - }; - - async function connect(callId: string) { - setState("encrypting"); - setCallId(callId); - setLivekitToken(await getCallToken(callId)); - log(2, "call", "purple", "Connecting to call", { callId, callSecret }); - } - - // Master reset function - function disconnect() { - setState("closing"); - setCallId(null); - setCallSecret(null); - setLivekitToken(null); - room.disconnect(); - e2eeWorker.terminate(); - } - - // Utils - async function joinCall( - userId: number, - callSecret?: string, - callId?: string, - ) { - log(2, "call", "purple", "Call creation initialised"); - if (callSecret) { - try { - const sharedSecret = await getSharedSecret( - await load("private_key"), - await get((await load("user_id")) as number).then( - (res) => res.public_key, - ), - await get(userId).then((res) => res.public_key), - ); - const decryptedSecret = await decryptText(sharedSecret, callSecret); - await keyProvider.setKey(decryptedSecret); - await room.setE2EEEnabled(true); - setCallSecret(decryptedSecret); - setState("connecting"); - } catch (err) { - log(1, "call", "red", "Failed getting call secret", err); - disconnect(); - } - } else { - const random = crypto.randomUUID(); - await keyProvider.setKey(random); - await room.setE2EEEnabled(true); - setCallSecret(random); - setState("connecting"); - } - try { - // check if user is already in call - - // connect to call - const finalId = callId || crypto.randomUUID(); - connect(finalId); - setView("grid"); - openCallPage(finalId); - } catch (err) { - log(1, "call", "red", "Failed to join call [navbar level]", err); - } - } - - // Event listener for incoming calls - useEffect(() => {}, []); - - /** - * All of UI - */ - const [view, setView] = useState<"preview" | "focused" | "grid">("preview"); - const [currentCallData, setCurrentCallData] = useState | null>(null); - - const [deaf, setDeaf] = useState(false); - - const toggleDeaf = () => { - setDeaf((wasDeaf) => { - room.remoteParticipants.forEach((participant) => { - participant.setVolume(wasDeaf ? 100 : 0); - }); - return !wasDeaf; - }); - }; - - const openCallPage = (callId: string) => - navigate({ to: "/call", search: { id: callId } }); - - // Get current call information for preview view - useEffect(() => { - if (view !== "preview" || !callId) return; - - send("call_data", { call_id: callId }) - .then((data) => { - setCurrentCallData(data.data); - }) - .catch((err) => { - log(1, "Call", "red", "Failed to get call data", { - callId, - error: err, - }); - setCurrentCallData({ - users: [], - }); - }); - }, [callId, send, view]); - - // Room & Encryption - const [keyProvider] = useState(() => new ExternalE2EEKeyProvider()); - const [e2eeWorker] = useState( - () => new Worker(new URL("livekit-client/e2ee-worker", import.meta.url)), - ); - const [room] = useState( - () => - new Room({ - dynacast: true, - adaptiveStream: true, - encryption: { - keyProvider, - worker: e2eeWorker, - }, - }), - ); - - return ( - - { - setState("open"); - log(2, "call", "purple", "Connected to call", { - callId, - callSecret, - }); - }} - onDisconnected={() => { - setState("closed"); - log(2, "call", "purple", "Disconnected from call", { - callId, - callSecret, - }); - }} - onError={(error) => { - log(1, "call", "red", error.message); - toast("error", error.message); - }} - onMediaDeviceFailure={(failure, kind) => { - log(1, "call", "red", "Media device failure", { failure, kind }); - toast("error", "Media device failure. See console for details."); - }} - onEncryptionError={(error) => { - log(1, "call", "red", "Encryption error", { error }); - toast( - "error", - "Error during call encryption. See console for details.", - ); - }} - audio={true} - > - - {props.children} - - - ); -} - -function NoiseFilter() { - const { microphoneTrack } = useLocalParticipant(); - const noiseFilter = useMemo( - () => - new DeepFilterNoiseFilterProcessor({ - enabled: true, - enableNoiseReduction: true, - noiseReductionLevel: 80, - sampleRate: 48000, - assetConfig: { - cdnUrl: "/assets", - }, - }), - [], - ); - - useEffect(() => { - const track = microphoneTrack?.track; - if (!(track instanceof LocalAudioTrack) || track.getProcessor()) return; - - track.setProcessor(noiseFilter).catch((err) => { - log(1, "call", "red", "Failed to enable noise filter", err); - }); - }, [microphoneTrack, noiseFilter]); - - return null; -} - -type contextType = { - state: "closed" | "closing" | "connecting" | "open" | "encrypting"; - view: "preview" | "focused" | "grid"; - setView: (view: "preview" | "focused" | "grid") => void; - disconnect: () => void; - joinCall: (userId: number, callSecret?: string, callId?: string) => void; - currentCallData: z.infer | null; - room: Room; - openCallPage: (callId: string) => Promise; - callId: string | null; - deaf: boolean; - toggleDeaf: () => void; -}; - -export function useCall(): contextType { - const ctx = useContext(context); - if (!ctx) { - throw new Error("useCall must be used within a CallProvider"); - } - return ctx; -} diff --git a/packages/call/src/screen.tsx b/packages/call/src/screen.tsx index 59fb3ee..49c6a11 100644 --- a/packages/call/src/screen.tsx +++ b/packages/call/src/screen.tsx @@ -1,4 +1,4 @@ -import { useCall } from "./context"; +import { useCall } from "./store"; import MainLayout from "./views/main/layout"; import MainGrid from "./views/main/grid"; @@ -6,7 +6,7 @@ import MainFocused from "./views/main/focused"; import Preview from "./views/preview"; export default function Screen() { - const { view } = useCall(); + const view = useCall((state) => state.view); return view === "preview" ? ( diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx new file mode 100644 index 0000000..6378ddf --- /dev/null +++ b/packages/call/src/store.tsx @@ -0,0 +1,464 @@ +import { useEffect, useMemo, useRef } from "react"; +import { create } from "zustand"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useTTP } from "@tensamin/ttp"; +import { log, toast } from "@tensamin/shared/log"; +import { ttp } from "@tensamin/shared/data"; +import { useCrypto } from "@tensamin/crypto/context"; +import { useStorage } from "@tensamin/storage/context"; +import { useUser } from "@tensamin/user/context"; +import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter"; +import { + ExternalE2EEKeyProvider, + LocalAudioTrack, + Room, + RoomEvent, + Track, +} from "livekit-client"; +import z from "zod"; + +type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting"; +type CallView = "preview" | "focused" | "grid"; +type CurrentCallData = z.infer | null; + +type NavigateFn = (options: { + to: string; + search?: Record; +}) => Promise; +type SendFn = ( + type: string, + data: Record, +) => Promise<{ data: unknown }>; +type GetSharedSecretFn = ( + privateKey: unknown, + ownPublicKey: string, + remotePublicKey: string, +) => Promise; +type DecryptTextFn = (sharedSecret: string, text: string) => Promise; +type LoadFn = (key: string) => Promise; +type GetUserFn = (userId: number) => Promise<{ public_key: string }>; + +type Runtime = { + navigate: NavigateFn; + send: SendFn; + getSharedSecret: GetSharedSecretFn; + decryptText: DecryptTextFn; + load: LoadFn; + getUser: GetUserFn; +}; + +type CallStore = { + state: CallState; + view: CallView; + callId: string | null; + callSecret: string | null; + livekitToken: string | null; + currentCallData: CurrentCallData; + deaf: boolean; + micEnabled: boolean; + screenShareEnabled: boolean; + isEncrypted: boolean; + room: Room; + keyProvider: ExternalE2EEKeyProvider; + e2eeWorker: Worker; + runtime: Runtime | null; +}; + +const keyProvider = new ExternalE2EEKeyProvider(); +const e2eeWorker = new Worker( + new URL("livekit-client/e2ee-worker", import.meta.url), +); +const room = new Room({ + dynacast: true, + adaptiveStream: true, + encryption: { + keyProvider, + worker: e2eeWorker, + }, +}); + +function requireRuntime(runtime: Runtime | null): Runtime { + if (!runtime) { + throw new Error("Call store is not initialized"); + } + + return runtime; +} + +export function syncParticipantState() { + const { room } = useCall.getState(); + + useCall.setState({ + micEnabled: room.localParticipant.isMicrophoneEnabled, + screenShareEnabled: room.localParticipant.isScreenShareEnabled, + isEncrypted: + room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted, + }); +} + +export function setCallRuntime(runtime: Runtime) { + useCall.setState({ runtime }); +} + +export function setCallState(state: CallState) { + useCall.setState({ state }); +} + +export function setCallView(view: CallView) { + useCall.setState({ view }); +} + +export function setCallId(callId: string | null) { + useCall.setState({ callId }); +} + +export function setCurrentCallData(currentCallData: CurrentCallData) { + useCall.setState({ currentCallData }); +} + +export async function openCallPage(callId: string) { + await requireRuntime(useCall.getState().runtime).navigate({ + to: "/call", + search: { id: callId }, + }); +} + +export async function getCallToken(callId: string): Promise { + const response = await requireRuntime(useCall.getState().runtime) + .send("call_token", { + call_id: callId, + }) + .catch((err) => { + log(1, "call", "red", "Failed to get call secret", err); + throw err; + }); + + const data = response.data as { call_token: string }; + return data.call_token; +} + +export async function connect(callId: string) { + const token = await getCallToken(callId); + + useCall.setState({ + state: "connecting", + callId, + livekitToken: token, + }); + + log(2, "call", "purple", "Connecting to call", { + callId, + callSecret: useCall.getState().callSecret, + }); + + await room.connect("wss://call.tensamin.net", token).catch((error) => { + useCall.setState({ state: "closed", livekitToken: null }); + log(1, "call", "red", "Failed to connect to room", error); + toast( + "error", + error instanceof Error ? error.message : "Failed to connect to call.", + ); + throw error; + }); + + await room.localParticipant.setMicrophoneEnabled(true).catch((error) => { + log(1, "call", "red", "Failed to enable microphone", error); + toast("error", "Failed to enable microphone."); + throw error; + }); + + syncParticipantState(); +} + +export function disconnect() { + useCall.setState({ + state: "closing", + callId: null, + callSecret: null, + livekitToken: null, + currentCallData: null, + deaf: false, + view: "preview", + }); + + room.remoteParticipants.forEach((participant) => { + participant.setVolume(100); + }); + + room.disconnect(); + syncParticipantState(); +} + +export async function joinCall( + userId: number, + callSecret?: string, + existingCallId?: string, +) { + const runtime = requireRuntime(useCall.getState().runtime); + + log(2, "call", "purple", "Call creation initialised"); + useCall.setState({ state: "encrypting" }); + + if (callSecret) { + try { + const sharedSecret = await runtime.getSharedSecret( + await runtime.load("private_key"), + await runtime + .getUser((await runtime.load("user_id")) as number) + .then((res) => res.public_key), + await runtime.getUser(userId).then((res) => res.public_key), + ); + const decryptedSecret = await runtime.decryptText( + sharedSecret, + callSecret, + ); + + await keyProvider.setKey(decryptedSecret); + await room.setE2EEEnabled(true); + useCall.setState({ callSecret: decryptedSecret }); + } catch (err) { + log(1, "call", "red", "Failed getting call secret", err); + disconnect(); + return; + } + } else { + const random = crypto.randomUUID(); + + await keyProvider.setKey(random); + await room.setE2EEEnabled(true); + useCall.setState({ callSecret: random }); + } + + const finalId = existingCallId || crypto.randomUUID(); + + try { + useCall.setState({ view: "grid", callId: finalId }); + await openCallPage(finalId); + await connect(finalId); + } catch (err) { + log(1, "call", "red", "Failed to join call [navbar level]", err); + } +} + +export function toggleDeaf() { + const nextDeaf = !useCall.getState().deaf; + + room.remoteParticipants.forEach((participant) => { + participant.setVolume(nextDeaf ? 0 : 100); + }); + + if (nextDeaf && room.localParticipant.isMicrophoneEnabled) { + toggleMute(); + } + + useCall.setState({ deaf: nextDeaf }); +} + +export async function toggleMute() { + const micEnabled = useCall.getState().micEnabled; + + if (!micEnabled && useCall.getState().deaf) { + toggleDeaf(); + } + + await room.localParticipant.setMicrophoneEnabled(!micEnabled); + syncParticipantState(); +} + +export async function setScreenShareEnabled(enabled: boolean) { + await room.localParticipant.setScreenShareEnabled(enabled); + syncParticipantState(); +} + +export function resetCallState() { + useCall.setState({ + state: "closed", + view: "preview", + callId: null, + callSecret: null, + livekitToken: null, + currentCallData: null, + deaf: false, + }); + + syncParticipantState(); +} + +async function ensureNoiseFilter( + noiseFilter: DeepFilterNoiseFilterProcessor, +): Promise { + const microphoneTrack = room.localParticipant.getTrackPublication( + Track.Source.Microphone, + )?.track; + + if ( + !(microphoneTrack instanceof LocalAudioTrack) || + microphoneTrack.getProcessor() + ) { + return; + } + + await microphoneTrack.setProcessor(noiseFilter).catch((err) => { + log(1, "call", "red", "Failed to enable noise filter", err); + }); +} + +export const useCall = create(() => ({ + state: "closed", + view: "preview", + callId: null, + callSecret: null, + livekitToken: null, + currentCallData: null, + deaf: false, + micEnabled: room.localParticipant.isMicrophoneEnabled, + screenShareEnabled: room.localParticipant.isScreenShareEnabled, + isEncrypted: + room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted, + room, + keyProvider, + e2eeWorker, + runtime: null, +})); + +export function useInitializeCall() { + const navigate = useNavigate(); + const location = useLocation(); + const { send } = useTTP(); + const { getSharedSecret, decryptText } = useCrypto(); + const { load } = useStorage(); + const { get } = useUser(); + + const callId = useCall((state) => state.callId); + const view = useCall((state) => state.view); + + const listenersRegistered = useRef(false); + const noiseFilter = useMemo( + () => + new DeepFilterNoiseFilterProcessor({ + enabled: true, + enableNoiseReduction: true, + noiseReductionLevel: 80, + sampleRate: 48000, + assetConfig: { + cdnUrl: "/assets", + }, + }), + [], + ); + + useEffect(() => { + setCallRuntime({ + navigate, + send: send as SendFn, + getSharedSecret: getSharedSecret as GetSharedSecretFn, + decryptText: decryptText as DecryptTextFn, + load: load as LoadFn, + getUser: get as GetUserFn, + }); + }, [decryptText, get, getSharedSecret, load, navigate, send]); + + useEffect(() => { + if (!location.pathname.startsWith("/call")) { + return; + } + + const search = new URLSearchParams(location.searchStr); + const routeCallId = search.get("id"); + + if (routeCallId) { + setCallId(routeCallId); + } + }, [location.pathname, location.searchStr]); + + useEffect(() => { + if (listenersRegistered.current) { + return; + } + + listenersRegistered.current = true; + + const onConnected = () => { + useCall.setState({ state: "open" }); + syncParticipantState(); + log(2, "call", "purple", "Connected to call", { + callId: useCall.getState().callId, + callSecret: useCall.getState().callSecret, + }); + }; + + const onDisconnected = () => { + useCall.setState({ state: "closed" }); + syncParticipantState(); + log(2, "call", "purple", "Disconnected from call", { + callId: useCall.getState().callId, + callSecret: useCall.getState().callSecret, + }); + }; + + const onMediaDeviceFailure = (error: Error, kind?: MediaDeviceKind) => { + log(1, "call", "red", "Media device failure", { error, kind }); + toast("error", "Media device failure. See console for details."); + }; + + const onEncryptionError = (error: Error) => { + log(1, "call", "red", "Encryption error", { error }); + toast("error", "Error during call encryption. See console for details."); + }; + + const onParticipantStateChange = () => { + syncParticipantState(); + void ensureNoiseFilter(noiseFilter); + }; + + room.on(RoomEvent.Connected, onConnected); + room.on(RoomEvent.Reconnected, onConnected); + room.on(RoomEvent.Disconnected, onDisconnected); + room.on(RoomEvent.TrackMuted, onParticipantStateChange); + room.on(RoomEvent.TrackUnmuted, onParticipantStateChange); + room.on(RoomEvent.LocalTrackPublished, onParticipantStateChange); + room.on(RoomEvent.LocalTrackUnpublished, onParticipantStateChange); + room.on(RoomEvent.MediaDevicesError, onMediaDeviceFailure); + room.on(RoomEvent.EncryptionError, onEncryptionError); + room.on(RoomEvent.ConnectionStateChanged, onParticipantStateChange); + + void ensureNoiseFilter(noiseFilter); + syncParticipantState(); + + return () => { + room.off(RoomEvent.Connected, onConnected); + room.off(RoomEvent.Reconnected, onConnected); + room.off(RoomEvent.Disconnected, onDisconnected); + room.off(RoomEvent.TrackMuted, onParticipantStateChange); + room.off(RoomEvent.TrackUnmuted, onParticipantStateChange); + room.off(RoomEvent.LocalTrackPublished, onParticipantStateChange); + room.off(RoomEvent.LocalTrackUnpublished, onParticipantStateChange); + room.off(RoomEvent.MediaDevicesError, onMediaDeviceFailure); + room.off(RoomEvent.EncryptionError, onEncryptionError); + room.off(RoomEvent.ConnectionStateChanged, onParticipantStateChange); + listenersRegistered.current = false; + room.disconnect(); + e2eeWorker.terminate(); + }; + }, [noiseFilter]); + + useEffect(() => { + if (view !== "preview" || !callId) { + return; + } + + send("call_data", { call_id: callId }) + .then((data) => { + setCurrentCallData(data.data as z.infer); + }) + .catch((err) => { + log(1, "Call", "red", "Failed to get call data", { + callId, + error: err, + }); + setCurrentCallData({ + users: [], + }); + }); + }, [callId, send, view]); +} diff --git a/packages/call/src/views/main/grid.tsx b/packages/call/src/views/main/grid.tsx index 515100a..5cd6f5e 100644 --- a/packages/call/src/views/main/grid.tsx +++ b/packages/call/src/views/main/grid.tsx @@ -1,3 +1,3 @@ export default function View() { - return
Grid
; + return
Grid
; } diff --git a/packages/call/src/views/main/layout.tsx b/packages/call/src/views/main/layout.tsx index 4eb57aa..ec339e8 100644 --- a/packages/call/src/views/main/layout.tsx +++ b/packages/call/src/views/main/layout.tsx @@ -1,8 +1,12 @@ +import Actions from "../../components/actions"; +import TopBar from "../../components/top"; + export default function Layout({ children }: { children: React.ReactNode }) { return ( -
- Layout - {children} +
+ +
{children}
+
); } diff --git a/packages/call/src/views/preview.tsx b/packages/call/src/views/preview.tsx index b9d08e8..aac80cd 100644 --- a/packages/call/src/views/preview.tsx +++ b/packages/call/src/views/preview.tsx @@ -1,7 +1,7 @@ -import { useCall } from "../context"; +import { useCall } from "../store"; export default function Preview() { - const { currentCallData } = useCall(); + const currentCallData = useCall((state) => state.currentCallData); return
Preview View {JSON.stringify(currentCallData?.users)}
; }