diff --git a/bun.lock b/bun.lock index b44492c..5d6c6fa 100644 --- a/bun.lock +++ b/bun.lock @@ -112,6 +112,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": "^3.8.1", + "sonner": "^2.0.7", "zod": "^4.3.6", "zustand": "^5.0.8", }, @@ -1463,7 +1464,7 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -1663,19 +1664,19 @@ "@tensamin/notifications/@tensamin/ttp": ["TTP@github:Tensamin/TTP#e246d1a", {}, "Tensamin-TTP-e246d1a", "sha512-ECvnNMtGemIawk/F1FcuOPwMmNgMUaAzcRZiEwHGuG1DtjcbQ8BSF1ZG9ZpGA/9HqcnHmoreG+QGjF6uLKbi+w=="], + "@tensamin/shared/sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="], + "@tensamin/tauri/lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], "@tensamin/tauth/lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], - "@tensamin/tauth/sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], - "@tensamin/ui/lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], "@tensamin/ui/recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="], "@tensamin/ui/shadcn": ["shadcn@3.8.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="], - "@tensamin/ui/sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "@tensamin/web/sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="], "@tensamin/web/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/packages/call/package.json b/packages/call/package.json index adaa1b4..e98ab97 100644 --- a/packages/call/package.json +++ b/packages/call/package.json @@ -32,7 +32,8 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": "^3.8.1", - "zustand": "^5.0.8", - "zod": "^4.3.6" + "sonner": "^2.0.7", + "zod": "^4.3.6", + "zustand": "^5.0.8" } } diff --git a/packages/call/src/components/top.tsx b/packages/call/src/components/top.tsx index ae1786f..8ef2b81 100644 --- a/packages/call/src/components/top.tsx +++ b/packages/call/src/components/top.tsx @@ -1,18 +1,54 @@ +import { useUser, type User } from "@tensamin/user/context"; import { useCall } from "../store"; +import { useEffect, useState } from "react"; export default function TopBar() { + const { get } = useUser(); 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; - }); + const userIds = Array.from(room.remoteParticipants.values(), (participant) => + Number(participant.identity), + ); + const userIdsKey = userIds.join(","); + + const [users, setUsers] = useState([]); + + useEffect(() => { + let active = true; + + if (userIds.length === 0) { + setUsers([]); + return () => { + active = false; + }; + } + + void Promise.all(userIds.map((id) => get(id))) + .then((users) => { + if (!active) { + return; + } + + setUsers(users); + }) + .catch(() => { + if (!active) { + return; + } + + setUsers([]); + }); + + return () => { + active = false; + }; + }, [get, userIdsKey]); return (
{users.map((user) => ( -
{user}
+
{user.display}
))}
diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index 6378ddf..1364b90 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { create } from "zustand"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { useTTP } from "@tensamin/ttp"; @@ -16,10 +16,13 @@ import { Track, } from "livekit-client"; import z from "zod"; +import { toast as sonnerToast } from "sonner"; type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting"; type CallView = "preview" | "focused" | "grid"; -type CurrentCallData = z.infer | null; +type CurrentCallData = + | (z.infer & { exists: boolean }) + | null; type NavigateFn = (options: { to: string; @@ -50,6 +53,7 @@ type Runtime = { type CallStore = { state: CallState; view: CallView; + invitedUserId: number | null; callId: string | null; callSecret: string | null; livekitToken: string | null; @@ -112,7 +116,9 @@ export function setCallId(callId: string | null) { useCall.setState({ callId }); } -export function setCurrentCallData(currentCallData: CurrentCallData) { +export function setCurrentCallData( + currentCallData: CurrentCallData & { exists: boolean }, +) { useCall.setState({ currentCallData }); } @@ -173,6 +179,7 @@ export async function connect(callId: string) { export function disconnect() { useCall.setState({ state: "closing", + invitedUserId: null, callId: null, callSecret: null, livekitToken: null, @@ -193,11 +200,15 @@ export async function joinCall( userId: number, callSecret?: string, existingCallId?: string, + sendInvite = true, ) { const runtime = requireRuntime(useCall.getState().runtime); log(2, "call", "purple", "Call creation initialised"); - useCall.setState({ state: "encrypting" }); + useCall.setState({ + state: "encrypting", + invitedUserId: sendInvite ? userId : null, + }); if (callSecret) { try { @@ -274,6 +285,7 @@ export function resetCallState() { useCall.setState({ state: "closed", view: "preview", + invitedUserId: null, callId: null, callSecret: null, livekitToken: null, @@ -306,6 +318,7 @@ async function ensureNoiseFilter( export const useCall = create(() => ({ state: "closed", view: "preview", + invitedUserId: null, callId: null, callSecret: null, livekitToken: null, @@ -324,8 +337,8 @@ export const useCall = create(() => ({ export function useInitializeCall() { const navigate = useNavigate(); const location = useLocation(); - const { send } = useTTP(); - const { getSharedSecret, decryptText } = useCrypto(); + const { send, subscribePush } = useTTP(); + const { getSharedSecret, decryptText, encryptText } = useCrypto(); const { load } = useStorage(); const { get } = useUser(); @@ -358,6 +371,49 @@ export function useInitializeCall() { }); }, [decryptText, get, getSharedSecret, load, navigate, send]); + const showCallingScreen = useCallback( + async (callId: string, callSecret: string, senderId: number) => { + const senderName = await get(senderId).then((data) => data.display); + + sonnerToast(`Incoming call from ${senderName}`, { + action: { + label: "Accept", + onClick: () => { + joinCall(senderId, callSecret, callId, false).catch((err) => { + log(1, "call", "red", "Failed to join call", err); + }); + }, + }, + cancel: { + label: "Decline", + onClick: () => { + log(2, "call", "purple", "Declined call invite", { + callId, + senderId, + }); + }, + }, + }); + }, + [get], + ); + + // listen to call invites + useEffect(() => { + subscribePush(async (message) => { + if (message.type !== "call_invite") return; + + const { call_id, call_secret, sender_id } = message.data as { + call_id: string; + call_secret: string; + sender_id: number; + }; + + showCallingScreen(call_id, call_secret, sender_id); + }); + }, [subscribePush, showCallingScreen]); + + // get callId from url useEffect(() => { if (!location.pathname.startsWith("/call")) { return; @@ -371,6 +427,7 @@ export function useInitializeCall() { } }, [location.pathname, location.searchStr]); + // room setup useEffect(() => { if (listenersRegistered.current) { return; @@ -381,6 +438,33 @@ export function useInitializeCall() { const onConnected = () => { useCall.setState({ state: "open" }); syncParticipantState(); + + const invitedUserId = useCall.getState().invitedUserId; + + if (invitedUserId != null) { + setTimeout(async () => { + void requireRuntime(useCall.getState().runtime) + .send("call_invite", { + receiver_id: invitedUserId, + call_id: useCall.getState().callId!, + call_secret: await encryptText( + await getSharedSecret( + await load("private_key"), + await get(await load("user_id")).then( + (data) => data.public_key, + ), + await get(invitedUserId).then((data) => data.public_key), + ), + useCall.getState().callSecret!, + ), + }) + .catch((error) => { + toast("error", "Failed to send call invite."); + log(1, "call", "red", "Failed to send call invite", error); + }); + }, 1000); + } + log(2, "call", "purple", "Connected to call", { callId: useCall.getState().callId, callSecret: useCall.getState().callSecret, @@ -440,8 +524,9 @@ export function useInitializeCall() { room.disconnect(); e2eeWorker.terminate(); }; - }, [noiseFilter]); + }, [noiseFilter, encryptText, get, getSharedSecret, load]); + // fetch call data for preview page useEffect(() => { if (view !== "preview" || !callId) { return; @@ -449,7 +534,10 @@ export function useInitializeCall() { send("call_data", { call_id: callId }) .then((data) => { - setCurrentCallData(data.data as z.infer); + setCurrentCallData({ + ...(data.data as z.infer), + exists: true, + }); }) .catch((err) => { log(1, "Call", "red", "Failed to get call data", { @@ -457,7 +545,8 @@ export function useInitializeCall() { error: err, }); setCurrentCallData({ - users: [], + user_ids: [], + exists: false, }); }); }, [callId, send, view]); diff --git a/packages/call/src/views/main/layout.tsx b/packages/call/src/views/main/layout.tsx index ec339e8..fb563ff 100644 --- a/packages/call/src/views/main/layout.tsx +++ b/packages/call/src/views/main/layout.tsx @@ -3,7 +3,7 @@ import TopBar from "../../components/top"; export default function Layout({ children }: { children: React.ReactNode }) { return ( -
+
{children}
diff --git a/packages/call/src/views/preview.tsx b/packages/call/src/views/preview.tsx index aac80cd..b847948 100644 --- a/packages/call/src/views/preview.tsx +++ b/packages/call/src/views/preview.tsx @@ -1,7 +1,59 @@ +import { useEffect, useState } from "react"; import { useCall } from "../store"; +import { useUser, type User } from "@tensamin/user/context"; export default function Preview() { + const { get } = useUser(); const currentCallData = useCall((state) => state.currentCallData); - return
Preview View {JSON.stringify(currentCallData?.users)}
; + const [data, setData] = useState([]); + + useEffect(() => { + let active = true; + + if (!currentCallData?.exists) { + setData([]); + return () => { + active = false; + }; + } + + void Promise.all(currentCallData.user_ids.map((id) => get(id))) + .then((users) => { + if (!active) { + return; + } + + setData(users); + }) + .catch(() => { + if (!active) { + return; + } + + setData([]); + }); + + return () => { + active = false; + }; + }, [currentCallData, get]); + + return ( +
+ {currentCallData?.exists ? ( +
+ {data.map((user) => { + return ( +

+ User: {user.display} +

+ ); + })} +
+ ) : ( +

Invalid call

+ )} +
+ ); } diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index fe122c0..62a3527 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -198,7 +198,19 @@ export const ttp = { call_id: z.string(), }), response: z.object({ - users: z.array(z.number()), + user_ids: z.array(z.number()), + }), + }, + call_invite: { + request: z.object({ + call_id: z.string(), + call_secret: z.base64(), + receiver_id: z.number(), + }), + response: z.object({ + call_id: z.string().optional(), + call_secret: z.base64().optional(), + sender_id: z.number().optional(), }), }, } satisfies Record;