(feat): good call progress, mandatory update due to ttp changes #3

Merged
alois merged 7 commits from dev into main 2026-05-20 23:11:22 +03:00
19 changed files with 1056 additions and 108 deletions

View file

@ -26,6 +26,7 @@
"@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:*",

View file

@ -1,20 +1,58 @@
import type { User } from "@tensamin/user/context"; import type { User } from "@tensamin/user/context";
import { Avatar, AvatarImage, AvatarFallback } from "@tensamin/ui"; import {
import { reduceDisplay } from "./utils"; Avatar,
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(props: { user: User }) { export function Basic({
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">
<Avatar> <div className="relative shrink-0 overflow-visible">
<AvatarImage src={props.user.avatar} /> <Avatar>
<AvatarFallback>{reduceDisplay(props.user.display)}</AvatarFallback> <AvatarImage src={user.avatar} />
</Avatar> <AvatarFallback>
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]"> {user.display.slice(0, 2).toUpperCase()}
<p>{props.user.display}</p> </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>
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
<p>{user.display}</p>
</div>
<div className="pr-2">{extra}</div>
</CardHeader> </CardHeader>
</Card> </Card>
); );

View file

@ -1,13 +0,0 @@
/**
* 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();
}
}

View file

@ -7,6 +7,26 @@ 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";
@ -14,18 +34,168 @@ 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";
* Renders the conversation sidebar with account summary and conversation list. import { useState } from "react";
* On mobile the sidebar is always kept in the DOM and hidden via CSS import type { User } from "@tensamin/user/context";
* (opacity + translateX) instead of being unmounted. This keeps the DOM and import type z from "zod";
* React state alive while the drawer is closed, allowing the sidebar to open import { ttp } from "@tensamin/shared/data";
* instantly on subsequent toggles. import { useTTP } from "@tensamin/ttp";
* @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 = (
<> <>
@ -39,7 +209,67 @@ export default function Sidebar() {
<Wrapper <Wrapper
loading={<Loading />} loading={<Loading />}
userId={"own"} userId={"own"}
component={(user) => <Basic user={user} />} component={(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">

View file

@ -1,3 +1,211 @@
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() {
return <div></div>; const { get } = useUser();
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>
);
} }

View file

@ -55,6 +55,7 @@
"@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:*",
@ -258,7 +259,7 @@
}, },
}, },
"overrides": { "overrides": {
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.17.tar.gz", "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.19.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": {
@ -698,7 +699,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.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/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/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=="],

View file

@ -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.17.tar.gz" "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.19.tar.gz"
}, },
"dependencies": { "dependencies": {
"@tensamin/ttp-core": "*", "@tensamin/ttp-core": "*",

View file

@ -18,10 +18,12 @@ 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);
@ -30,8 +32,9 @@ 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]); }, [screenRef, defaultPortal]);
async function startWebShare() { async function startWebShare() {
try { try {
@ -110,7 +113,9 @@ export default function ScreenshareButton({
/> />
<PopoverContent <PopoverContent
className="flex w-40 flex-col gap-2" className="flex w-40 flex-col gap-2"
portalProps={{ container: portalContainer }} portalProps={{
container: defaultPortal ? undefined : portalContainer,
}}
> >
<Button <Button
disabled={isScreensharing} disabled={isScreensharing}
@ -141,7 +146,11 @@ export default function ScreenshareButton({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{tooltip && ( {tooltip && (
<TooltipContent portalProps={{ container: portalContainer }}> <TooltipContent
portalProps={{
container: defaultPortal ? undefined : portalContainer,
}}
>
{tooltip} {tooltip}
</TooltipContent> </TooltipContent>
)} )}

View file

@ -18,6 +18,7 @@ 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";
@ -103,6 +104,7 @@ 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,
@ -150,12 +152,12 @@ export default function Base({
const onClick = () => { const onClick = () => {
if (view === "grid") { if (view === "grid") {
focusParticipant(user.user_id); focusParticipant(user.user_id, type);
} else { } else {
if (user.user_id === focusedParticipantId) { if (user.user_id === focusedParticipantId) {
setCallView("grid"); setCallView("grid");
} else { } else {
focusParticipant(user.user_id); focusParticipant(user.user_id, type);
} }
} }
}; };
@ -211,9 +213,9 @@ export default function Base({
</div> </div>
<div <div
ref={currentCard} ref={currentCard}
className={`z-10 bg-card absolute top-0 left-0 w-full h-full flex gap-2 items-center justify-center ${ 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 ${
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 */}

View file

