(feat): move call context to zustand
(feat): add actions, basic call page
This commit is contained in:
parent
d01df7c02c
commit
413764f33e
17 changed files with 673 additions and 382 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button } from "@tensamin/ui";
|
||||||
import { ArrowLeft, House, Phone, Settings, User } from "lucide-react";
|
import { ArrowLeft, House, Phone, Settings, User } from "lucide-react";
|
||||||
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
|
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 Wrapper from "@tensamin/user/wrapper";
|
||||||
import { Skeleton } from "@tensamin/ui";
|
import { Skeleton } from "@tensamin/ui";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } 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 }) {
|
export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { joinCall, state } = useCall();
|
const callState = useCall((state) => state.state);
|
||||||
const { contacts } = useTTP();
|
const { contacts } = useTTP();
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
@ -73,9 +73,9 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
<>
|
<>
|
||||||
{currentCalls.length === 0 ? (
|
{currentCalls.length === 0 ? (
|
||||||
<Button
|
<Button
|
||||||
disabled={state !== "closed"}
|
disabled={callState !== "closed"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
joinCall(id);
|
void joinCall(id);
|
||||||
}}
|
}}
|
||||||
className="w-9 h-9 aspect-square rounded-lg"
|
className="w-9 h-9 aspect-square rounded-lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -84,9 +84,9 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
</Button>
|
</Button>
|
||||||
) : currentCalls.length === 1 ? (
|
) : currentCalls.length === 1 ? (
|
||||||
<Button
|
<Button
|
||||||
disabled={state !== "closed"}
|
disabled={callState !== "closed"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
joinCall(id, currentCalls[0].secret, currentCalls[0].id);
|
void joinCall(id, currentCalls[0].secret, currentCalls[0].id);
|
||||||
}}
|
}}
|
||||||
className="w-9 h-9 aspect-square rounded-lg"
|
className="w-9 h-9 aspect-square rounded-lg"
|
||||||
>
|
>
|
||||||
|
|
@ -108,7 +108,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
value={call.id}
|
value={call.id}
|
||||||
key={call.id}
|
key={call.id}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
joinCall(id, call.secret, call.id);
|
void joinCall(id, call.secret, call.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{displayCallId(call.id)}
|
{displayCallId(call.id)}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import CallScreen from "@tensamin/call/screen";
|
||||||
import Login from "@/routes/screens/login";
|
import Login from "@/routes/screens/login";
|
||||||
|
|
||||||
import ChatContext from "@tensamin/chat/context";
|
import ChatContext from "@tensamin/chat/context";
|
||||||
import CallContext from "@tensamin/call/context";
|
import { useInitializeCall } from "@tensamin/call/store";
|
||||||
import { Provider as SocketContext } from "@tensamin/ttp";
|
import { Provider as SocketContext } from "@tensamin/ttp";
|
||||||
import UserContext from "@tensamin/user/context";
|
import UserContext from "@tensamin/user/context";
|
||||||
import DeeplinkContext from "@tensamin/tauri/deeplinkHandler";
|
import DeeplinkContext from "@tensamin/tauri/deeplinkHandler";
|
||||||
|
|
@ -142,18 +142,23 @@ function AppShell() {
|
||||||
return (
|
return (
|
||||||
<SocketContext>
|
<SocketContext>
|
||||||
<UserContext>
|
<UserContext>
|
||||||
<CallContext>
|
<CallInit />
|
||||||
<TAuthWrapper>
|
<TAuthWrapper>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</TAuthWrapper>
|
</TAuthWrapper>
|
||||||
</CallContext>
|
|
||||||
</UserContext>
|
</UserContext>
|
||||||
</SocketContext>
|
</SocketContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CallInit() {
|
||||||
|
useInitializeCall();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function Chat() {
|
function Chat() {
|
||||||
return (
|
return (
|
||||||
<ChatContext>
|
<ChatContext>
|
||||||
|
|
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -113,6 +113,7 @@
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/chat": {
|
"packages/chat": {
|
||||||
|
|
@ -1614,6 +1615,8 @@
|
||||||
|
|
||||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||||
|
|
||||||
|
"zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
|
||||||
|
|
||||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./context": "./src/context.tsx",
|
"./store": "./src/store.tsx",
|
||||||
"./screen": "./src/screen.tsx",
|
"./screen": "./src/screen.tsx",
|
||||||
"./utils": "./src/utils.ts",
|
"./utils": "./src/utils.ts",
|
||||||
"./sidebarBox": "./src/components/sidebarBox.tsx"
|
"./sidebarBox": "./src/components/sidebarBox.tsx"
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"zustand": "^5.0.8",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,20 @@
|
||||||
|
import { Card, CardContent } from "@tensamin/ui";
|
||||||
|
import MuteButton from "./buttons/mute";
|
||||||
|
import DeafButton from "./buttons/deaf";
|
||||||
|
import ScreenshareButton from "./buttons/screenshare";
|
||||||
|
import LeaveButton from "./buttons/leave";
|
||||||
|
|
||||||
export default function Actions() {
|
export default function Actions() {
|
||||||
return <div>Actions</div>;
|
return (
|
||||||
|
<div className="w-full flex justify-center py-3">
|
||||||
|
<Card className="p-1.75">
|
||||||
|
<CardContent className="p-0! flex gap-1.75">
|
||||||
|
<MuteButton className="w-14 h-10" iconSize={25} />
|
||||||
|
<DeafButton className="w-14 h-10" iconSize={25} />
|
||||||
|
<ScreenshareButton className="w-14 h-10" iconSize={25} />
|
||||||
|
<LeaveButton className="w-14 h-10" iconSize={25} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button } from "@tensamin/ui";
|
||||||
import { useCall } from "../../context";
|
import { toggleDeaf, useCall } from "../../store";
|
||||||
import { HeadphoneOff, Headphones } from "lucide-react";
|
import { HeadphoneOff, Headphones } from "lucide-react";
|
||||||
|
|
||||||
export default function DeafButton({ className }: { className?: string }) {
|
export default function DeafButton({
|
||||||
const { toggleDeaf, deaf } = useCall();
|
className,
|
||||||
|
iconSize,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
iconSize?: number;
|
||||||
|
}) {
|
||||||
|
const deaf = useCall((state) => state.deaf);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -11,7 +17,15 @@ export default function DeafButton({ className }: { className?: string }) {
|
||||||
onClick={toggleDeaf}
|
onClick={toggleDeaf}
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
{deaf ? <HeadphoneOff /> : <Headphones />}
|
{deaf ? (
|
||||||
|
<HeadphoneOff
|
||||||
|
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Headphones
|
||||||
|
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
packages/call/src/components/buttons/leave.tsx
Normal file
21
packages/call/src/components/buttons/leave.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Button
|
||||||
|
className={className}
|
||||||
|
onClick={() => disconnect()}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
<LeaveIcon style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,27 @@
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button } from "@tensamin/ui";
|
||||||
import { useCall } from "../../context";
|
import { toggleMute, useCall } from "../../store";
|
||||||
import { Mic, MicOff } from "lucide-react";
|
import { Mic, MicOff } from "lucide-react";
|
||||||
|
|
||||||
export default function MuteButton({ className }: { className?: string }) {
|
export default function MuteButton({
|
||||||
const { room } = useCall();
|
className,
|
||||||
const micEnabled = room.localParticipant.isMicrophoneEnabled;
|
iconSize,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
iconSize?: number;
|
||||||
|
}) {
|
||||||
|
const micEnabled = useCall((state) => state.micEnabled);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant={micEnabled ? "default" : "destructive"}
|
variant={micEnabled ? "default" : "destructive"}
|
||||||
className={className}
|
className={className}
|
||||||
|
onClick={() => void toggleMute()}
|
||||||
>
|
>
|
||||||
{micEnabled ? <Mic /> : <MicOff />}
|
{micEnabled ? (
|
||||||
|
<Mic style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />
|
||||||
|
) : (
|
||||||
|
<MicOff style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,47 @@
|
||||||
import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui";
|
import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui";
|
||||||
import { MonitorDot, ScreenShare } from "lucide-react";
|
import { MonitorDot, ScreenShare } from "lucide-react";
|
||||||
import { useCall } from "../../context";
|
import { setScreenShareEnabled, useCall } from "../../store";
|
||||||
|
|
||||||
export default function ScreenshareButton({
|
export default function ScreenshareButton({
|
||||||
className,
|
className,
|
||||||
|
iconSize,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
iconSize?: number;
|
||||||
}) {
|
}) {
|
||||||
const { room } = useCall();
|
const isScreensharing = useCall((state) => state.screenShareEnabled);
|
||||||
|
|
||||||
const isScreensharing = room.localParticipant.isScreenShareEnabled;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant={isScreensharing ? "secondary" : "default"}
|
variant={isScreensharing ? "subtleDefault" : "default"}
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
{isScreensharing ? <MonitorDot /> : <ScreenShare />}
|
{isScreensharing ? (
|
||||||
|
<MonitorDot
|
||||||
|
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScreenShare
|
||||||
|
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<PopoverContent className="w-auto flex flex-col gap-2">
|
<PopoverContent className="w-auto flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
disabled={isScreensharing}
|
disabled={isScreensharing}
|
||||||
onClick={() => room.localParticipant.setScreenShareEnabled(true)}
|
onClick={() => void setScreenShareEnabled(true)}
|
||||||
>
|
>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={!isScreensharing}
|
disabled={!isScreensharing}
|
||||||
onClick={() => room.localParticipant.setScreenShareEnabled(false)}
|
onClick={() => void setScreenShareEnabled(false)}
|
||||||
>
|
>
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Lock, LockOpen } from "lucide-react";
|
import { Lock, LockOpen } from "lucide-react";
|
||||||
import { useCall } from "../context";
|
import { openCallPage, useCall } from "../store";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -10,16 +10,16 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
useIsMobile,
|
useIsMobile,
|
||||||
} from "@tensamin/ui";
|
} from "@tensamin/ui";
|
||||||
import { LeaveIcon } from "@livekit/components-react";
|
|
||||||
import ScreenshareButton from "./buttons/screenshare";
|
import ScreenshareButton from "./buttons/screenshare";
|
||||||
import MuteButton from "./buttons/mute";
|
import MuteButton from "./buttons/mute";
|
||||||
import DeafButton from "./buttons/deaf";
|
import DeafButton from "./buttons/deaf";
|
||||||
import { Room, Track } from "livekit-client";
|
import { Room, Track } from "livekit-client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ResponsiveContainer, AreaChart, Area } from "recharts";
|
import { ResponsiveContainer, AreaChart, Area } from "recharts";
|
||||||
|
import LeaveButton from "./buttons/leave";
|
||||||
|
|
||||||
export default function SidebarBox() {
|
export default function SidebarBox() {
|
||||||
const { state, disconnect } = useCall();
|
const state = useCall((store) => store.state);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return state === "closed" ? null : (
|
return state === "closed" ? null : (
|
||||||
|
|
@ -32,14 +32,7 @@ export default function SidebarBox() {
|
||||||
<MuteButton className="w-9 h-9" />
|
<MuteButton className="w-9 h-9" />
|
||||||
<DeafButton className="w-9 h-9" />
|
<DeafButton className="w-9 h-9" />
|
||||||
<ScreenshareButton className="w-9 h-9" />
|
<ScreenshareButton className="w-9 h-9" />
|
||||||
<Button
|
<LeaveButton className="w-9 h-9" />
|
||||||
size="lg"
|
|
||||||
className="w-9 h-9"
|
|
||||||
onClick={() => disconnect()}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
<LeaveIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -47,9 +40,9 @@ export default function SidebarBox() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionBar() {
|
function ConnectionBar() {
|
||||||
const { state, room, openCallPage, callId } = useCall();
|
const state = useCall((store) => store.state);
|
||||||
const encrypted =
|
const isEncrypted = useCall((store) => store.isEncrypted);
|
||||||
room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted;
|
const callId = useCall((store) => store.callId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
@ -59,7 +52,7 @@ function ConnectionBar() {
|
||||||
onClick={() => openCallPage(callId || "")}
|
onClick={() => openCallPage(callId || "")}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant={
|
variant={
|
||||||
state === "open" && encrypted ? "subtleDefault" : "destructive"
|
state === "open" && isEncrypted ? "subtleDefault" : "destructive"
|
||||||
}
|
}
|
||||||
className="flex justify-between items-center"
|
className="flex justify-between items-center"
|
||||||
>
|
>
|
||||||
|
|
@ -71,7 +64,7 @@ function ConnectionBar() {
|
||||||
|
|
||||||
<TinyPingGraph />
|
<TinyPingGraph />
|
||||||
|
|
||||||
{encrypted ? (
|
{isEncrypted ? (
|
||||||
<Lock color="var(--primary-foreground-alt)" />
|
<Lock color="var(--primary-foreground-alt)" />
|
||||||
) : (
|
) : (
|
||||||
<LockOpen color="var(--destructive)" />
|
<LockOpen color="var(--destructive)" />
|
||||||
|
|
@ -85,11 +78,9 @@ function ConnectionBar() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TinyPingGraph() {
|
export function TinyPingGraph() {
|
||||||
const { room } = useCall();
|
const room = useCall((store) => store.room);
|
||||||
|
|
||||||
const [mapData, setMapData] = useState<Map<number, number>>(
|
const [mapData, setMapData] = useState<Map<number, number>>(() => new Map());
|
||||||
() => new Map([[Date.now(), 0]]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = Array.from(mapData, ([time, ping]) => ({ time, ping }));
|
const data = Array.from(mapData, ([time, ping]) => ({ time, ping }));
|
||||||
|
|
||||||
|
|
@ -102,7 +93,9 @@ export function TinyPingGraph() {
|
||||||
setMapData((prev) => {
|
setMapData((prev) => {
|
||||||
const next = new Map(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()) {
|
for (const time of next.keys()) {
|
||||||
if (time < cutoff) next.delete(time);
|
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%)",
|
"linear-gradient(to right, transparent 0%, var(--primary) 15%, var(--primary) 85%, transparent 100%)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
{data.length > 1 && (
|
||||||
<AreaChart
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
data={data}
|
<AreaChart
|
||||||
margin={{ top: 2, right: 0, bottom: 2, left: 0 }}
|
data={data}
|
||||||
>
|
margin={{ top: 2, right: 0, bottom: 2, left: 0 }}
|
||||||
<defs>
|
>
|
||||||
<linearGradient id="pingGradient" x1="0" y1="0" x2="0" y2="1">
|
<defs>
|
||||||
<stop
|
<linearGradient id="pingGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
offset="0%"
|
<stop
|
||||||
stopColor="currentColor"
|
offset="0%"
|
||||||
stopOpacity={0.35}
|
stopColor="currentColor"
|
||||||
/>
|
stopOpacity={0.35}
|
||||||
<stop
|
/>
|
||||||
offset="70%"
|
<stop
|
||||||
stopColor="currentColor"
|
offset="70%"
|
||||||
stopOpacity={0.08}
|
stopColor="currentColor"
|
||||||
/>
|
stopOpacity={0.08}
|
||||||
<stop
|
/>
|
||||||
offset="100%"
|
<stop
|
||||||
stopColor="currentColor"
|
offset="100%"
|
||||||
stopOpacity={0}
|
stopColor="currentColor"
|
||||||
/>
|
stopOpacity={0}
|
||||||
</linearGradient>
|
/>
|
||||||
</defs>
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="ping"
|
dataKey="ping"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="url(#pingGradient)"
|
fill="url(#pingGradient)"
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
dot={false}
|
dot={false}
|
||||||
activeDot={false}
|
activeDot={false}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TooltipContent>{data.at(-1)?.ping} ms</TooltipContent>
|
<TooltipContent>
|
||||||
|
{data.length > 0 ? `${data.at(-1)?.ping} ms` : "Measuring ping..."}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -197,5 +194,11 @@ async function getPing(room: Room): Promise<number | undefined> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Math.round(bestRtt || 0);
|
if (bestRtt == null || bestRtt <= 0) return;
|
||||||
|
|
||||||
|
const roundedRtt = Math.round(bestRtt);
|
||||||
|
|
||||||
|
if (roundedRtt <= 0) return;
|
||||||
|
|
||||||
|
return roundedRtt;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
packages/call/src/components/top.tsx
Normal file
20
packages/call/src/components/top.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="w-full flex justify-between h-12">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div key={user}>{user}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<contextType | undefined>(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<string | null>(
|
|
||||||
location.pathname.startsWith("/call") ? search.get("id") : null,
|
|
||||||
);
|
|
||||||
const [callSecret, setCallSecret] = useState<string | null>(null);
|
|
||||||
const [livekitToken, setLivekitToken] = useState<string | null>(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<z.infer<
|
|
||||||
typeof ttp.call_data.response
|
|
||||||
> | 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 (
|
|
||||||
<context.Provider
|
|
||||||
value={{
|
|
||||||
state,
|
|
||||||
view,
|
|
||||||
setView,
|
|
||||||
room,
|
|
||||||
|
|
||||||
callId,
|
|
||||||
openCallPage,
|
|
||||||
|
|
||||||
deaf,
|
|
||||||
toggleDeaf,
|
|
||||||
|
|
||||||
disconnect,
|
|
||||||
joinCall,
|
|
||||||
currentCallData,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LiveKitRoom
|
|
||||||
serverUrl="wss://call.tensamin.net"
|
|
||||||
token={livekitToken || ""}
|
|
||||||
connect={state !== "closed" && livekitToken !== ""}
|
|
||||||
room={room}
|
|
||||||
onConnected={() => {
|
|
||||||
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}
|
|
||||||
>
|
|
||||||
<NoiseFilter />
|
|
||||||
{props.children}
|
|
||||||
</LiveKitRoom>
|
|
||||||
</context.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<typeof ttp.call_data.response> | null;
|
|
||||||
room: Room;
|
|
||||||
openCallPage: (callId: string) => Promise<void>;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCall } from "./context";
|
import { useCall } from "./store";
|
||||||
|
|
||||||
import MainLayout from "./views/main/layout";
|
import MainLayout from "./views/main/layout";
|
||||||
import MainGrid from "./views/main/grid";
|
import MainGrid from "./views/main/grid";
|
||||||
|
|
@ -6,7 +6,7 @@ import MainFocused from "./views/main/focused";
|
||||||
import Preview from "./views/preview";
|
import Preview from "./views/preview";
|
||||||
|
|
||||||
export default function Screen() {
|
export default function Screen() {
|
||||||
const { view } = useCall();
|
const view = useCall((state) => state.view);
|
||||||
|
|
||||||
return view === "preview" ? (
|
return view === "preview" ? (
|
||||||
<Preview />
|
<Preview />
|
||||||
|
|
|
||||||
464
packages/call/src/store.tsx
Normal file
464
packages/call/src/store.tsx
Normal file
|
|
@ -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<typeof ttp.call_data.response> | null;
|
||||||
|
|
||||||
|
type NavigateFn = (options: {
|
||||||
|
to: string;
|
||||||
|
search?: Record<string, unknown>;
|
||||||
|
}) => Promise<void>;
|
||||||
|
type SendFn = (
|
||||||
|
type: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
) => Promise<{ data: unknown }>;
|
||||||
|
type GetSharedSecretFn = (
|
||||||
|
privateKey: unknown,
|
||||||
|
ownPublicKey: string,
|
||||||
|
remotePublicKey: string,
|
||||||
|
) => Promise<string>;
|
||||||
|
type DecryptTextFn = (sharedSecret: string, text: string) => Promise<string>;
|
||||||
|
type LoadFn = (key: string) => Promise<unknown>;
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<CallStore>(() => ({
|
||||||
|
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<typeof ttp.call_data.response>);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log(1, "Call", "red", "Failed to get call data", {
|
||||||
|
callId,
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
setCurrentCallData({
|
||||||
|
users: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [callId, send, view]);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export default function View() {
|
export default function View() {
|
||||||
return <div>Grid</div>;
|
return <div className="w-full h-full bg-red-500">Grid</div>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
|
import Actions from "../../components/actions";
|
||||||
|
import TopBar from "../../components/top";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="w-full h-full flex flex-col bg-green-500">
|
||||||
Layout
|
<TopBar />
|
||||||
{children}
|
<div className="w-full h-full">{children}</div>
|
||||||
|
<Actions />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCall } from "../context";
|
import { useCall } from "../store";
|
||||||
|
|
||||||
export default function Preview() {
|
export default function Preview() {
|
||||||
const { currentCallData } = useCall();
|
const currentCallData = useCall((state) => state.currentCallData);
|
||||||
|
|
||||||
return <div>Preview View {JSON.stringify(currentCallData?.users)}</div>;
|
return <div>Preview View {JSON.stringify(currentCallData?.users)}</div>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue