(feat): add sonner call invite, to be replaced with dedicated popup

(feat): add fallback screen to preview page
(feat): show users call page
This commit is contained in:
Alois 2026-04-29 16:42:17 +02:00
commit 36902e340a
7 changed files with 214 additions and 23 deletions

View file

@ -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=="],

View file

@ -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"
}
}

View file

@ -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<User[]>([]);
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 (
<div className="w-full flex justify-between h-12">
<div className="flex gap-1">
{users.map((user) => (
<div key={user}>{user}</div>
<div key={user.user_id}>{user.display}</div>
))}
</div>
</div>

View file

@ -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<typeof ttp.call_data.response> | null;
type CurrentCallData =
| (z.infer<typeof ttp.call_data.response> & { 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<CallStore>(() => ({
state: "closed",
view: "preview",
invitedUserId: null,
callId: null,
callSecret: null,
livekitToken: null,
@ -324,8 +337,8 @@ export const useCall = create<CallStore>(() => ({
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<typeof ttp.call_data.response>);
setCurrentCallData({
...(data.data as z.infer<typeof ttp.call_data.response>),
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]);

View file

@ -3,7 +3,7 @@ import TopBar from "../../components/top";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="w-full h-full flex flex-col bg-green-500">
<div className="w-full h-full flex flex-col">
<TopBar />
<div className="w-full h-full">{children}</div>
<Actions />

View file

@ -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 <div>Preview View {JSON.stringify(currentCallData?.users)}</div>;
const [data, setData] = useState<User[]>([]);
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 (
<div className="w-full h-full flex items-center justify-center">
{currentCallData?.exists ? (
<div className="flex flex-col gap-2">
{data.map((user) => {
return (
<p key={user.user_id} className="text-2xl">
User: {user.display}
</p>
);
})}
</div>
) : (
<p className="text-2xl">Invalid call</p>
)}
</div>
);
}

View file

@ -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<string, { request: z.ZodType; response: z.ZodType }>;