@ -20,24 +20,18 @@ 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 portalContainer={portalContainer} /> <ConnectionBar />
</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" /> <ScreenshareButton className="w-9 h-9" defaultPortal />
<LeaveButton className="w-9 h-9" /> <LeaveButton className="w-9 h-9" />
</div> </div>
</CardContent> </CardContent>
@ -45,7 +39,7 @@ export default function SidebarBox() {
); );
} }
function ConnectionBar({ portalContainer }: { portalContainer?: HTMLElement }) { function ConnectionBar() {
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);
@ -68,7 +62,7 @@ function ConnectionBar({ portalContainer }: { portalContainer?: HTMLElement }) {
{state === "closed" && "Closed"} {state === "closed" && "Closed"}
{state === "closing" && "Closing"} {state === "closing" && "Closing"}
<TinyPingGraph portalContainer={portalContainer} /> <TinyPingGraph />
{isEncrypted ? ( {isEncrypted ? (
<Lock color="var(--primary-foreground-alt)" /> <Lock color="var(--primary-foreground-alt)" />
@ -78,18 +72,12 @@ function ConnectionBar({ portalContainer }: { portalContainer?: HTMLElement }) {
</Button> </Button>
} }
/> />
<TooltipContent portalProps={{ container: portalContainer }}> <TooltipContent>Click to open call page</TooltipContent>
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());
@ -176,7 +164,7 @@ export function TinyPingGraph({
</div> </div>
} }
/> />
<TooltipContent portalProps={{ container: portalContainer }}> <TooltipContent>
{data.length > 0 ? `${data.at(-1)?.ping} ms` : "Measuring ping..."} {data.length > 0 ? `${data.at(-1)?.ping} ms` : "Measuring ping..."}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View file

@ -0,0 +1,252 @@
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));
}

View file

@ -11,6 +11,7 @@ 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,
@ -28,6 +29,10 @@ import {
createScreenShareController, createScreenShareController,
type ScreenShareSession, type ScreenShareSession,
} from "./screenshare"; } from "./screenshare";
import {
getSpeakingDetector,
disposeSpeakingDetector,
} from "./speakingIndicator";
// logging // logging
setLogExtension( setLogExtension(
@ -86,6 +91,7 @@ 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[];
@ -99,6 +105,8 @@ 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();
@ -152,7 +160,7 @@ function clearRemoteAudio() {
} }
} }
function getParticipantId(identity: string | undefined): number | null { export function getParticipantId(identity: string | undefined): number | null {
if (!identity) { if (!identity) {
return null; return null;
} }
@ -199,7 +207,10 @@ 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.kind === Track.Kind.Audio); publication.setSubscribed(
publication.kind === Track.Kind.Audio &&
publication.source !== Track.Source.ScreenShareAudio,
);
} }
} }
@ -457,6 +468,8 @@ 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"
@ -614,6 +627,7 @@ 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(
@ -627,6 +641,7 @@ export function startWatchingStream(participantId: number) {
? state.pendingWatchedParticipantIds ? state.pendingWatchedParticipantIds
: [...state.pendingWatchedParticipantIds, participantId], : [...state.pendingWatchedParticipantIds, participantId],
focusedParticipantId: participantId, focusedParticipantId: participantId,
focusedParticipantType: "stream",
})); }));
} }
@ -646,9 +661,13 @@ 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(participantId: number) { export function focusParticipant(
participantId: number,
type: "user" | "stream" = "user",
) {
useCall.setState({ useCall.setState({
focusedParticipantId: participantId, focusedParticipantId: participantId,
focusedParticipantType: type,
view: "focused", view: "focused",
}); });
} }
@ -656,6 +675,11 @@ export function focusParticipant(participantId: number) {
// 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(
@ -668,6 +692,10 @@ 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"
@ -757,6 +785,7 @@ 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 {
@ -782,10 +811,12 @@ 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) => {
@ -879,6 +910,7 @@ export async function toggleDeaf() {
deafened: nextDeaf ? "true" : "false", deafened: nextDeaf ? "true" : "false",
}); });
getSpeakingDetector().setDeaf(nextDeaf);
useCall.setState({ deaf: nextDeaf }); useCall.setState({ deaf: nextDeaf });
} }
@ -940,6 +972,7 @@ 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: [],
@ -967,6 +1000,16 @@ 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>(() => ({
@ -982,6 +1025,7 @@ 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: [],
@ -996,6 +1040,8 @@ 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.
@ -1016,7 +1062,7 @@ export function useInitializeCall() {
new DeepFilterNoiseFilterProcessor({ new DeepFilterNoiseFilterProcessor({
enabled: true, enabled: true,
enableNoiseReduction: true, enableNoiseReduction: true,
noiseReductionLevel: 80, noiseReductionLevel: 60,
sampleRate: 48000, sampleRate: 48000,
assetConfig: { assetConfig: {
cdnUrl: "/assets", cdnUrl: "/assets",
@ -1137,10 +1183,46 @@ export function useInitializeCall() {
listenersRegistered.current = true; listenersRegistered.current = true;
const onConnected = () => { const onConnected = async () => {
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) {
@ -1177,6 +1259,7 @@ export function useInitializeCall() {
if (participantId != null) { if (participantId != null) {
stopWatchingStream(participantId); stopWatchingStream(participantId);
getSpeakingDetector().removeParticipant(participantId);
} }
syncParticipantState(); syncParticipantState();
@ -1197,6 +1280,40 @@ 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,
@ -1204,7 +1321,10 @@ export function useInitializeCall() {
const participantId = getParticipantId(participant.identity); const participantId = getParticipantId(participant.identity);
if (participantId != null) { if (participantId != null) {
if (publication.kind === Track.Kind.Audio) { if (
publication.kind === Track.Kind.Audio &&
publication.source !== Track.Source.ScreenShareAudio
) {
publication.setSubscribed(true); publication.setSubscribed(true);
} else { } else {
publication.setSubscribed(false); publication.setSubscribed(false);
@ -1214,9 +1334,21 @@ export function useInitializeCall() {
onParticipantStateChange(); onParticipantStateChange();
}; };
const onTrackSubscribed = (track: RemoteTrack) => { const onTrackSubscribed = (
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();
@ -1224,15 +1356,22 @@ export function useInitializeCall() {
const onTrackUnsubscribed = ( const onTrackUnsubscribed = (
track: RemoteTrack, track: RemoteTrack,
_publication: unknown, publication: RemoteTrackPublication,
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);
}
const participantId = getParticipantId(participant.identity); if (
participantId != null &&
publication.source === Track.Source.Microphone
) {
getSpeakingDetector().removeParticipant(participantId);
}
}
if ( if (
participantId != null && participantId != null &&
@ -1256,8 +1395,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, onParticipantStateChange); room.on(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
room.on(RoomEvent.LocalTrackUnpublished, onParticipantStateChange); room.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished);
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);
@ -1277,8 +1416,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, onParticipantStateChange); room.off(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
room.off(RoomEvent.LocalTrackUnpublished, onParticipantStateChange); room.off(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished);
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);
@ -1287,7 +1426,7 @@ export function useInitializeCall() {
room.disconnect(); room.disconnect();
e2eeWorker.terminate(); e2eeWorker.terminate();
}; };
}, [noiseFilter]); }, [noiseFilter, load]);
// fetch call data for preview page // fetch call data for preview page
useEffect(() => { useEffect(() => {

View file

@ -16,6 +16,9 @@ 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,
); );
@ -157,6 +160,11 @@ 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 (
@ -179,7 +187,7 @@ export default function View() {
<Base <Base
fill={isImmersiveFocusedView} fill={isImmersiveFocusedView}
flush={isImmersiveFocusedView || isFocusedTileFlush} flush={isImmersiveFocusedView || isFocusedTileFlush}
type={focusedParticipantHasActiveScreenShare ? "stream" : "user"} type={focusedTileType}
participant={focusedParticipant} participant={focusedParticipant}
/> />
</div> </div>

View file

@ -1,4 +1,3 @@
- Speaking indicator
- Overlay for stream modals - Overlay for stream modals
- User modals - User modals
- Bg based on avatar - Bg based on avatar
@ -11,3 +10,5 @@
- 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

View file

@ -97,6 +97,8 @@ 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}

View file

@ -23,6 +23,7 @@ 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";
@ -33,8 +34,36 @@ 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;
@ -80,6 +109,10 @@ 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);
@ -143,7 +176,22 @@ export default function Input(props: InputProps) {
}); });
}, [props.value]); }, [props.value]);
return <div ref={elementRef} className="tm-md-root w-full" />; return (
<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
}
/>
);
} }
/** /**
@ -204,7 +252,7 @@ function createEditorExtensions(
}), }),
EditorView.theme({ EditorView.theme({
"&": { "&": {
fontSize: "1rem", fontSize: "inherit",
}, },
"&.cm-editor": { "&.cm-editor": {
width: "100%", width: "100%",

View file

@ -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: 0.65rem; background: hsl(var(--card)); caret-color: var(--foreground); } .cm-editor.tm-md-editor { border-radius: inherit; background: transparent; 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: 0.7rem 0.85rem; min-height: 2.75rem; } .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-line { padding: 0 1px; color: hsl(var(--foreground)); } .cm-editor.tm-md-editor .cm-line { padding: 0; 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; }
`; `;

View file

@ -48,6 +48,31 @@ 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({
@ -92,31 +117,11 @@ export const ttp = {
request: z.object({ request: z.object({
user_id: z.number(), user_id: z.number(),
}), }),
response: z.object({ response: user,
about: z.string().max(255).optional(), },
avatar: z.string().optional(), change_user_data: {
display: z.string().max(15), request: user.partial(),
iota_id: z.number(), response: z.object({}),
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({
@ -245,6 +250,8 @@ 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 = {
@ -278,4 +285,31 @@ 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";
}
}

View file

@ -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/png;base64,${userData.data.avatar}` ? `data:image/webp;base64,${atob(userData.data.avatar)}`
: undefined, : undefined,
}; };