(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:
parent
413764f33e
commit
36902e340a
7 changed files with 214 additions and 23 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue