Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b1b34db6a |
19 changed files with 108 additions and 1056 deletions
|
|
@ -26,7 +26,6 @@
|
||||||
"@tensamin/tauri": "workspace:*",
|
"@tensamin/tauri": "workspace:*",
|
||||||
"@tensamin/ttp": "workspace:*",
|
"@tensamin/ttp": "workspace:*",
|
||||||
"@tensamin/tauth": "workspace:*",
|
"@tensamin/tauth": "workspace:*",
|
||||||
"@tensamin/markdown": "workspace:*",
|
|
||||||
"@tensamin/ui": "*",
|
"@tensamin/ui": "*",
|
||||||
"@tensamin/user": "workspace:*",
|
"@tensamin/user": "workspace:*",
|
||||||
"@tensamin/notifications": "workspace:*",
|
"@tensamin/notifications": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,20 @@
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
import {
|
import { Avatar, AvatarImage, AvatarFallback } from "@tensamin/ui";
|
||||||
Avatar,
|
import { reduceDisplay } from "./utils";
|
||||||
AvatarImage,
|
|
||||||
AvatarFallback,
|
|
||||||
Tooltip,
|
|
||||||
TooltipTrigger,
|
|
||||||
TooltipContent,
|
|
||||||
} from "@tensamin/ui";
|
|
||||||
import { Card, CardHeader } from "@tensamin/ui";
|
import { Card, CardHeader } from "@tensamin/ui";
|
||||||
import { Skeleton } from "@tensamin/ui";
|
import { Skeleton } from "@tensamin/ui";
|
||||||
import { getStatusColor } from "@tensamin/shared/data";
|
|
||||||
|
|
||||||
export function Basic({
|
export function Basic(props: { user: User }) {
|
||||||
user,
|
|
||||||
extra,
|
|
||||||
}: {
|
|
||||||
user: User;
|
|
||||||
extra?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Card className="animate-in fade-in duration-300 rounded-2xl py-0 m-px">
|
<Card className="animate-in fade-in duration-300 rounded-2xl py-0 m-px">
|
||||||
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
|
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
|
||||||
<div className="relative shrink-0 overflow-visible">
|
<Avatar>
|
||||||
<Avatar>
|
<AvatarImage src={props.user.avatar} />
|
||||||
<AvatarImage src={user.avatar} />
|
<AvatarFallback>{reduceDisplay(props.user.display)}</AvatarFallback>
|
||||||
<AvatarFallback>
|
</Avatar>
|
||||||
{user.display.slice(0, 2).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<div className="absolute -bottom-0.75 -right-0.75 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-card">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: getStatusColor(user.online_status),
|
|
||||||
}}
|
|
||||||
className="h-2.5 w-2.5 rounded-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent>
|
|
||||||
{user.online_status
|
|
||||||
.split("_")
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(" ")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
|
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
|
||||||
<p>{user.display}</p>
|
<p>{props.user.display}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="pr-2">{extra}</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
13
apps/web/src/components/modals/utils.ts
Normal file
13
apps/web/src/components/modals/utils.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Executes reduceDisplay.
|
||||||
|
* @param display Parameter display.
|
||||||
|
* @returns unknown.
|
||||||
|
*/
|
||||||
|
export function reduceDisplay(display: string) {
|
||||||
|
const words = display.split(" ");
|
||||||
|
if (words.length === 1) {
|
||||||
|
return display.slice(0, 2).toUpperCase();
|
||||||
|
} else {
|
||||||
|
return words[0].charAt(0).toUpperCase() + words[1].charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,26 +7,6 @@ import {
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
Input,
|
|
||||||
DialogClose,
|
|
||||||
Button,
|
|
||||||
Label,
|
|
||||||
Select,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
} from "@tensamin/ui";
|
} from "@tensamin/ui";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import { useIsMobile } from "@tensamin/ui";
|
import { useIsMobile } from "@tensamin/ui";
|
||||||
|
|
@ -34,168 +14,18 @@ import { MobileNavbar } from "./navbar";
|
||||||
|
|
||||||
import SidebarBox from "@tensamin/call/sidebarBox";
|
import SidebarBox from "@tensamin/call/sidebarBox";
|
||||||
import { useShowMobileNavbar } from "@/routes/app/layout";
|
import { useShowMobileNavbar } from "@/routes/app/layout";
|
||||||
import { Ellipsis, Check } from "lucide-react";
|
/**
|
||||||
import { useState } from "react";
|
* Renders the conversation sidebar with account summary and conversation list.
|
||||||
import type { User } from "@tensamin/user/context";
|
* On mobile the sidebar is always kept in the DOM and hidden via CSS
|
||||||
import type z from "zod";
|
* (opacity + translateX) instead of being unmounted. This keeps the DOM and
|
||||||
import { ttp } from "@tensamin/shared/data";
|
* React state alive while the drawer is closed, allowing the sidebar to open
|
||||||
import { useTTP } from "@tensamin/ttp";
|
* instantly on subsequent toggles.
|
||||||
|
* @returns Sidebar JSX.
|
||||||
type OnlineStatus = z.infer<
|
*/
|
||||||
typeof ttp.get_user_data.response.shape.online_status
|
|
||||||
>;
|
|
||||||
|
|
||||||
const onlineStatusLabels: Record<OnlineStatus, string> = {
|
|
||||||
user_online: "Online",
|
|
||||||
user_offline: "Offline",
|
|
||||||
user_dnd: "Do not disturb",
|
|
||||||
user_idle: "Idle",
|
|
||||||
user_wc: "Away",
|
|
||||||
user_borked: "Borked",
|
|
||||||
iota_offline: "Iota offline",
|
|
||||||
iota_online: "Iota online",
|
|
||||||
iota_borked: "Iota borked",
|
|
||||||
};
|
|
||||||
|
|
||||||
function StatusDialog({
|
|
||||||
user,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
send,
|
|
||||||
draftStatus,
|
|
||||||
setDraftStatus,
|
|
||||||
draftOnlineStatus,
|
|
||||||
setDraftOnlineStatus,
|
|
||||||
errorMessage,
|
|
||||||
setErrorMessage,
|
|
||||||
saveSucceeded,
|
|
||||||
setSaveSucceeded,
|
|
||||||
}: {
|
|
||||||
user: User;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
send: ReturnType<typeof useTTP>["send"];
|
|
||||||
draftStatus: string;
|
|
||||||
setDraftStatus: (value: string) => void;
|
|
||||||
draftOnlineStatus: OnlineStatus;
|
|
||||||
setDraftOnlineStatus: (value: OnlineStatus) => void;
|
|
||||||
errorMessage: string;
|
|
||||||
setErrorMessage: (value: string) => void;
|
|
||||||
saveSucceeded: boolean;
|
|
||||||
setSaveSucceeded: (value: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(nextOpen) => {
|
|
||||||
if (!nextOpen) {
|
|
||||||
setDraftStatus(user.status ?? "");
|
|
||||||
setDraftOnlineStatus(user.online_status);
|
|
||||||
setErrorMessage("");
|
|
||||||
setSaveSucceeded(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpenChange(nextOpen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Update Status</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="user-status">Status message</Label>
|
|
||||||
<Input
|
|
||||||
id="user-status"
|
|
||||||
placeholder="Getting snacks..."
|
|
||||||
value={draftStatus}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSaveSucceeded(false);
|
|
||||||
setErrorMessage("");
|
|
||||||
setDraftStatus(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="user-online-status">Online status</Label>
|
|
||||||
<Select
|
|
||||||
value={draftOnlineStatus}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSaveSucceeded(false);
|
|
||||||
setErrorMessage("");
|
|
||||||
setDraftOnlineStatus(value ?? "user_online");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full" id="user-online-status">
|
|
||||||
<SelectValue placeholder="Online">
|
|
||||||
{onlineStatusLabels[draftOnlineStatus]}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="p-1">
|
|
||||||
<SelectItem value="user_online">Online</SelectItem>
|
|
||||||
<SelectItem value="user_offline">Offline</SelectItem>
|
|
||||||
<SelectItem value="user_idle">Idle</SelectItem>
|
|
||||||
<SelectItem value="user_dnd">Do not disturb</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{errorMessage && (
|
|
||||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
|
||||||
)}
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
const payload = {
|
|
||||||
...(draftStatus && { status: draftStatus }),
|
|
||||||
online_status: draftOnlineStatus,
|
|
||||||
};
|
|
||||||
|
|
||||||
const validation =
|
|
||||||
ttp.change_user_data.request.safeParse(payload);
|
|
||||||
|
|
||||||
if (!validation.success) {
|
|
||||||
setSaveSucceeded(false);
|
|
||||||
setErrorMessage(
|
|
||||||
validation.error.issues[0]?.message ?? "Invalid status data",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await send("change_user_data", validation.data);
|
|
||||||
setSaveSucceeded(true);
|
|
||||||
setErrorMessage("");
|
|
||||||
} catch (err) {
|
|
||||||
setSaveSucceeded(false);
|
|
||||||
setErrorMessage("Failed to update status: " + err);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saveSucceeded ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Check className="size-4" />
|
|
||||||
Saved
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"Save"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const showMobileNavbar = useShowMobileNavbar();
|
const showMobileNavbar = useShowMobileNavbar();
|
||||||
const { openMobile, setOpenMobile } = useSidebar();
|
const { openMobile, setOpenMobile } = useSidebar();
|
||||||
const [draftStatus, setDraftStatus] = useState("");
|
|
||||||
const [draftOnlineStatus, setDraftOnlineStatus] =
|
|
||||||
useState<OnlineStatus>("user_online");
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [statusErrorMessage, setStatusErrorMessage] = useState("");
|
|
||||||
const [statusSaveSucceeded, setStatusSaveSucceeded] = useState(false);
|
|
||||||
|
|
||||||
const { send } = useTTP();
|
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
|
|
@ -209,67 +39,7 @@ export default function Sidebar() {
|
||||||
<Wrapper
|
<Wrapper
|
||||||
loading={<Loading />}
|
loading={<Loading />}
|
||||||
userId={"own"}
|
userId={"own"}
|
||||||
component={(user) => (
|
component={(user) => <Basic user={user} />}
|
||||||
<>
|
|
||||||
<Basic
|
|
||||||
user={user}
|
|
||||||
extra={
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Open profile menu"
|
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
||||||
>
|
|
||||||
<Ellipsis />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>Profile</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setDraftStatus(user.status ?? "");
|
|
||||||
setDraftOnlineStatus(user.online_status);
|
|
||||||
setStatusErrorMessage("");
|
|
||||||
setStatusSaveSucceeded(false);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Set Status
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatusDialog
|
|
||||||
user={user}
|
|
||||||
open={dialogOpen}
|
|
||||||
onOpenChange={(nextOpen) => {
|
|
||||||
if (!nextOpen) {
|
|
||||||
setDraftStatus(user.status ?? "");
|
|
||||||
setDraftOnlineStatus(user.online_status);
|
|
||||||
setStatusErrorMessage("");
|
|
||||||
setStatusSaveSucceeded(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
setDialogOpen(nextOpen);
|
|
||||||
}}
|
|
||||||
send={send}
|
|
||||||
draftStatus={draftStatus}
|
|
||||||
setDraftStatus={setDraftStatus}
|
|
||||||
draftOnlineStatus={draftOnlineStatus}
|
|
||||||
setDraftOnlineStatus={setDraftOnlineStatus}
|
|
||||||
errorMessage={statusErrorMessage}
|
|
||||||
setErrorMessage={setStatusErrorMessage}
|
|
||||||
saveSucceeded={statusSaveSucceeded}
|
|
||||||
setSaveSucceeded={setStatusSaveSucceeded}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
|
|
|
||||||
|
|
@ -1,211 +1,3 @@
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
} from "@tensamin/ui";
|
|
||||||
import { useUser, type User } from "@tensamin/user/context";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import MDInput from "@tensamin/markdown/input";
|
|
||||||
import { ttp } from "@tensamin/shared/data";
|
|
||||||
import { useTTP } from "@tensamin/ttp";
|
|
||||||
import { Check } from "lucide-react";
|
|
||||||
|
|
||||||
async function prepImage(
|
|
||||||
file: File,
|
|
||||||
size = 300,
|
|
||||||
quality = 0.8,
|
|
||||||
): Promise<string> {
|
|
||||||
const bitmap = await createImageBitmap(file);
|
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = size;
|
|
||||||
canvas.height = size;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) throw new Error("Could not get canvas context");
|
|
||||||
|
|
||||||
const scale = Math.max(size / bitmap.width, size / bitmap.height);
|
|
||||||
const width = bitmap.width * scale;
|
|
||||||
const height = bitmap.height * scale;
|
|
||||||
const x = (size - width) / 2;
|
|
||||||
const y = (size - height) / 2;
|
|
||||||
|
|
||||||
ctx.drawImage(bitmap, x, y, width, height);
|
|
||||||
|
|
||||||
return canvas.toDataURL("image/webp", quality);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { get } = useUser();
|
return <div></div>;
|
||||||
const { load } = useStorage();
|
|
||||||
const { send } = useTTP();
|
|
||||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
|
||||||
const [draftUser, setDraftUser] = useState<Partial<User>>({});
|
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
|
||||||
const [saveSucceeded, setSaveSucceeded] = useState(false);
|
|
||||||
const avatarUploadRef = useRef<HTMLInputElement>(null);
|
|
||||||
const draftInitializedRef = useRef(false);
|
|
||||||
const effectiveAvatar =
|
|
||||||
draftUser.avatar === "none" ? undefined : draftUser.avatar;
|
|
||||||
|
|
||||||
const updateDraftUser = (
|
|
||||||
updater: (previous: Partial<User>) => Partial<User>,
|
|
||||||
) => {
|
|
||||||
setSaveSucceeded(false);
|
|
||||||
setErrorMessage("");
|
|
||||||
setDraftUser(updater);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchUser = async () => {
|
|
||||||
const user = await get(await load("user_id"));
|
|
||||||
setCurrentUser(user);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUser();
|
|
||||||
}, [load, get]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentUser || draftInitializedRef.current) return;
|
|
||||||
|
|
||||||
setDraftUser(currentUser);
|
|
||||||
draftInitializedRef.current = true;
|
|
||||||
}, [currentUser]);
|
|
||||||
|
|
||||||
const handleAvatarUpload = async (file: File) => {
|
|
||||||
const final = await prepImage(file);
|
|
||||||
updateDraftUser((prev) => ({ ...prev, avatar: final }));
|
|
||||||
if (avatarUploadRef.current) {
|
|
||||||
avatarUploadRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return currentUser ? (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={avatarUploadRef}
|
|
||||||
hidden
|
|
||||||
onChange={(e) =>
|
|
||||||
e.target.files?.[0] && handleAvatarUpload(e.target.files[0])
|
|
||||||
}
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-5 w-80">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Avatar className="size-14">
|
|
||||||
<AvatarImage src={effectiveAvatar} />
|
|
||||||
<AvatarFallback className="text-2xl">
|
|
||||||
{draftUser.display?.slice(0, 2).toUpperCase() ||
|
|
||||||
currentUser.display.slice(0, 2).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p>Avatar</p>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button onClick={() => avatarUploadRef.current?.click()}>
|
|
||||||
Upload avatar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
updateDraftUser((prev) => {
|
|
||||||
return { ...prev, avatar: "none" };
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
variant="destructive"
|
|
||||||
disabled={effectiveAvatar === undefined}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
GIFs are supported in decentralised mode or with Tensamin Premium
|
|
||||||
<br />
|
|
||||||
Maximum file size is 16mb.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
className="w-full"
|
|
||||||
onChange={(event) =>
|
|
||||||
updateDraftUser((prev) => ({
|
|
||||||
...prev,
|
|
||||||
display: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Display Name"
|
|
||||||
value={draftUser.display || ""}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
className="w-full"
|
|
||||||
onChange={(event) =>
|
|
||||||
updateDraftUser((prev) => ({
|
|
||||||
...prev,
|
|
||||||
username: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Username"
|
|
||||||
value={draftUser.username || ""}
|
|
||||||
/>
|
|
||||||
<MDInput
|
|
||||||
styled
|
|
||||||
paddingY="4px"
|
|
||||||
paddingX="10px"
|
|
||||||
fontSize=".875rem"
|
|
||||||
placeholder="About Me"
|
|
||||||
setValue={(value) =>
|
|
||||||
updateDraftUser((prev) => ({ ...prev, about: value }))
|
|
||||||
}
|
|
||||||
value={draftUser.about || ""}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
const payload = {
|
|
||||||
...draftUser,
|
|
||||||
avatar:
|
|
||||||
typeof draftUser.avatar === "string" &&
|
|
||||||
draftUser.avatar.startsWith("data:")
|
|
||||||
? (draftUser.avatar.split(",", 2)[1] ?? "")
|
|
||||||
: draftUser.avatar,
|
|
||||||
};
|
|
||||||
|
|
||||||
const validation = ttp.change_user_data.request.safeParse(payload);
|
|
||||||
|
|
||||||
if (!validation.success) {
|
|
||||||
setSaveSucceeded(false);
|
|
||||||
setErrorMessage(
|
|
||||||
validation.error.issues[0]?.message ?? "Invalid profile data",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await send("change_user_data", validation.data);
|
|
||||||
setSaveSucceeded(true);
|
|
||||||
setErrorMessage("");
|
|
||||||
} catch (err) {
|
|
||||||
setSaveSucceeded(false);
|
|
||||||
setErrorMessage("Failed to update profile: " + err);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saveSucceeded ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Check className="size-4" />
|
|
||||||
Saved
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"Save"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{errorMessage && (
|
|
||||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p>Loading...</p>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
bun.lock
5
bun.lock
|
|
@ -55,7 +55,6 @@
|
||||||
"@tensamin/call": "workspace:*",
|
"@tensamin/call": "workspace:*",
|
||||||
"@tensamin/chat": "workspace:*",
|
"@tensamin/chat": "workspace:*",
|
||||||
"@tensamin/crypto": "workspace:*",
|
"@tensamin/crypto": "workspace:*",
|
||||||
"@tensamin/markdown": "workspace:*",
|
|
||||||
"@tensamin/notifications": "workspace:*",
|
"@tensamin/notifications": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
|
|
@ -259,7 +258,7 @@
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.19.tar.gz",
|
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.17.tar.gz",
|
||||||
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.34.tar.gz",
|
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.34.tar.gz",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|
@ -699,7 +698,7 @@
|
||||||
|
|
||||||
"@tensamin/ttp": ["@tensamin/ttp@workspace:packages/ttp"],
|
"@tensamin/ttp": ["@tensamin/ttp@workspace:packages/ttp"],
|
||||||
|
|
||||||
"@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.19.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.59.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "zod": "^4.4.1" } }, "sha512-La9VqXqJFtzzsRQotXVp+3Vr6u8kj4mQ4wTlSIMRDxKFBnbCvtZyB3V/f8HiIlf7FlZ0Xg0suUUpnamvtcvs9w=="],
|
"@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.17.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.59.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "zod": "^4.4.1" } }, "sha512-smyx+04hSnWM1oyWJfJrRWmxu7OInwyHeIlaD1h3tUrkrSxiKWHl1qCmxVO1bC+cDwyUXhJ5HUnNCbx1aWx7Dg=="],
|
||||||
|
|
||||||
"@tensamin/ui": ["@tensamin/ui@https://git.methanium.net/tensamin/ui/archive/0.0.34.tar.gz", { "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/inter": "^5.2.6", "@tauri-apps/api": "^2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^1.8.0", "next-themes": "^0.4.6", "react-day-picker": "^9.14.0", "react-resizable-panels": "^4.10.0", "recharts": "3.8.0", "shadcn": "^3.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.3.0", "vaul": "^1.1.2" }, "peerDependencies": { "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-hp7rV0a0gfD/rNw9et+PqM1PPkgFC6/Z7eYfza6NIp/m+a8/r5Um+S/8tzBDCgZmQC9Y1sJsjesH7mXZz6Jmuw=="],
|
"@tensamin/ui": ["@tensamin/ui@https://git.methanium.net/tensamin/ui/archive/0.0.34.tar.gz", { "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/inter": "^5.2.6", "@tauri-apps/api": "^2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^1.8.0", "next-themes": "^0.4.6", "react-day-picker": "^9.14.0", "react-resizable-panels": "^4.10.0", "recharts": "3.8.0", "shadcn": "^3.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.3.0", "vaul": "^1.1.2" }, "peerDependencies": { "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-hp7rV0a0gfD/rNw9et+PqM1PPkgFC6/Z7eYfza6NIp/m+a8/r5Um+S/8tzBDCgZmQC9Y1sJsjesH7mXZz6Jmuw=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.34.tar.gz",
|
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.34.tar.gz",
|
||||||
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.19.tar.gz"
|
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.17.tar.gz"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tensamin/ttp-core": "*",
|
"@tensamin/ttp-core": "*",
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,10 @@ export default function ScreenshareButton({
|
||||||
className,
|
className,
|
||||||
iconSize,
|
iconSize,
|
||||||
tooltip,
|
tooltip,
|
||||||
defaultPortal,
|
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
iconSize?: number;
|
iconSize?: number;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
defaultPortal?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const isScreensharing = useCall((state) => state.screenShareEnabled);
|
const isScreensharing = useCall((state) => state.screenShareEnabled);
|
||||||
const screenRef = useCall((state) => state.screenRef);
|
const screenRef = useCall((state) => state.screenRef);
|
||||||
|
|
@ -32,9 +30,8 @@ export default function ScreenshareButton({
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (defaultPortal) return;
|
|
||||||
setPortalContainer(screenRef?.current ?? undefined);
|
setPortalContainer(screenRef?.current ?? undefined);
|
||||||
}, [screenRef, defaultPortal]);
|
}, [screenRef]);
|
||||||
|
|
||||||
async function startWebShare() {
|
async function startWebShare() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -113,9 +110,7 @@ export default function ScreenshareButton({
|
||||||
/>
|
/>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="flex w-40 flex-col gap-2"
|
className="flex w-40 flex-col gap-2"
|
||||||
portalProps={{
|
portalProps={{ container: portalContainer }}
|
||||||
container: defaultPortal ? undefined : portalContainer,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
disabled={isScreensharing}
|
disabled={isScreensharing}
|
||||||
|
|
@ -146,11 +141,7 @@ export default function ScreenshareButton({
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{tooltip && (
|
{tooltip && (
|
||||||
<TooltipContent
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
portalProps={{
|
|
||||||
container: defaultPortal ? undefined : portalContainer,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tooltip}
|
{tooltip}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import {
|
||||||
import { Track, type Participant } from "livekit-client";
|
import { Track, type Participant } from "livekit-client";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useUser, type User } from "@tensamin/user/context";
|
import { useUser, type User } from "@tensamin/user/context";
|
||||||
import { useIsSpeaking } from "../../speakingIndicator";
|
|
||||||
import VideoViewer from "../videoViewer";
|
import VideoViewer from "../videoViewer";
|
||||||
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";
|
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -104,7 +103,6 @@ export default function Base({
|
||||||
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
||||||
const view = useCall((state) => state.view);
|
const view = useCall((state) => state.view);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const isSpeaking = useIsSpeaking(user?.user_id ?? -1);
|
|
||||||
const screenSharePublication = getTrackPublicationBySource(
|
const screenSharePublication = getTrackPublicationBySource(
|
||||||
participant,
|
participant,
|
||||||
Track.Source.ScreenShare,
|
Track.Source.ScreenShare,
|
||||||
|
|
@ -152,12 +150,12 @@ export default function Base({
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
if (view === "grid") {
|
if (view === "grid") {
|
||||||
focusParticipant(user.user_id, type);
|
focusParticipant(user.user_id);
|
||||||
} else {
|
} else {
|
||||||
if (user.user_id === focusedParticipantId) {
|
if (user.user_id === focusedParticipantId) {
|
||||||
setCallView("grid");
|
setCallView("grid");
|
||||||
} else {
|
} else {
|
||||||
focusParticipant(user.user_id, type);
|
focusParticipant(user.user_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -213,9 +211,9 @@ export default function Base({
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={currentCard}
|
ref={currentCard}
|
||||||
className={`transition-all duration-150 z-10 bg-card absolute top-0 left-0 w-full h-full flex gap-2 items-center justify-center ${
|
className={`z-10 bg-card absolute top-0 left-0 w-full h-full flex gap-2 items-center justify-center ${
|
||||||
flush ? "rounded-none" : "rounded-sm"
|
flush ? "rounded-none" : "rounded-sm"
|
||||||
} ${type === "user" && isSpeaking ? "border-4 border-(--primary-foreground-alt)/75" : "border-0"}`}
|
}`}
|
||||||
style={{ containerType: "size" }}
|
style={{ containerType: "size" }}
|
||||||
>
|
>
|
||||||
{/* Detect video / user and place here */}
|
{/* Detect video / user and place here */}
|
||||||
|
|
|
||||||
|
|
@ -20,18 +20,24 @@ import LeaveButton from "./buttons/leave";
|
||||||
|
|
||||||
export default function SidebarBox() {
|
export default function SidebarBox() {
|
||||||
const state = useCall((store) => store.state);
|
const state = useCall((store) => store.state);
|
||||||
|
const screenRef = useCall((store) => store.screenRef);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const [portalContainer, setPortalContainer] = useState<HTMLElement>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPortalContainer(screenRef?.current ?? undefined);
|
||||||
|
}, [screenRef]);
|
||||||
|
|
||||||
return state === "closed" ? null : (
|
return state === "closed" ? null : (
|
||||||
<Card className="p-1.5 gap-2" hidden={isMobile}>
|
<Card className="p-1.5 gap-2" hidden={isMobile}>
|
||||||
<CardHeader className="p-0! pb-2! border-b-2">
|
<CardHeader className="p-0! pb-2! border-b-2">
|
||||||
<ConnectionBar />
|
<ConnectionBar portalContainer={portalContainer} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0! flex flex-col gap-1">
|
<CardContent className="p-0! flex flex-col gap-1">
|
||||||
<div className="flex justify-start gap-1">
|
<div className="flex justify-start gap-1">
|
||||||
<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" defaultPortal />
|
<ScreenshareButton className="w-9 h-9" />
|
||||||
<LeaveButton className="w-9 h-9" />
|
<LeaveButton className="w-9 h-9" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -39,7 +45,7 @@ export default function SidebarBox() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionBar() {
|
function ConnectionBar({ portalContainer }: { portalContainer?: HTMLElement }) {
|
||||||
const state = useCall((store) => store.state);
|
const state = useCall((store) => store.state);
|
||||||
const isEncrypted = useCall((store) => store.isEncrypted);
|
const isEncrypted = useCall((store) => store.isEncrypted);
|
||||||
const callId = useCall((store) => store.callId);
|
const callId = useCall((store) => store.callId);
|
||||||
|
|
@ -62,7 +68,7 @@ function ConnectionBar() {
|
||||||
{state === "closed" && "Closed"}
|
{state === "closed" && "Closed"}
|
||||||
{state === "closing" && "Closing"}
|
{state === "closing" && "Closing"}
|
||||||
|
|
||||||
<TinyPingGraph />
|
<TinyPingGraph portalContainer={portalContainer} />
|
||||||
|
|
||||||
{isEncrypted ? (
|
{isEncrypted ? (
|
||||||
<Lock color="var(--primary-foreground-alt)" />
|
<Lock color="var(--primary-foreground-alt)" />
|
||||||
|
|
@ -72,12 +78,18 @@ function ConnectionBar() {
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TooltipContent>Click to open call page</TooltipContent>
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
|
Click to open call page
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TinyPingGraph() {
|
export function TinyPingGraph({
|
||||||
|
portalContainer,
|
||||||
|
}: {
|
||||||
|
portalContainer?: HTMLElement;
|
||||||
|
}) {
|
||||||
const room = useCall((store) => store.room);
|
const room = useCall((store) => store.room);
|
||||||
|
|
||||||
const [mapData, setMapData] = useState<Map<number, number>>(() => new Map());
|
const [mapData, setMapData] = useState<Map<number, number>>(() => new Map());
|
||||||
|
|
@ -164,7 +176,7 @@ export function TinyPingGraph() {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TooltipContent>
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
{data.length > 0 ? `${data.at(-1)?.ping} ms` : "Measuring ping..."}
|
{data.length > 0 ? `${data.at(-1)?.ping} ms` : "Measuring ping..."}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
import { log } from "@tensamin/shared/log";
|
|
||||||
import { useCall } from "./store";
|
|
||||||
|
|
||||||
const SPEAKING_THRESHOLD = 0.01;
|
|
||||||
const SPEAKING_HANGTIME_MS = 500;
|
|
||||||
const ANALYSIS_INTERVAL_MS = 30;
|
|
||||||
const FFT_SIZE = 256;
|
|
||||||
|
|
||||||
type AnalyserEntry = {
|
|
||||||
source: MediaStreamAudioSourceNode;
|
|
||||||
analyser: AnalyserNode;
|
|
||||||
track: MediaStreamTrack;
|
|
||||||
originalTrack?: MediaStreamTrack;
|
|
||||||
lastSpeakingTime: number;
|
|
||||||
isSpeaking: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
class SpeakingDetector {
|
|
||||||
private audioContext: AudioContext | null = null;
|
|
||||||
private entries = new Map<number, AnalyserEntry>();
|
|
||||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private deaf = false;
|
|
||||||
private gateThresholdStart = -50;
|
|
||||||
private gateThresholdEnd = -40;
|
|
||||||
private localParticipantId: number | null = null;
|
|
||||||
private localMicGateClosed = false;
|
|
||||||
|
|
||||||
private ensureAudioContext(): AudioContext {
|
|
||||||
if (!this.audioContext) {
|
|
||||||
this.audioContext = new AudioContext();
|
|
||||||
}
|
|
||||||
if (this.audioContext.state === "suspended") {
|
|
||||||
void this.audioContext.resume();
|
|
||||||
}
|
|
||||||
return this.audioContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocalParticipantId(id: number) {
|
|
||||||
this.localParticipantId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
setGateThresholds(start: number, end: number) {
|
|
||||||
this.gateThresholdStart = start;
|
|
||||||
this.gateThresholdEnd = end;
|
|
||||||
}
|
|
||||||
|
|
||||||
addTrack(
|
|
||||||
participantId: number,
|
|
||||||
track: MediaStreamTrack,
|
|
||||||
originalTrack?: MediaStreamTrack,
|
|
||||||
) {
|
|
||||||
if (track.kind !== "audio") return;
|
|
||||||
|
|
||||||
this.removeParticipant(participantId);
|
|
||||||
|
|
||||||
const ctx = this.ensureAudioContext();
|
|
||||||
const stream = new MediaStream([track]);
|
|
||||||
const source = ctx.createMediaStreamSource(stream);
|
|
||||||
const analyser = ctx.createAnalyser();
|
|
||||||
analyser.fftSize = FFT_SIZE;
|
|
||||||
source.connect(analyser);
|
|
||||||
|
|
||||||
this.entries.set(participantId, {
|
|
||||||
source,
|
|
||||||
analyser,
|
|
||||||
track,
|
|
||||||
originalTrack,
|
|
||||||
lastSpeakingTime: 0,
|
|
||||||
isSpeaking: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!this.intervalId) {
|
|
||||||
this.startLoop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeParticipant(participantId: number) {
|
|
||||||
const entry = this.entries.get(participantId);
|
|
||||||
if (!entry) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
entry.source.disconnect();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
this.entries.delete(participantId);
|
|
||||||
|
|
||||||
useCall.setState((state) => {
|
|
||||||
if (!state.speakingParticipantIds.has(participantId)) return state;
|
|
||||||
const next = new Set(state.speakingParticipantIds);
|
|
||||||
next.delete(participantId);
|
|
||||||
return { speakingParticipantIds: next };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (participantId === this.localParticipantId && this.localMicGateClosed) {
|
|
||||||
this.muteLocalTrack(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setDeaf(deaf: boolean) {
|
|
||||||
this.deaf = deaf;
|
|
||||||
if (deaf) {
|
|
||||||
for (const entry of this.entries.values()) {
|
|
||||||
entry.isSpeaking = false;
|
|
||||||
entry.lastSpeakingTime = 0;
|
|
||||||
}
|
|
||||||
useCall.setState({ speakingParticipantIds: new Set() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private startLoop() {
|
|
||||||
if (this.intervalId) return;
|
|
||||||
this.intervalId = setInterval(() => this.analyse(), ANALYSIS_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopLoop() {
|
|
||||||
if (this.intervalId) {
|
|
||||||
clearInterval(this.intervalId);
|
|
||||||
this.intervalId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private muteLocalTrack(muted: boolean) {
|
|
||||||
const entry = this.localParticipantId
|
|
||||||
? this.entries.get(this.localParticipantId)
|
|
||||||
: undefined;
|
|
||||||
const target = entry?.originalTrack ?? entry?.track;
|
|
||||||
|
|
||||||
if (target && target.enabled === muted) {
|
|
||||||
target.enabled = !muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.localMicGateClosed = muted;
|
|
||||||
useCall.setState({ micGated: muted });
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyNoiseGate(rms: number) {
|
|
||||||
const db = 20 * Math.log10(Math.max(rms, 0.0001));
|
|
||||||
|
|
||||||
if (!this.localMicGateClosed && db < this.gateThresholdStart) {
|
|
||||||
log(3, "noise gate", "purple", "closed");
|
|
||||||
this.muteLocalTrack(true);
|
|
||||||
} else if (this.localMicGateClosed && db > this.gateThresholdEnd) {
|
|
||||||
log(3, "noise gate", "purple", "opened");
|
|
||||||
this.muteLocalTrack(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private analyse() {
|
|
||||||
if (this.deaf || this.entries.size === 0) return;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const changed = new Map<number, boolean>();
|
|
||||||
|
|
||||||
for (const [participantId, entry] of this.entries) {
|
|
||||||
const { analyser, track } = entry;
|
|
||||||
|
|
||||||
if (track.muted || track.readyState === "ended" || !track.enabled) {
|
|
||||||
if (entry.isSpeaking) {
|
|
||||||
entry.isSpeaking = false;
|
|
||||||
entry.lastSpeakingTime = 0;
|
|
||||||
changed.set(participantId, false);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bufferLength = analyser.frequencyBinCount;
|
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
|
||||||
analyser.getByteTimeDomainData(dataArray);
|
|
||||||
|
|
||||||
let sum = 0;
|
|
||||||
for (let i = 0; i < bufferLength; i++) {
|
|
||||||
const sample = (dataArray[i] - 128) / 128.0;
|
|
||||||
sum += sample * sample;
|
|
||||||
}
|
|
||||||
const rms = Math.sqrt(sum / bufferLength);
|
|
||||||
|
|
||||||
let nextIsSpeaking = entry.isSpeaking;
|
|
||||||
if (rms > SPEAKING_THRESHOLD) {
|
|
||||||
entry.lastSpeakingTime = now;
|
|
||||||
nextIsSpeaking = true;
|
|
||||||
} else if (now - entry.lastSpeakingTime > SPEAKING_HANGTIME_MS) {
|
|
||||||
nextIsSpeaking = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (participantId === this.localParticipantId) {
|
|
||||||
this.applyNoiseGate(rms);
|
|
||||||
if (this.localMicGateClosed) {
|
|
||||||
nextIsSpeaking = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextIsSpeaking !== entry.isSpeaking) {
|
|
||||||
entry.isSpeaking = nextIsSpeaking;
|
|
||||||
changed.set(participantId, nextIsSpeaking);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed.size > 0) {
|
|
||||||
useCall.setState((state) => {
|
|
||||||
let hasDiff = false;
|
|
||||||
const next = new Set(state.speakingParticipantIds);
|
|
||||||
for (const [id, speaking] of changed) {
|
|
||||||
if (speaking) {
|
|
||||||
if (!next.has(id)) {
|
|
||||||
next.add(id);
|
|
||||||
hasDiff = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (next.has(id)) {
|
|
||||||
next.delete(id);
|
|
||||||
hasDiff = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasDiff ? { speakingParticipantIds: next } : state;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.stopLoop();
|
|
||||||
for (const id of Array.from(this.entries.keys())) {
|
|
||||||
this.removeParticipant(id);
|
|
||||||
}
|
|
||||||
this.entries.clear();
|
|
||||||
if (this.audioContext) {
|
|
||||||
void this.audioContext.close();
|
|
||||||
this.audioContext = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let detectorInstance: SpeakingDetector | null = null;
|
|
||||||
|
|
||||||
export function getSpeakingDetector(): SpeakingDetector {
|
|
||||||
if (!detectorInstance) {
|
|
||||||
detectorInstance = new SpeakingDetector();
|
|
||||||
}
|
|
||||||
return detectorInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function disposeSpeakingDetector(): void {
|
|
||||||
if (detectorInstance) {
|
|
||||||
detectorInstance.dispose();
|
|
||||||
detectorInstance = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useIsSpeaking(participantId: number): boolean {
|
|
||||||
return useCall((state) => state.speakingParticipantIds.has(participantId));
|
|
||||||
}
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter";
|
||||||
import {
|
import {
|
||||||
ExternalE2EEKeyProvider,
|
ExternalE2EEKeyProvider,
|
||||||
LocalAudioTrack,
|
LocalAudioTrack,
|
||||||
type LocalTrackPublication,
|
|
||||||
type Participant,
|
type Participant,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
type RemoteTrackPublication,
|
type RemoteTrackPublication,
|
||||||
|
|
@ -29,10 +28,6 @@ import {
|
||||||
createScreenShareController,
|
createScreenShareController,
|
||||||
type ScreenShareSession,
|
type ScreenShareSession,
|
||||||
} from "./screenshare";
|
} from "./screenshare";
|
||||||
import {
|
|
||||||
getSpeakingDetector,
|
|
||||||
disposeSpeakingDetector,
|
|
||||||
} from "./speakingIndicator";
|
|
||||||
|
|
||||||
// logging
|
// logging
|
||||||
setLogExtension(
|
setLogExtension(
|
||||||
|
|
@ -91,7 +86,6 @@ type CallStore = {
|
||||||
screenShareEnabled: boolean;
|
screenShareEnabled: boolean;
|
||||||
screenShareSession: ScreenShareSession | null;
|
screenShareSession: ScreenShareSession | null;
|
||||||
focusedParticipantId: number | null;
|
focusedParticipantId: number | null;
|
||||||
focusedParticipantType: "user" | "stream" | null;
|
|
||||||
usersInFocusedViewHidden: boolean;
|
usersInFocusedViewHidden: boolean;
|
||||||
watchedStreamParticipantIds: number[];
|
watchedStreamParticipantIds: number[];
|
||||||
pendingWatchedParticipantIds: number[];
|
pendingWatchedParticipantIds: number[];
|
||||||
|
|
@ -105,8 +99,6 @@ type CallStore = {
|
||||||
keyProvider: ExternalE2EEKeyProvider;
|
keyProvider: ExternalE2EEKeyProvider;
|
||||||
e2eeWorker: Worker;
|
e2eeWorker: Worker;
|
||||||
runtime: Runtime | null;
|
runtime: Runtime | null;
|
||||||
speakingParticipantIds: Set<number>;
|
|
||||||
micGated: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const keyProvider = new ExternalE2EEKeyProvider();
|
const keyProvider = new ExternalE2EEKeyProvider();
|
||||||
|
|
@ -160,7 +152,7 @@ function clearRemoteAudio() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParticipantId(identity: string | undefined): number | null {
|
function getParticipantId(identity: string | undefined): number | null {
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -207,10 +199,7 @@ function matchesRemoteTrackSelector(
|
||||||
|
|
||||||
function syncRemoteParticipantTrackSubscriptions(participantId: number) {
|
function syncRemoteParticipantTrackSubscriptions(participantId: number) {
|
||||||
for (const publication of getRemoteTrackPublications(participantId)) {
|
for (const publication of getRemoteTrackPublications(participantId)) {
|
||||||
publication.setSubscribed(
|
publication.setSubscribed(publication.kind === Track.Kind.Audio);
|
||||||
publication.kind === Track.Kind.Audio &&
|
|
||||||
publication.source !== Track.Source.ScreenShareAudio,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -468,8 +457,6 @@ function syncScreenShareParticipants() {
|
||||||
watchedStreamParticipantIds,
|
watchedStreamParticipantIds,
|
||||||
pendingWatchedParticipantIds,
|
pendingWatchedParticipantIds,
|
||||||
focusedParticipantId,
|
focusedParticipantId,
|
||||||
focusedParticipantType:
|
|
||||||
focusedParticipantId == null ? null : state.focusedParticipantType,
|
|
||||||
view:
|
view:
|
||||||
state.view === "focused" && focusedParticipantId == null
|
state.view === "focused" && focusedParticipantId == null
|
||||||
? "grid"
|
? "grid"
|
||||||
|
|
@ -627,7 +614,6 @@ export function startWatchingStream(participantId: number) {
|
||||||
const trackReady = getScreenShareTrackForParticipant(participantId) != null;
|
const trackReady = getScreenShareTrackForParticipant(participantId) != null;
|
||||||
|
|
||||||
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare);
|
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare);
|
||||||
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShareAudio);
|
|
||||||
|
|
||||||
useCall.setState((state) => ({
|
useCall.setState((state) => ({
|
||||||
watchedStreamParticipantIds: state.watchedStreamParticipantIds.includes(
|
watchedStreamParticipantIds: state.watchedStreamParticipantIds.includes(
|
||||||
|
|
@ -641,7 +627,6 @@ export function startWatchingStream(participantId: number) {
|
||||||
? state.pendingWatchedParticipantIds
|
? state.pendingWatchedParticipantIds
|
||||||
: [...state.pendingWatchedParticipantIds, participantId],
|
: [...state.pendingWatchedParticipantIds, participantId],
|
||||||
focusedParticipantId: participantId,
|
focusedParticipantId: participantId,
|
||||||
focusedParticipantType: "stream",
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -661,13 +646,9 @@ export function setParticipantTrackSubscribed(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus a participant in the main call view even when they are not sharing a screen.
|
// Focus a participant in the main call view even when they are not sharing a screen.
|
||||||
export function focusParticipant(
|
export function focusParticipant(participantId: number) {
|
||||||
participantId: number,
|
|
||||||
type: "user" | "stream" = "user",
|
|
||||||
) {
|
|
||||||
useCall.setState({
|
useCall.setState({
|
||||||
focusedParticipantId: participantId,
|
focusedParticipantId: participantId,
|
||||||
focusedParticipantType: type,
|
|
||||||
view: "focused",
|
view: "focused",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -675,11 +656,6 @@ export function focusParticipant(
|
||||||
// Stop tracking a participant's shared screen and clean up related UI state.
|
// Stop tracking a participant's shared screen and clean up related UI state.
|
||||||
export function stopWatchingStream(participantId: number) {
|
export function stopWatchingStream(participantId: number) {
|
||||||
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare, false);
|
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare, false);
|
||||||
setParticipantTrackSubscribed(
|
|
||||||
participantId,
|
|
||||||
Track.Source.ScreenShareAudio,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
useCall.setState((state) => ({
|
useCall.setState((state) => ({
|
||||||
watchedStreamParticipantIds: state.watchedStreamParticipantIds.filter(
|
watchedStreamParticipantIds: state.watchedStreamParticipantIds.filter(
|
||||||
|
|
@ -692,10 +668,6 @@ export function stopWatchingStream(participantId: number) {
|
||||||
state.focusedParticipantId === participantId
|
state.focusedParticipantId === participantId
|
||||||
? null
|
? null
|
||||||
: state.focusedParticipantId,
|
: state.focusedParticipantId,
|
||||||
focusedParticipantType:
|
|
||||||
state.focusedParticipantId === participantId
|
|
||||||
? null
|
|
||||||
: state.focusedParticipantType,
|
|
||||||
view:
|
view:
|
||||||
state.view === "focused" && state.focusedParticipantId === participantId
|
state.view === "focused" && state.focusedParticipantId === participantId
|
||||||
? "grid"
|
? "grid"
|
||||||
|
|
@ -785,7 +757,6 @@ export async function connect(callId: string) {
|
||||||
|
|
||||||
// Tear down the active call session and return the store to a closed state.
|
// Tear down the active call session and return the store to a closed state.
|
||||||
export async function disconnect() {
|
export async function disconnect() {
|
||||||
disposeSpeakingDetector();
|
|
||||||
await clearScreenSharePreview();
|
await clearScreenSharePreview();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -811,12 +782,10 @@ export async function disconnect() {
|
||||||
view: "preview",
|
view: "preview",
|
||||||
screenShareSession: null,
|
screenShareSession: null,
|
||||||
focusedParticipantId: null,
|
focusedParticipantId: null,
|
||||||
focusedParticipantType: null,
|
|
||||||
usersInFocusedViewHidden: false,
|
usersInFocusedViewHidden: false,
|
||||||
watchedStreamParticipantIds: [],
|
watchedStreamParticipantIds: [],
|
||||||
pendingWatchedParticipantIds: [],
|
pendingWatchedParticipantIds: [],
|
||||||
activeScreenShareParticipantIds: [],
|
activeScreenShareParticipantIds: [],
|
||||||
micGated: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
room.remoteParticipants.forEach((participant) => {
|
room.remoteParticipants.forEach((participant) => {
|
||||||
|
|
@ -910,7 +879,6 @@ export async function toggleDeaf() {
|
||||||
deafened: nextDeaf ? "true" : "false",
|
deafened: nextDeaf ? "true" : "false",
|
||||||
});
|
});
|
||||||
|
|
||||||
getSpeakingDetector().setDeaf(nextDeaf);
|
|
||||||
useCall.setState({ deaf: nextDeaf });
|
useCall.setState({ deaf: nextDeaf });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -972,7 +940,6 @@ export function resetCallState() {
|
||||||
deaf: false,
|
deaf: false,
|
||||||
screenShareSession: null,
|
screenShareSession: null,
|
||||||
focusedParticipantId: null,
|
focusedParticipantId: null,
|
||||||
focusedParticipantType: null,
|
|
||||||
usersInFocusedViewHidden: false,
|
usersInFocusedViewHidden: false,
|
||||||
watchedStreamParticipantIds: [],
|
watchedStreamParticipantIds: [],
|
||||||
pendingWatchedParticipantIds: [],
|
pendingWatchedParticipantIds: [],
|
||||||
|
|
@ -1000,16 +967,6 @@ async function ensureNoiseFilter(
|
||||||
await microphoneTrack.setProcessor(noiseFilter).catch((err) => {
|
await microphoneTrack.setProcessor(noiseFilter).catch((err) => {
|
||||||
log(1, "call", "red", "Failed to enable noise filter", err);
|
log(1, "call", "red", "Failed to enable noise filter", err);
|
||||||
});
|
});
|
||||||
|
|
||||||
const participantId = getParticipantId(room.localParticipant.identity);
|
|
||||||
if (participantId != null) {
|
|
||||||
const processedTrack = microphoneTrack.mediaStreamTrack;
|
|
||||||
getSpeakingDetector().addTrack(
|
|
||||||
participantId,
|
|
||||||
processedTrack.clone(),
|
|
||||||
processedTrack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCall = create<CallStore>(() => ({
|
export const useCall = create<CallStore>(() => ({
|
||||||
|
|
@ -1025,7 +982,6 @@ export const useCall = create<CallStore>(() => ({
|
||||||
screenShareEnabled: room.localParticipant.isScreenShareEnabled,
|
screenShareEnabled: room.localParticipant.isScreenShareEnabled,
|
||||||
screenShareSession: null,
|
screenShareSession: null,
|
||||||
focusedParticipantId: null,
|
focusedParticipantId: null,
|
||||||
focusedParticipantType: null,
|
|
||||||
usersInFocusedViewHidden: false,
|
usersInFocusedViewHidden: false,
|
||||||
watchedStreamParticipantIds: [],
|
watchedStreamParticipantIds: [],
|
||||||
pendingWatchedParticipantIds: [],
|
pendingWatchedParticipantIds: [],
|
||||||
|
|
@ -1040,8 +996,6 @@ export const useCall = create<CallStore>(() => ({
|
||||||
keyProvider,
|
keyProvider,
|
||||||
e2eeWorker,
|
e2eeWorker,
|
||||||
runtime: null,
|
runtime: null,
|
||||||
speakingParticipantIds: new Set(),
|
|
||||||
micGated: false,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Register app-level call listeners and wire React dependencies into the store.
|
// Register app-level call listeners and wire React dependencies into the store.
|
||||||
|
|
@ -1062,7 +1016,7 @@ export function useInitializeCall() {
|
||||||
new DeepFilterNoiseFilterProcessor({
|
new DeepFilterNoiseFilterProcessor({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
enableNoiseReduction: true,
|
enableNoiseReduction: true,
|
||||||
noiseReductionLevel: 60,
|
noiseReductionLevel: 80,
|
||||||
sampleRate: 48000,
|
sampleRate: 48000,
|
||||||
assetConfig: {
|
assetConfig: {
|
||||||
cdnUrl: "/assets",
|
cdnUrl: "/assets",
|
||||||
|
|
@ -1183,46 +1137,10 @@ export function useInitializeCall() {
|
||||||
|
|
||||||
listenersRegistered.current = true;
|
listenersRegistered.current = true;
|
||||||
|
|
||||||
const onConnected = async () => {
|
const onConnected = () => {
|
||||||
useCall.setState({ state: "open" });
|
useCall.setState({ state: "open" });
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
|
|
||||||
const detector = getSpeakingDetector();
|
|
||||||
const localParticipantId = getParticipantId(
|
|
||||||
room.localParticipant.identity,
|
|
||||||
);
|
|
||||||
if (localParticipantId != null) {
|
|
||||||
detector.setLocalParticipantId(localParticipantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [start, end] = await Promise.all([
|
|
||||||
load("call_mute_range_start"),
|
|
||||||
load("call_mute_range_end"),
|
|
||||||
]);
|
|
||||||
detector.setGateThresholds(start, end);
|
|
||||||
|
|
||||||
// Scan existing audio tracks for speaking detection
|
|
||||||
for (const participant of getAllParticipants()) {
|
|
||||||
const participantId = getParticipantId(participant.identity);
|
|
||||||
if (participantId == null) continue;
|
|
||||||
|
|
||||||
for (const publication of participant.trackPublications.values()) {
|
|
||||||
if (
|
|
||||||
publication.kind === Track.Kind.Audio &&
|
|
||||||
publication.source === Track.Source.Microphone &&
|
|
||||||
publication.track
|
|
||||||
) {
|
|
||||||
const mediaTrack = publication.track.mediaStreamTrack;
|
|
||||||
if (participant === room.localParticipant) {
|
|
||||||
const clonedTrack = mediaTrack.clone();
|
|
||||||
detector.addTrack(participantId, clonedTrack, mediaTrack);
|
|
||||||
} else {
|
|
||||||
detector.addTrack(participantId, mediaTrack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const invitedUserId = useCall.getState().invitedUserId;
|
const invitedUserId = useCall.getState().invitedUserId;
|
||||||
|
|
||||||
if (invitedUserId != null) {
|
if (invitedUserId != null) {
|
||||||
|
|
@ -1259,7 +1177,6 @@ export function useInitializeCall() {
|
||||||
|
|
||||||
if (participantId != null) {
|
if (participantId != null) {
|
||||||
stopWatchingStream(participantId);
|
stopWatchingStream(participantId);
|
||||||
getSpeakingDetector().removeParticipant(participantId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
|
|
@ -1280,40 +1197,6 @@ export function useInitializeCall() {
|
||||||
void ensureNoiseFilter(noiseFilter);
|
void ensureNoiseFilter(noiseFilter);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLocalTrackPublished = (publication: LocalTrackPublication) => {
|
|
||||||
if (
|
|
||||||
publication.kind === Track.Kind.Audio &&
|
|
||||||
publication.source === Track.Source.Microphone &&
|
|
||||||
publication.track
|
|
||||||
) {
|
|
||||||
const participantId = getParticipantId(room.localParticipant.identity);
|
|
||||||
if (participantId != null) {
|
|
||||||
const originalTrack = publication.track.mediaStreamTrack;
|
|
||||||
const clonedTrack = originalTrack.clone();
|
|
||||||
getSpeakingDetector().addTrack(
|
|
||||||
participantId,
|
|
||||||
clonedTrack,
|
|
||||||
originalTrack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
syncParticipantState();
|
|
||||||
void ensureNoiseFilter(noiseFilter);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onLocalTrackUnpublished = (publication: LocalTrackPublication) => {
|
|
||||||
if (
|
|
||||||
publication.kind === Track.Kind.Audio &&
|
|
||||||
publication.source === Track.Source.Microphone
|
|
||||||
) {
|
|
||||||
const participantId = getParticipantId(room.localParticipant.identity);
|
|
||||||
if (participantId != null) {
|
|
||||||
getSpeakingDetector().removeParticipant(participantId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
syncParticipantState();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onTrackPublished = (
|
const onTrackPublished = (
|
||||||
publication: RemoteTrackPublication,
|
publication: RemoteTrackPublication,
|
||||||
participant: RemoteParticipant,
|
participant: RemoteParticipant,
|
||||||
|
|
@ -1321,10 +1204,7 @@ export function useInitializeCall() {
|
||||||
const participantId = getParticipantId(participant.identity);
|
const participantId = getParticipantId(participant.identity);
|
||||||
|
|
||||||
if (participantId != null) {
|
if (participantId != null) {
|
||||||
if (
|
if (publication.kind === Track.Kind.Audio) {
|
||||||
publication.kind === Track.Kind.Audio &&
|
|
||||||
publication.source !== Track.Source.ScreenShareAudio
|
|
||||||
) {
|
|
||||||
publication.setSubscribed(true);
|
publication.setSubscribed(true);
|
||||||
} else {
|
} else {
|
||||||
publication.setSubscribed(false);
|
publication.setSubscribed(false);
|
||||||
|
|
@ -1334,21 +1214,9 @@ export function useInitializeCall() {
|
||||||
onParticipantStateChange();
|
onParticipantStateChange();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTrackSubscribed = (
|
const onTrackSubscribed = (track: RemoteTrack) => {
|
||||||
track: RemoteTrack,
|
|
||||||
publication: RemoteTrackPublication,
|
|
||||||
participant: RemoteParticipant,
|
|
||||||
) => {
|
|
||||||
if (track.kind === "audio" && track.sid) {
|
if (track.kind === "audio" && track.sid) {
|
||||||
attachRemoteAudio(track.sid, track.attach());
|
attachRemoteAudio(track.sid, track.attach());
|
||||||
|
|
||||||
const participantId = getParticipantId(participant.identity);
|
|
||||||
if (
|
|
||||||
participantId != null &&
|
|
||||||
publication.source === Track.Source.Microphone
|
|
||||||
) {
|
|
||||||
getSpeakingDetector().addTrack(participantId, track.mediaStreamTrack);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
|
|
@ -1356,23 +1224,16 @@ export function useInitializeCall() {
|
||||||
|
|
||||||
const onTrackUnsubscribed = (
|
const onTrackUnsubscribed = (
|
||||||
track: RemoteTrack,
|
track: RemoteTrack,
|
||||||
publication: RemoteTrackPublication,
|
_publication: unknown,
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
) => {
|
) => {
|
||||||
const participantId = getParticipantId(participant.identity);
|
|
||||||
|
|
||||||
if (track.kind === "audio" && track.sid) {
|
if (track.kind === "audio" && track.sid) {
|
||||||
track.detach();
|
track.detach();
|
||||||
detachRemoteAudio(track.sid);
|
detachRemoteAudio(track.sid);
|
||||||
|
|
||||||
if (
|
|
||||||
participantId != null &&
|
|
||||||
publication.source === Track.Source.Microphone
|
|
||||||
) {
|
|
||||||
getSpeakingDetector().removeParticipant(participantId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const participantId = getParticipantId(participant.identity);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
participantId != null &&
|
participantId != null &&
|
||||||
getTrackPublicationBySource(participant, Track.Source.ScreenShare)
|
getTrackPublicationBySource(participant, Track.Source.ScreenShare)
|
||||||
|
|
@ -1395,8 +1256,8 @@ export function useInitializeCall() {
|
||||||
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
||||||
room.on(RoomEvent.TrackMuted, onParticipantStateChange);
|
room.on(RoomEvent.TrackMuted, onParticipantStateChange);
|
||||||
room.on(RoomEvent.TrackUnmuted, onParticipantStateChange);
|
room.on(RoomEvent.TrackUnmuted, onParticipantStateChange);
|
||||||
room.on(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
|
room.on(RoomEvent.LocalTrackPublished, onParticipantStateChange);
|
||||||
room.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished);
|
room.on(RoomEvent.LocalTrackUnpublished, onParticipantStateChange);
|
||||||
room.on(RoomEvent.MediaDevicesError, onMediaDeviceFailure);
|
room.on(RoomEvent.MediaDevicesError, onMediaDeviceFailure);
|
||||||
room.on(RoomEvent.EncryptionError, onEncryptionError);
|
room.on(RoomEvent.EncryptionError, onEncryptionError);
|
||||||
room.on(RoomEvent.ConnectionStateChanged, onParticipantStateChange);
|
room.on(RoomEvent.ConnectionStateChanged, onParticipantStateChange);
|
||||||
|
|
@ -1416,8 +1277,8 @@ export function useInitializeCall() {
|
||||||
room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
||||||
room.off(RoomEvent.TrackMuted, onParticipantStateChange);
|
room.off(RoomEvent.TrackMuted, onParticipantStateChange);
|
||||||
room.off(RoomEvent.TrackUnmuted, onParticipantStateChange);
|
room.off(RoomEvent.TrackUnmuted, onParticipantStateChange);
|
||||||
room.off(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
|
room.off(RoomEvent.LocalTrackPublished, onParticipantStateChange);
|
||||||
room.off(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished);
|
room.off(RoomEvent.LocalTrackUnpublished, onParticipantStateChange);
|
||||||
room.off(RoomEvent.MediaDevicesError, onMediaDeviceFailure);
|
room.off(RoomEvent.MediaDevicesError, onMediaDeviceFailure);
|
||||||
room.off(RoomEvent.EncryptionError, onEncryptionError);
|
room.off(RoomEvent.EncryptionError, onEncryptionError);
|
||||||
room.off(RoomEvent.ConnectionStateChanged, onParticipantStateChange);
|
room.off(RoomEvent.ConnectionStateChanged, onParticipantStateChange);
|
||||||
|
|
@ -1426,7 +1287,7 @@ export function useInitializeCall() {
|
||||||
room.disconnect();
|
room.disconnect();
|
||||||
e2eeWorker.terminate();
|
e2eeWorker.terminate();
|
||||||
};
|
};
|
||||||
}, [noiseFilter, load]);
|
}, [noiseFilter]);
|
||||||
|
|
||||||
// fetch call data for preview page
|
// fetch call data for preview page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,6 @@ export default function View() {
|
||||||
const callIsFullscreen = useCall((state) => state.callIsFullscreen);
|
const callIsFullscreen = useCall((state) => state.callIsFullscreen);
|
||||||
|
|
||||||
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
||||||
const focusedParticipantType = useCall(
|
|
||||||
(state) => state.focusedParticipantType,
|
|
||||||
);
|
|
||||||
const activeScreenShareParticipantIds = useCall(
|
const activeScreenShareParticipantIds = useCall(
|
||||||
(state) => state.activeScreenShareParticipantIds,
|
(state) => state.activeScreenShareParticipantIds,
|
||||||
);
|
);
|
||||||
|
|
@ -160,11 +157,6 @@ export default function View() {
|
||||||
const focusedParticipant = getParticipantById(focusedParticipantId);
|
const focusedParticipant = getParticipantById(focusedParticipantId);
|
||||||
const focusedParticipantHasActiveScreenShare =
|
const focusedParticipantHasActiveScreenShare =
|
||||||
activeScreenShareParticipantIdSet.has(focusedParticipantId);
|
activeScreenShareParticipantIdSet.has(focusedParticipantId);
|
||||||
const focusedTileType: "user" | "stream" =
|
|
||||||
focusedParticipantType === "stream" &&
|
|
||||||
focusedParticipantHasActiveScreenShare
|
|
||||||
? "stream"
|
|
||||||
: "user";
|
|
||||||
const isImmersiveFocusedView = callIsFullscreen && usersInFocusedViewHidden;
|
const isImmersiveFocusedView = callIsFullscreen && usersInFocusedViewHidden;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -187,7 +179,7 @@ export default function View() {
|
||||||
<Base
|
<Base
|
||||||
fill={isImmersiveFocusedView}
|
fill={isImmersiveFocusedView}
|
||||||
flush={isImmersiveFocusedView || isFocusedTileFlush}
|
flush={isImmersiveFocusedView || isFocusedTileFlush}
|
||||||
type={focusedTileType}
|
type={focusedParticipantHasActiveScreenShare ? "stream" : "user"}
|
||||||
participant={focusedParticipant}
|
participant={focusedParticipant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
- Speaking indicator
|
||||||
- Overlay for stream modals
|
- Overlay for stream modals
|
||||||
- User modals
|
- User modals
|
||||||
- Bg based on avatar
|
- Bg based on avatar
|
||||||
|
|
@ -10,5 +11,3 @@
|
||||||
- Disconnect
|
- Disconnect
|
||||||
- Desktop-App screenshares
|
- Desktop-App screenshares
|
||||||
- Context menus
|
- Context menus
|
||||||
- Popout Window
|
|
||||||
- If micGated=true & isSpeaking=false for 5 seconds show banner with mic detection
|
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,6 @@ export default function InputComponent({
|
||||||
>
|
>
|
||||||
<CardHeader className="relative p-0 flex flex-col">
|
<CardHeader className="relative p-0 flex flex-col">
|
||||||
<Input
|
<Input
|
||||||
paddingY="13px"
|
|
||||||
paddingX="13px"
|
|
||||||
placeholder="Send a message..."
|
placeholder="Send a message..."
|
||||||
value={value}
|
value={value}
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import {
|
||||||
indentWithTab,
|
indentWithTab,
|
||||||
} from "@codemirror/commands";
|
} from "@codemirror/commands";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
|
|
||||||
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
|
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
|
||||||
|
|
||||||
|
|
@ -34,36 +33,8 @@ export type InputProps = {
|
||||||
setValue: (value: string) => void;
|
setValue: (value: string) => void;
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
invertEnterBehavior?: boolean;
|
invertEnterBehavior?: boolean;
|
||||||
styled?: boolean;
|
|
||||||
fontSize?: CSSProperties["fontSize"];
|
|
||||||
paddingX?: CSSProperties["padding"];
|
|
||||||
paddingY?: CSSProperties["padding"];
|
|
||||||
className?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type InputStyle = CSSProperties & {
|
|
||||||
"--tm-md-content-padding"?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function toCssLength(value: CSSProperties["padding"]): string | undefined {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return typeof value === "number" ? `${value}px` : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCssPadding(
|
|
||||||
vertical: CSSProperties["padding"],
|
|
||||||
horizontal: CSSProperties["padding"],
|
|
||||||
styled: boolean,
|
|
||||||
): string {
|
|
||||||
const defaultVertical = styled ? "0.25rem" : "0";
|
|
||||||
const defaultHorizontal = styled ? "0.625rem" : "0";
|
|
||||||
|
|
||||||
return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TokenRange = {
|
type TokenRange = {
|
||||||
from: number;
|
from: number;
|
||||||
to: number;
|
to: number;
|
||||||
|
|
@ -109,10 +80,6 @@ const markdownDecorations = ViewPlugin.fromClass(
|
||||||
export default function Input(props: InputProps) {
|
export default function Input(props: InputProps) {
|
||||||
ensureMarkdownStyles();
|
ensureMarkdownStyles();
|
||||||
|
|
||||||
const shellClassName = props.styled
|
|
||||||
? "min-h-8 w-full min-w-0 rounded-lg border border-input bg-transparent text-base transition-colors outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewRef = useRef<EditorView | undefined>(undefined);
|
const viewRef = useRef<EditorView | undefined>(undefined);
|
||||||
const ignoreSyncRef = useRef(false);
|
const ignoreSyncRef = useRef(false);
|
||||||
|
|
@ -176,22 +143,7 @@ export default function Input(props: InputProps) {
|
||||||
});
|
});
|
||||||
}, [props.value]);
|
}, [props.value]);
|
||||||
|
|
||||||
return (
|
return <div ref={elementRef} className="tm-md-root w-full" />;
|
||||||
<div
|
|
||||||
ref={elementRef}
|
|
||||||
className={`tm-md-root ${shellClassName} ${props.className ?? ""}`}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
fontSize: props.fontSize ?? "1rem",
|
|
||||||
"--tm-md-content-padding": toCssPadding(
|
|
||||||
props.paddingY,
|
|
||||||
props.paddingX,
|
|
||||||
Boolean(props.styled),
|
|
||||||
),
|
|
||||||
} as InputStyle
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -252,7 +204,7 @@ function createEditorExtensions(
|
||||||
}),
|
}),
|
||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
"&": {
|
"&": {
|
||||||
fontSize: "inherit",
|
fontSize: "1rem",
|
||||||
},
|
},
|
||||||
"&.cm-editor": {
|
"&.cm-editor": {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|
|
||||||
|
|
@ -665,12 +665,12 @@ export const markdownStyles = `
|
||||||
.tm-md-table th { background: hsl(var(--muted)); font-weight: 600; }
|
.tm-md-table th { background: hsl(var(--muted)); font-weight: 600; }
|
||||||
.tm-md-hr { margin: 0.55rem 0; }
|
.tm-md-hr { margin: 0.55rem 0; }
|
||||||
|
|
||||||
.cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); }
|
.cm-editor.tm-md-editor { border-radius: 0.65rem; background: hsl(var(--card)); caret-color: var(--foreground); }
|
||||||
.cm-editor.tm-md-editor.cm-focused { outline: none; box-shadow: none; }
|
.cm-editor.tm-md-editor.cm-focused { outline: none; box-shadow: none; }
|
||||||
.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; }
|
.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; }
|
||||||
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
|
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
|
||||||
.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; }
|
.cm-editor.tm-md-editor .cm-content { padding: 0.7rem 0.85rem; min-height: 2.75rem; }
|
||||||
.cm-editor.tm-md-editor .cm-line { padding: 0; color: hsl(var(--foreground)); }
|
.cm-editor.tm-md-editor .cm-line { padding: 0 1px; color: hsl(var(--foreground)); }
|
||||||
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
|
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
|
||||||
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; }
|
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; }
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -48,31 +48,6 @@ export type Communities = z.infer<
|
||||||
export type Calls = z.infer<typeof ttp.challenge_response.response.shape.calls>;
|
export type Calls = z.infer<typeof ttp.challenge_response.response.shape.calls>;
|
||||||
|
|
||||||
// TTP
|
// TTP
|
||||||
const user = z.object({
|
|
||||||
about: z.string().max(255).optional(),
|
|
||||||
avatar: z.string().optional(),
|
|
||||||
display: z.string().min(1).max(15),
|
|
||||||
iota_id: z.number(),
|
|
||||||
omikron_connections: z.array(z.number()),
|
|
||||||
omikron_id: z.number().optional(),
|
|
||||||
online_status: z.enum([
|
|
||||||
"user_offline",
|
|
||||||
"user_online",
|
|
||||||
"user_dnd",
|
|
||||||
"user_idle",
|
|
||||||
"user_wc",
|
|
||||||
"user_borked",
|
|
||||||
"iota_offline",
|
|
||||||
"iota_online",
|
|
||||||
"iota_borked",
|
|
||||||
]),
|
|
||||||
public_key: z.base64(),
|
|
||||||
status: z.string().max(15).optional(),
|
|
||||||
sub_end: z.number(),
|
|
||||||
sub_level: z.number(),
|
|
||||||
user_id: z.number(),
|
|
||||||
username: z.string().min(1).max(15),
|
|
||||||
});
|
|
||||||
export const ttp = {
|
export const ttp = {
|
||||||
identification: {
|
identification: {
|
||||||
request: z.object({
|
request: z.object({
|
||||||
|
|
@ -117,11 +92,31 @@ export const ttp = {
|
||||||
request: z.object({
|
request: z.object({
|
||||||
user_id: z.number(),
|
user_id: z.number(),
|
||||||
}),
|
}),
|
||||||
response: user,
|
response: z.object({
|
||||||
},
|
about: z.string().max(255).optional(),
|
||||||
change_user_data: {
|
avatar: z.string().optional(),
|
||||||
request: user.partial(),
|
display: z.string().max(15),
|
||||||
response: z.object({}),
|
iota_id: z.number(),
|
||||||
|
omikron_connections: z.array(z.number()),
|
||||||
|
omikron_id: z.number().optional(),
|
||||||
|
online_status: z.enum([
|
||||||
|
"user_offline",
|
||||||
|
"user_online",
|
||||||
|
"user_dnd",
|
||||||
|
"user_idle",
|
||||||
|
"user_wc",
|
||||||
|
"user_borked",
|
||||||
|
"iota_offline",
|
||||||
|
"iota_online",
|
||||||
|
"iota_borked",
|
||||||
|
]),
|
||||||
|
public_key: z.base64(),
|
||||||
|
status: z.string().max(15).optional(),
|
||||||
|
sub_end: z.number(),
|
||||||
|
sub_level: z.number(),
|
||||||
|
user_id: z.number(),
|
||||||
|
username: z.string().max(15),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
ping: {
|
ping: {
|
||||||
request: z.object({
|
request: z.object({
|
||||||
|
|
@ -250,8 +245,6 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
cached_contacts: Contacts;
|
cached_contacts: Contacts;
|
||||||
cached_communities: Communities;
|
cached_communities: Communities;
|
||||||
ttp_url: string;
|
ttp_url: string;
|
||||||
call_mute_range_start: number;
|
|
||||||
call_mute_range_end: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storageDefaults: Storage = {
|
export const storageDefaults: Storage = {
|
||||||
|
|
@ -285,31 +278,4 @@ export const storageDefaults: Storage = {
|
||||||
cached_contacts: [],
|
cached_contacts: [],
|
||||||
cached_communities: [],
|
cached_communities: [],
|
||||||
ttp_url: "https://tensamin.net:959",
|
ttp_url: "https://tensamin.net:959",
|
||||||
call_mute_range_start: -55,
|
|
||||||
call_mute_range_end: -45,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// User Status
|
|
||||||
export function getStatusColor(
|
|
||||||
status: z.infer<typeof ttp.get_user_data.response.shape.online_status>,
|
|
||||||
) {
|
|
||||||
switch (status) {
|
|
||||||
case "user_online":
|
|
||||||
return "#22c55e";
|
|
||||||
case "iota_online":
|
|
||||||
return "#22c55e";
|
|
||||||
case "user_dnd":
|
|
||||||
return "#ef4444";
|
|
||||||
case "user_idle":
|
|
||||||
return "#f59e0b";
|
|
||||||
case "user_wc":
|
|
||||||
return "#3b82f6";
|
|
||||||
case "user_borked":
|
|
||||||
case "iota_borked":
|
|
||||||
return "#6b7280";
|
|
||||||
case "user_offline":
|
|
||||||
case "iota_offline":
|
|
||||||
default:
|
|
||||||
return "#9ca3af";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export default function UserProvider(props: { children: React.ReactNode }) {
|
||||||
const user = {
|
const user = {
|
||||||
...userData.data,
|
...userData.data,
|
||||||
avatar: userData.data.avatar
|
avatar: userData.data.avatar
|
||||||
? `data:image/webp;base64,${atob(userData.data.avatar)}`
|
? `data:image/png;base64,${userData.data.avatar}`
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue