Compare commits

...
Author SHA1 Message Date
ba59a49f98 (feat): improved conversation adding flow
All checks were successful
/ build-web (push) Successful in 1m8s
/ build-desktop (push) Successful in 11m11s
/ build-mobile (push) Successful in 15m40s
/ release (push) Successful in 21s
(feat): visual tweaks for user modal
(feat): now using full width for profile settings page on mobile
(fix): profile not saving with empty avatar
(chore): bump version due to prod release not getting created
(chore): update licenses
2026-05-21 12:15:37 +02:00
4380d664be (chore): update ttp
All checks were successful
/ build-web (push) Successful in 1m7s
/ build-desktop (push) Successful in 17m48s
/ build-mobile (push) Successful in 27m37s
/ release (push) Successful in 37s
2026-05-20 22:09:58 +02:00
55da8026a7 (feat): add profile page
All checks were successful
/ build-web (push) Successful in 1m12s
/ build-desktop (push) Successful in 11m51s
/ build-mobile (push) Successful in 16m33s
/ release (push) Successful in 23s
(feat): show user online-status
(feat): make markdown input more modular
2026-05-16 14:41:43 +02:00
168694ae86 (fix): speaking indicator broken if screensharing, audio was autosubscribed to
All checks were successful
/ build-web (push) Successful in 1m14s
/ build-desktop (push) Successful in 12m29s
/ build-mobile (push) Successful in 17m1s
/ release (push) Successful in 23s
2026-05-15 19:32:35 +02:00
c795f691bf (feat): improved microphone gate
All checks were successful
/ build-web (push) Successful in 1m16s
/ build-desktop (push) Successful in 14m2s
/ build-mobile (push) Successful in 19m13s
/ release (push) Successful in 24s
(fix): weird behaviour related to users that are screensharing
2026-05-15 16:19:03 +02:00
e76c579bb4 (feat): add speaking indicator
All checks were successful
/ build-web (push) Successful in 1m19s
/ build-desktop (push) Successful in 13m14s
/ build-mobile (push) Successful in 18m45s
/ release (push) Successful in 25s
(feat): add microphone gate
(qol): update todo
2026-05-15 15:43:53 +02:00
f624dc7375 (fix): wrong portal for sidebarBox
All checks were successful
/ build-web (push) Successful in 1m10s
/ build-desktop (push) Successful in 11m34s
/ build-mobile (push) Successful in 16m10s
/ release (push) Successful in 22s
(qol): update todo
2026-05-14 16:05:05 +02:00
8954e4193e (fix): tooltips from the sidebarBox hidden behind sidebar
All checks were successful
/ build-web (push) Successful in 1m8s
/ build-desktop (push) Successful in 11m20s
/ build-mobile (push) Successful in 16m6s
/ release (push) Successful in 22s
2026-05-14 15:48:24 +02:00
28 changed files with 1247 additions and 140 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.5 -right-0.5 z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-card">
<div
style={{
backgroundColor: getStatusColor(user.online_status),
}}
className="h-2.25 w-2.25 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-1">{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

@ -23,7 +23,6 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
const currentCalls = calls.filter((call) => const currentCalls = calls.filter((call) =>
call.call_members.some((member) => member === id), call.call_members.some((member) => member === id),
); );
//const currentCalls = ["leck", "schleck", "und", "eck"];
const [selectOpen, setSelectOpen] = useState(false); const [selectOpen, setSelectOpen] = useState(false);

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="cursor-pointer 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

@ -76,6 +76,16 @@ export default function List() {
</div> </div>
); );
})} })}
<p
className="text-center text-sm px-2 text-muted-foreground transition-all duration-200 ease-in-out"
hidden={contacts.length !== 0 || category !== "conversations"}
style={{
opacity:
contacts.length === 0 && category === "conversations" ? 1 : 0,
}}
>
Get started by adding a conversation
</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -11,12 +11,12 @@ import {
useIsMobile, useIsMobile,
} from "@tensamin/ui"; } from "@tensamin/ui";
import z from "zod"; import z from "zod";
import { toast } from "@tensamin/shared/log";
import { useTTP } from "@tensamin/ttp"; import { useTTP } from "@tensamin/ttp";
import { useState } from "react"; import { useState } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { useSession } from "@tensamin/storage/session";
// The page // The page
export default function Page() { export default function Page() {
@ -43,11 +43,16 @@ export default function Page() {
// Add Conversation Button Component // Add Conversation Button Component
function AddConversationButton() { function AddConversationButton() {
const { send } = useTTP(); const { send } = useTTP();
const { contacts, insertContact } = useSession();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
function submit(username: string | null) { async function submit(username: string | null) {
if (loading) return; if (loading) return;
setError(null);
// username check
const schema = z const schema = z
.string() .string()
.min(1, "Username is too short") .min(1, "Username is too short")
@ -56,26 +61,50 @@ function AddConversationButton() {
const result = schema.safeParse(username?.toLowerCase().trim()); const result = schema.safeParse(username?.toLowerCase().trim());
if (!result.success) { if (!result.success) {
toast("error", result.error.issues[0].message); setError(result.error.issues[0].message);
return; return;
} }
// user existence check
const user = await send("get_user_data", {
username: result.data,
})
.then((data) => {
if (data.data.user_id === 0) {
throw new Error();
}
return data;
})
.catch(() => {
setError("User not found");
return;
});
if (!user) return;
// alrady added check
if (contacts.some((contact) => contact.user_id === user.data.user_id)) {
setError("Conversation already exists");
return;
}
// add the conv
const timeout = setTimeout(() => setLoading(true), 500); const timeout = setTimeout(() => setLoading(true), 500);
send("add_conversation", { send("add_conversation", {
chat_partner_name: result.data, chat_partner_name: result.data,
}) })
.then(() => { .then(() => {
toast("success", "Conversation added"); insertContact(user.data.user_id);
setOpen(false);
}) })
.catch((error) => { .catch((error) => {
if (String(error).includes("error_not_found")) { if (String(error).includes("error_not_found")) {
toast("error", "User not found"); setError("User not found");
return; return;
} }
toast("error", "Failed to add conversation, check console for details"); setError(String(error));
console.error("Failed to add conversation", error);
}) })
.finally(() => { .finally(() => {
clearTimeout(timeout); clearTimeout(timeout);
@ -84,7 +113,15 @@ function AddConversationButton() {
} }
return ( return (
<Dialog> <Dialog
open={open}
onOpenChange={(value) => {
if (value) {
setError(null);
}
setOpen(value);
}}
>
<DialogTrigger render={<Button>Add Conversation</Button>} /> <DialogTrigger render={<Button>Add Conversation</Button>} />
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@ -110,7 +147,10 @@ function AddConversationButton() {
name="username" name="username"
placeholder="Enter username..." placeholder="Enter username..."
/> />
<div className="flex w-full justify-end gap-1"> <div className="flex w-full justify-end items-center gap-1">
{error && (
<p className="text-sm text-destructive w-full">{error}</p>
)}
<DialogClose render={<Button variant="outline">Cancel</Button>} /> <DialogClose render={<Button variant="outline">Cancel</Button>} />
<Button disabled={loading} type="submit"> <Button disabled={loading} type="submit">
{loading && <Loader2 className="animate-spin" />} Continue {loading && <Loader2 className="animate-spin" />} Continue

View file

@ -1,3 +1,218 @@
import { useStorage } from "@tensamin/storage/context";
import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
cn,
Input,
useIsMobile,
} 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 = "";
}
};
const isMobile = useIsMobile();
return currentUser ? (
<>
<input
ref={avatarUploadRef}
hidden
onChange={(e) =>
e.target.files?.[0] && handleAvatarUpload(e.target.files[0])
}
type="file"
/>
<div className={cn("flex flex-col gap-5", isMobile ? "w-full" : "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 { avatar, ...draftUsersWithoutAvatar } = draftUser;
const payload = {
...draftUsersWithoutAvatar,
...(typeof avatar === "string"
? {
avatar: avatar.startsWith("data:")
? (avatar.split(",", 2)[1] ?? "")
: 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

@ -163,18 +163,18 @@ Generated from bun.lock and installed packages in workspace node_modules folders
- Folder: `licenses/@tauri-apps_plugin-opener@2.5.4` - Folder: `licenses/@tauri-apps_plugin-opener@2.5.4`
- Source package dir: `apps/tauri/node_modules/@tauri-apps/plugin-opener` - Source package dir: `apps/tauri/node_modules/@tauri-apps/plugin-opener`
## @tensamin/ttp-core@0.0.15 ## @tensamin/ttp-core@0.0.19
- License: UNKNOWN - License: UNKNOWN
- Included files: LICENSE - Included files: LICENSE
- Folder: `licenses/@tensamin_ttp-core@0.0.15` - Folder: `licenses/@tensamin_ttp-core@0.0.19`
- Source package dir: `packages/ttp/node_modules/@tensamin/ttp-core` - Source package dir: `packages/ttp/node_modules/@tensamin/ttp-core`
## @tensamin/ui@0.0.30 ## @tensamin/ui@0.0.34
- License: UNKNOWN - License: UNKNOWN
- Included files: none found - Included files: none found
- Folder: `licenses/@tensamin_ui@0.0.30` - Folder: `licenses/@tensamin_ui@0.0.34`
- Source package dir: `apps/tauri/node_modules/@tensamin/ui` - Source package dir: `apps/tauri/node_modules/@tensamin/ui`
## @types/node@25.6.0 ## @types/node@25.6.0
@ -449,6 +449,16 @@ Generated from bun.lock and installed packages in workspace node_modules folders
- Folder: `licenses/tailwindcss@4.2.4` - Folder: `licenses/tailwindcss@4.2.4`
- Source package dir: `apps/web/node_modules/tailwindcss` - Source package dir: `apps/web/node_modules/tailwindcss`
## tauri-plugin-app-events-api@0.2.0
- License: MIT
- Homepage: https://github.com/wtto00/tauri-plugin-app-events#readme
- Repository: git+https://github.com/wtto00/tauri-plugin-app-events.git
- Description: A plugin for tauri@v2 to listen some events on iOS and Android.
- Included files: LICENSE
- Folder: `licenses/tauri-plugin-app-events-api@0.2.0`
- Source package dir: `apps/web/node_modules/tauri-plugin-app-events-api`
## tw-animate-css@1.4.0 ## tw-animate-css@1.4.0
- License: MIT - License: MIT

View file

@ -3,7 +3,7 @@
"specVersion": "1.5", "specVersion": "1.5",
"version": 1, "version": 1,
"metadata": { "metadata": {
"timestamp": "2026-05-04T13:05:17.472Z", "timestamp": "2026-05-21T10:15:15.541Z",
"tools": [ "tools": [
{ {
"vendor": "OpenAI", "vendor": "OpenAI",
@ -580,15 +580,15 @@
}, },
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/%40tensamin/ttp-core@0.0.15", "bomRef": "pkg:npm/%40tensamin/ttp-core@0.0.19",
"name": "@tensamin/ttp-core", "name": "@tensamin/ttp-core",
"version": "0.0.15", "version": "0.0.19",
"purl": "pkg:npm/%40tensamin/ttp-core@0.0.15", "purl": "pkg:npm/%40tensamin/ttp-core@0.0.19",
"externalReferences": [], "externalReferences": [],
"properties": [ "properties": [
{ {
"name": "local:licenseFolder", "name": "local:licenseFolder",
"value": "licenses/@tensamin_ttp-core@0.0.15" "value": "licenses/@tensamin_ttp-core@0.0.19"
}, },
{ {
"name": "local:sourcePackageDir", "name": "local:sourcePackageDir",
@ -598,15 +598,15 @@
}, },
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/%40tensamin/ui@0.0.30", "bomRef": "pkg:npm/%40tensamin/ui@0.0.34",
"name": "@tensamin/ui", "name": "@tensamin/ui",
"version": "0.0.30", "version": "0.0.34",
"purl": "pkg:npm/%40tensamin/ui@0.0.30", "purl": "pkg:npm/%40tensamin/ui@0.0.34",
"externalReferences": [], "externalReferences": [],
"properties": [ "properties": [
{ {
"name": "local:licenseFolder", "name": "local:licenseFolder",
"value": "licenses/@tensamin_ui@0.0.30" "value": "licenses/@tensamin_ui@0.0.34"
}, },
{ {
"name": "local:sourcePackageDir", "name": "local:sourcePackageDir",
@ -1562,6 +1562,41 @@
} }
] ]
}, },
{
"type": "library",
"bomRef": "pkg:npm/tauri-plugin-app-events-api@0.2.0",
"name": "tauri-plugin-app-events-api",
"version": "0.2.0",
"purl": "pkg:npm/tauri-plugin-app-events-api@0.2.0",
"description": "A plugin for tauri@v2 to listen some events on iOS and Android.",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://github.com/wtto00/tauri-plugin-app-events#readme"
},
{
"type": "vcs",
"url": "git+https://github.com/wtto00/tauri-plugin-app-events.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/tauri-plugin-app-events-api@0.2.0"
},
{
"name": "local:sourcePackageDir",
"value": "apps/web/node_modules/tauri-plugin-app-events-api"
}
]
},
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/tw-animate-css@1.4.0", "bomRef": "pkg:npm/tw-animate-css@1.4.0",

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 简静凡
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,6 +1,6 @@
{ {
"generatedAt": "2026-05-04T13:05:17.471Z", "generatedAt": "2026-05-21T10:15:15.540Z",
"packageCount": 53, "packageCount": 54,
"packages": [ "packages": [
{ {
"name": "@codemirror/commands", "name": "@codemirror/commands",
@ -227,7 +227,7 @@
}, },
{ {
"name": "@tensamin/ttp-core", "name": "@tensamin/ttp-core",
"version": "0.0.15", "version": "0.0.19",
"license": "UNKNOWN", "license": "UNKNOWN",
"homepage": null, "homepage": null,
"repository": null, "repository": null,
@ -235,18 +235,18 @@
"files": [ "files": [
"LICENSE" "LICENSE"
], ],
"licenseFolder": "licenses/@tensamin_ttp-core@0.0.15", "licenseFolder": "licenses/@tensamin_ttp-core@0.0.19",
"sourcePackageDir": "packages/ttp/node_modules/@tensamin/ttp-core" "sourcePackageDir": "packages/ttp/node_modules/@tensamin/ttp-core"
}, },
{ {
"name": "@tensamin/ui", "name": "@tensamin/ui",
"version": "0.0.30", "version": "0.0.34",
"license": "UNKNOWN", "license": "UNKNOWN",
"homepage": null, "homepage": null,
"repository": null, "repository": null,
"description": null, "description": null,
"files": [], "files": [],
"licenseFolder": "licenses/@tensamin_ui@0.0.30", "licenseFolder": "licenses/@tensamin_ui@0.0.34",
"sourcePackageDir": "apps/tauri/node_modules/@tensamin/ui" "sourcePackageDir": "apps/tauri/node_modules/@tensamin/ui"
}, },
{ {
@ -614,6 +614,19 @@
"licenseFolder": "licenses/tailwindcss@4.2.4", "licenseFolder": "licenses/tailwindcss@4.2.4",
"sourcePackageDir": "apps/web/node_modules/tailwindcss" "sourcePackageDir": "apps/web/node_modules/tailwindcss"
}, },
{
"name": "tauri-plugin-app-events-api",
"version": "0.2.0",
"license": "MIT",
"homepage": "https://github.com/wtto00/tauri-plugin-app-events#readme",
"repository": "git+https://github.com/wtto00/tauri-plugin-app-events.git",
"description": "A plugin for tauri@v2 to listen some events on iOS and Android.",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/tauri-plugin-app-events-api@0.2.0",
"sourcePackageDir": "apps/web/node_modules/tauri-plugin-app-events-api"
},
{ {
"name": "tw-animate-css", "name": "tw-animate-css",
"version": "1.4.0", "version": "1.4.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "tensamin", "name": "tensamin",
"version": "0.0.1", "version": "0.0.2",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
@ -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({
@ -90,33 +115,14 @@ export const ttp = {
}, },
get_user_data: { get_user_data: {
request: z.object({ request: z.object({
user_id: z.number(), user_id: z.number().optional(),
}), username: z.string().optional(),
response: z.object({
about: z.string().max(255).optional(),
avatar: z.string().optional(),
display: z.string().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().max(15),
}), }),
response: user,
},
change_user_data: {
request: user.partial(),
response: z.object({}),
}, },
ping: { ping: {
request: z.object({ request: z.object({
@ -245,6 +251,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 +286,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

@ -14,6 +14,7 @@ interface SessionContextType {
communities: Communities; communities: Communities;
calls: Calls; calls: Calls;
moveUserIdToTop: (userId: number) => void; moveUserIdToTop: (userId: number) => void;
insertContact: (userId: number) => void;
} }
const SessionContext = createContext<SessionContextType | undefined>(undefined); const SessionContext = createContext<SessionContextType | undefined>(undefined);
@ -69,9 +70,31 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
}); });
}; };
const insertContact = (userId: number) => {
setContacts((prevContacts) => {
if (prevContacts.some((contact) => contact.user_id === userId)) {
return prevContacts;
}
const newUser = {
user_id: userId,
last_message_at: new Date().getTime(),
messages: [],
} satisfies Contacts[0];
return [newUser, ...prevContacts];
});
};
return ( return (
<SessionContext.Provider <SessionContext.Provider
value={{ contacts, communities, calls: freshCalls, moveUserIdToTop }} value={{
contacts,
communities,
calls: freshCalls,
moveUserIdToTop,
insertContact,
}}
> >
{children} {children}
</SessionContext.Provider> </SessionContext.Provider>

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,
}; };