(feat): call improvements #18
53 changed files with 2085 additions and 1917 deletions
|
|
@ -1,6 +1,11 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
|
||||
"entry": ["src/index.{ts,tsx,js,jsx}", "src/main.{ts,tsx,js,jsx}"],
|
||||
"entry": [
|
||||
"src/index.{ts,tsx,js,jsx}",
|
||||
"src/main.{ts,tsx,js,jsx}",
|
||||
"apps/tauri/render-version.ts",
|
||||
"packages/**/*.test.ts"
|
||||
],
|
||||
"workspaces": {
|
||||
"packages": ["packages/*", "apps/*"]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"main": "dist/main/main.js",
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist release",
|
||||
"lint": "eslint src",
|
||||
"build:web": "cd ../.. && bun run build:web",
|
||||
"build": "tsc -p tsconfig.json && esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload/preload.cjs",
|
||||
"dev:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron . --verbose",
|
||||
|
|
@ -26,9 +27,7 @@
|
|||
"validate:raw": "bun run build && bun run package:linux:raw",
|
||||
"validate": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run validate:raw; else bun run validate:raw; fi"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.6.2"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"electron": "^39.2.7",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { checkForUpdates } from "./updates.js";
|
||||
import {
|
||||
ipcChannels,
|
||||
type DesktopScreenShareAudioOutput,
|
||||
type DesktopScreenShareCapabilities,
|
||||
} from "../shared/ipc.js";
|
||||
|
||||
|
|
@ -88,7 +89,7 @@ function execJson(command: string, args: string[]) {
|
|||
});
|
||||
}
|
||||
|
||||
async function listAudioOutputs() {
|
||||
async function listAudioOutputs(): Promise<DesktopScreenShareAudioOutput[]> {
|
||||
verboseLog("listAudioOutputs", { platform: process.platform });
|
||||
|
||||
if (process.platform !== "linux") return [];
|
||||
|
|
@ -106,7 +107,9 @@ async function listAudioOutputs() {
|
|||
if (!id || !name) return null;
|
||||
return { id, name, isDefault: false };
|
||||
})
|
||||
.filter(Boolean);
|
||||
.filter(
|
||||
(output): output is DesktopScreenShareAudioOutput => output != null,
|
||||
);
|
||||
}
|
||||
|
||||
async function listScreenShareSources() {
|
||||
|
|
|
|||
|
|
@ -30,10 +30,9 @@
|
|||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-barcode-scanner": "~2",
|
||||
"@tauri-apps/plugin-deep-link": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tensamin/ui": "*",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"lucide-react": "^1.14.0",
|
||||
"@tensamin/ui": "*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
|
|
|
|||
2466
apps/tauri/src-tauri/Cargo.lock
generated
2466
apps/tauri/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,9 @@ tauri-plugin-opener = "2"
|
|||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
ttp-core = { git = "https://git.methanium.net/tensamin/ttp.git", package = "ttp-core" }
|
||||
ttp-native = { git = "https://git.methanium.net/tensamin/ttp.git", package = "ttp-native" }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies.tauri]
|
||||
version = "2"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-minimize",
|
||||
"core:event:default",
|
||||
"deep-link:default"
|
||||
"deep-link:default",
|
||||
"notification:default"
|
||||
]
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
"app-events:default",
|
||||
"barcode-scanner:default",
|
||||
"barcode-scanner:allow-scan",
|
||||
"barcode-scanner:allow-cancel"
|
||||
"barcode-scanner:allow-cancel",
|
||||
"notification:default"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default();
|
||||
let builder = tauri::Builder::default().plugin(tauri_plugin_notification::init());
|
||||
|
||||
let builder = builder
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-router": "^1.169.1",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
|
|
@ -30,21 +29,13 @@
|
|||
"@tensamin/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"@tensamin/notifications": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"comlink": "^4.4.2",
|
||||
"framer-motion": "^12.38.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"shadcn": "^4.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-scrollbar-hide": "^4.0.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tauri-plugin-app-events-api": "^0.2.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"tailwind-scrollbar-hide": "^4.0.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,62 @@
|
|||
import type { User } from "@tensamin/user/context";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Button,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@tensamin/ui";
|
||||
import { toLossySixDigitCode } from "@tensamin/shared/code";
|
||||
import Text from "@tensamin/markdown/text";
|
||||
import { ChevronDown, ChevronUp, Info } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCrypto } from "@tensamin/crypto/context";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useUser } from "@tensamin/user/context";
|
||||
|
||||
export default function Profile({ user }: { user: User }) {
|
||||
const [showAdvancedInformation, setShowAdvancedInformation] = useState(false);
|
||||
const [sharedSecret, setSharedSecret] = useState("");
|
||||
|
||||
const { getSharedSecret } = useCrypto();
|
||||
const { load } = useStorage();
|
||||
const { get } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const ownId = await load("user_id");
|
||||
const privateKey = await load("private_key");
|
||||
const ownData = await get(ownId);
|
||||
const secret = await getSharedSecret(
|
||||
privateKey,
|
||||
ownData.public_key,
|
||||
user.public_key,
|
||||
);
|
||||
|
||||
if (active) {
|
||||
setSharedSecret(secret);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setSharedSecret("");
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [get, getSharedSecret, load, user.public_key]);
|
||||
|
||||
const sharedSecretCode = sharedSecret
|
||||
? toLossySixDigitCode(sharedSecret)
|
||||
: "------";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
|
|
@ -18,17 +72,42 @@ export default function Profile({ user }: { user: User }) {
|
|||
</div>
|
||||
</div>
|
||||
<Text value={user.about || ""} />
|
||||
<div className="flex flex-col">
|
||||
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
iota: {user.iota_id}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
user: {user.user_id}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
pub key: {user.public_key}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline"
|
||||
variant="link"
|
||||
aria-expanded={showAdvancedInformation}
|
||||
onClick={() => setShowAdvancedInformation((show) => !show)}
|
||||
>
|
||||
{showAdvancedInformation ? (
|
||||
<ChevronUp className="size-4" />
|
||||
) : (
|
||||
<ChevronDown className="size-4" />
|
||||
)}
|
||||
<span>Show advanced information</span>
|
||||
</Button>
|
||||
{showAdvancedInformation && (
|
||||
<div className="flex flex-col">
|
||||
<p className="text-white flex gap-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
Shared Code: <code>{sharedSecretCode}</code>{" "}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-3.5" />} />
|
||||
<TooltipContent>
|
||||
You can compare this code with the conversation partner to
|
||||
validate that this chat is E2EE
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
Iota ID: <code>{user.iota_id}</code>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
User ID: <code>{user.user_id}</code>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
Public Key: <code>{user.public_key}</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,8 +33,6 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [selectOpen, setSelectOpen] = useState(false);
|
||||
|
||||
const [userInfoOpen, setUserInfoOpen] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -74,7 +72,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
userId={id}
|
||||
component={(user) =>
|
||||
isMobile ? (
|
||||
<p className="font-medium text-md">{user?.display}</p>
|
||||
<p className="font-medium text-[1.07rem]">{user?.display}</p>
|
||||
) : (
|
||||
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
|
||||
<PopoverTrigger
|
||||
|
|
@ -86,7 +84,9 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
textDecorationLine: "none",
|
||||
}}
|
||||
>
|
||||
<p className="font-medium text-md">{user?.display}</p>
|
||||
<p className="font-medium text-[1.07rem]">
|
||||
{user?.display}
|
||||
</p>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
|
@ -131,15 +131,20 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setSelectOpen((prev) => !prev)}
|
||||
className="w-9! h-9! aspect-square rounded-lg"
|
||||
>
|
||||
<Phone />
|
||||
</Button>
|
||||
<Select onOpenChange={setSelectOpen} open={selectOpen}>
|
||||
<SelectTrigger hidden />
|
||||
<SelectContent>
|
||||
<Select>
|
||||
<SelectTrigger
|
||||
// eslint-disable-next-line
|
||||
render={({ className, ...props }) => (
|
||||
<Button
|
||||
{...props}
|
||||
className="w-9! h-9! aspect-square rounded-lg px-0! flex! justify-center!"
|
||||
variant="default"
|
||||
>
|
||||
<Phone />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<SelectContent className="p-1">
|
||||
{currentCalls.map((call) => (
|
||||
<SelectItem
|
||||
value={call.call_id}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { useIsMobile } from "@tensamin/ui";
|
|||
import { MobileNavbar } from "./navbar";
|
||||
|
||||
import SidebarBox from "@tensamin/call/sidebarBox";
|
||||
import { useShowMobileNavbar } from "@/routes/app/layout";
|
||||
import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar";
|
||||
import { Ellipsis, Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { User } from "@tensamin/user/context";
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ function ContinueButton({
|
|||
);
|
||||
}
|
||||
|
||||
export function BigCheckbox({
|
||||
function BigCheckbox({
|
||||
id,
|
||||
label,
|
||||
checked,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Label } from "@tensamin/ui";
|
||||
import { Switch as UISwitch, Input as UIInput } from "@tensamin/ui";
|
||||
import { Switch as UISwitch } from "@tensamin/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { Storage } from "@tensamin/shared/data";
|
||||
|
|
@ -10,28 +10,6 @@ type BooleanStorageKey = {
|
|||
[K in keyof Storage]: Storage[K] extends boolean ? K : never;
|
||||
}[keyof Storage];
|
||||
|
||||
type StringStorageKey = {
|
||||
[K in keyof Storage]: Storage[K] extends string ? K : never;
|
||||
}[keyof Storage];
|
||||
|
||||
type NumberStorageKey = {
|
||||
[K in keyof Storage]: Storage[K] extends number ? K : never;
|
||||
}[keyof Storage];
|
||||
|
||||
type InputProps =
|
||||
| {
|
||||
label: string;
|
||||
id: StringStorageKey;
|
||||
placeholder?: string;
|
||||
type?: "text";
|
||||
}
|
||||
| {
|
||||
label: string;
|
||||
id: NumberStorageKey;
|
||||
placeholder?: string;
|
||||
type: "number";
|
||||
};
|
||||
|
||||
export function Switch({
|
||||
label,
|
||||
id,
|
||||
|
|
@ -60,49 +38,3 @@ export function Switch({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({ label, id, placeholder, type }: InputProps) {
|
||||
const { save, load } = useStorage();
|
||||
const [value, setValue] = useState<string | number>(
|
||||
type === "number" ? 0 : "",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load(id).then((loadedValue) => {
|
||||
if (type === "number") {
|
||||
if (typeof loadedValue === "number") {
|
||||
setValue(loadedValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof loadedValue === "string") {
|
||||
setValue(loadedValue);
|
||||
}
|
||||
});
|
||||
}, [id, load, type]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<UIInput
|
||||
id={id}
|
||||
type={type || "text"}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
if (type === "number") {
|
||||
const nextValue = Number(e.target.value);
|
||||
setValue(nextValue);
|
||||
save(id, nextValue);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = e.target.value;
|
||||
setValue(nextValue);
|
||||
save(id, nextValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
@source "../../../packages/**/src/**/*.{ts,tsx}";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter Variable", sans-serif;
|
||||
|
||||
--animate-wiggle: wiggle 0.5s ease-in-out infinite;
|
||||
|
||||
@keyframes wiggle {
|
||||
|
|
@ -28,6 +30,10 @@ body,
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter Variable", sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import ChatScreen from "@tensamin/chat/screen";
|
|||
import CallScreen from "@tensamin/call/screen";
|
||||
import Login from "@/routes/screens/login";
|
||||
|
||||
import CallPopout from "@tensamin/call/popout";
|
||||
import ChatContext from "@tensamin/chat/context";
|
||||
import { useInitializeCall } from "@tensamin/call/store";
|
||||
import { Provider as TTPProvider } from "@tensamin/ttp";
|
||||
|
|
@ -147,6 +148,7 @@ function AppShell() {
|
|||
<Session>
|
||||
<UserContext>
|
||||
<CallInit />
|
||||
<CallPopout />
|
||||
<TAuthWrapper>
|
||||
<AppLayout>
|
||||
<ChatContext>
|
||||
|
|
|
|||
|
|
@ -15,27 +15,16 @@ import { useTTP } from "@tensamin/ttp";
|
|||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
|
||||
// The page
|
||||
export default function Page() {
|
||||
const { clear } = useStorage();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className={`px-3 flex gap-2 ${!(isTauri() && isMobile) && "pt-3"}`}>
|
||||
<AddConversationButton />
|
||||
<Button disabled>Add Community</Button>
|
||||
<Button variant="outline" onClick={() => location.reload()}>
|
||||
Reload
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => clear().then(() => location.reload())}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,12 @@ import { type ReactNode } from "react";
|
|||
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import Navbar, { MobileNavbar } from "@/components/navbar";
|
||||
import { useShowMobileNavbar } from "./useShowMobileNavbar";
|
||||
|
||||
import { useIsMobile, cn, SidebarProvider } from "@tensamin/ui";
|
||||
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { useLocation, useMatches } from "@tanstack/react-router";
|
||||
|
||||
/**
|
||||
* Executes Layout.
|
||||
* @param props Parameter props.
|
||||
* @returns unknown.
|
||||
*/
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
const isMobile = useIsMobile();
|
||||
const showMobileNavbar = useShowMobileNavbar();
|
||||
|
|
@ -47,17 +42,3 @@ export default function Layout({ children }: { children: ReactNode }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useShowMobileNavbar(): boolean {
|
||||
const matches = useMatches();
|
||||
const location = useLocation();
|
||||
|
||||
const value = matches.some(
|
||||
(match) =>
|
||||
match.pathname === location.pathname &&
|
||||
// @ts-expect-error Stuff
|
||||
match.staticData?.showMobileNavbar === true,
|
||||
);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
|
|
|||
14
apps/web/src/routes/app/useShowMobileNavbar.ts
Normal file
14
apps/web/src/routes/app/useShowMobileNavbar.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { useLocation, useMatches } from "@tanstack/react-router";
|
||||
|
||||
export function useShowMobileNavbar(): boolean {
|
||||
const matches = useMatches();
|
||||
const location = useLocation();
|
||||
|
||||
return matches.some(
|
||||
(match) =>
|
||||
match.pathname === location.pathname &&
|
||||
// Router staticData is app-defined and not typed by the route matcher here.
|
||||
// @ts-expect-error App route metadata
|
||||
match.staticData?.showMobileNavbar === true,
|
||||
);
|
||||
}
|
||||
55
bun.lock
55
bun.lock
|
|
@ -28,9 +28,6 @@
|
|||
"apps/electron": {
|
||||
"name": "@tensamin/electron",
|
||||
"version": "0.0.3",
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.6.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"electron": "^39.2.7",
|
||||
|
|
@ -46,10 +43,9 @@
|
|||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-barcode-scanner": "~2",
|
||||
"@tauri-apps/plugin-deep-link": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
},
|
||||
|
|
@ -63,7 +59,6 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-router": "^1.169.1",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
|
|
@ -80,20 +75,12 @@
|
|||
"@tensamin/ttp": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"comlink": "^4.4.2",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"shadcn": "^4.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-scrollbar-hide": "^4.0.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tauri-plugin-app-events-api": "^0.2.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
|
|
@ -116,15 +103,11 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "^2.9.20",
|
||||
"@tanstack/react-query": "^5.100.7",
|
||||
"@tanstack/react-router": "^1.169.1",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/markdown": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/tauri": "workspace:*",
|
||||
"@tensamin/ttp": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
|
|
@ -134,7 +117,6 @@
|
|||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.8",
|
||||
},
|
||||
|
|
@ -177,7 +159,6 @@
|
|||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@tensamin/ui": "*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
},
|
||||
|
|
@ -193,9 +174,11 @@
|
|||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/ttp": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
},
|
||||
|
|
@ -207,7 +190,6 @@
|
|||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
},
|
||||
|
|
@ -238,8 +220,6 @@
|
|||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
},
|
||||
"packages/ttp": {
|
||||
|
|
@ -256,7 +236,6 @@
|
|||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tauri-plugin-app-events-api": "^0.2.0",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^10.0.3",
|
||||
|
|
@ -276,7 +255,7 @@
|
|||
},
|
||||
},
|
||||
"overrides": {
|
||||
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.19.tar.gz",
|
||||
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.20.tar.gz",
|
||||
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.36.tar.gz",
|
||||
},
|
||||
"packages": {
|
||||
|
|
@ -740,7 +719,7 @@
|
|||
|
||||
"@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.9", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA=="],
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
|
||||
"@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="],
|
||||
|
||||
"@tensamin/call": ["@tensamin/call@workspace:packages/call"],
|
||||
|
||||
|
|
@ -764,7 +743,7 @@
|
|||
|
||||
"@tensamin/ttp": ["@tensamin/ttp@workspace:packages/ttp"],
|
||||
|
||||
"@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.19.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.59.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "zod": "^4.4.1" } }, "sha512-La9VqXqJFtzzsRQotXVp+3Vr6u8kj4mQ4wTlSIMRDxKFBnbCvtZyB3V/f8HiIlf7FlZ0Xg0suUUpnamvtcvs9w=="],
|
||||
"@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.20.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-08pYuWTivuDWNiBoSSY2P8rIZz4FvuvLQZMjaQnf7KE/Pz9bGO69XHFcCHQXIz1SWsT2TyjochT0sPEGAta15g=="],
|
||||
|
||||
"@tensamin/ui": ["@tensamin/ui@https://git.methanium.net/tensamin/ui/archive/0.0.36.tar.gz", { "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/inter": "^5.2.6", "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-5zql8LtBKVn7WuQDCIdLbthKvLEyxjWShAceXCYbk+kwO41VVILhS1EWuDvjgvv2BBdyuxoVWvdblPIW4BcdSQ=="],
|
||||
|
||||
|
|
@ -1122,8 +1101,6 @@
|
|||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.349", "", {}, "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A=="],
|
||||
|
||||
"electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="],
|
||||
|
||||
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
|
||||
|
||||
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
|
||||
|
|
@ -1256,8 +1233,6 @@
|
|||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
||||
|
|
@ -1476,10 +1451,6 @@
|
|||
|
||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||
|
||||
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||
|
||||
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
|
||||
|
||||
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
||||
|
||||
"loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="],
|
||||
|
|
@ -1528,10 +1499,6 @@
|
|||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"msw": ["msw@2.14.2", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.7", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-D2bTe0tpuf9nw4DA39wFaqUD/hRPKj0DKpo2lAqu+A47Ifg4+h0hbfn6QxVOsiUY2uhgEN6TTpGSHDsc+ysYNg=="],
|
||||
|
|
@ -1770,7 +1737,7 @@
|
|||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shadcn": ["shadcn@4.6.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-4XeMwFf8ZZxmqQQp+U+Nsq2M+cY4Da8Joo/EaMdHVc4uVuWSTJoeidlZ3gDjyxXCjYB1FLcxYwR4lYQAH8emOg=="],
|
||||
"shadcn": ["shadcn@3.8.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
|
|
@ -1850,8 +1817,6 @@
|
|||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
|
@ -2054,8 +2019,6 @@
|
|||
|
||||
"@tensamin/ui/recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="],
|
||||
|
||||
"@tensamin/ui/shadcn": ["shadcn@3.8.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="],
|
||||
|
||||
"@types/cacheable-request/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
|
||||
"@types/fs-extra/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
|
|
@ -2180,10 +2143,6 @@
|
|||
|
||||
"@tensamin/tauri/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"@tensamin/ui/shadcn/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
|
||||
|
||||
"@tensamin/ui/shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@types/cacheable-request/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"@types/fs-extra/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export default [
|
|||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
outputs = {nixpkgs, ...}: let
|
||||
systems = ["x86_64-linux"];
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
version = "0.0.7";
|
||||
x86_64DebHash = "sha256-gRkUEx1T7mrCgFEVp0X/ZiaGrKWzO+cCvv33DKCkB+o=";
|
||||
version = "0.0.8";
|
||||
x86_64DebHash = "sha256-xC8YHVk+JpeMvXHB1dZihYLFyzFDXne4bkV6TZiOFrE=";
|
||||
forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}";
|
||||
in {
|
||||
packages = forAllSystems (system: let
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "tensamin",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.9",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
},
|
||||
"overrides": {
|
||||
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.36.tar.gz",
|
||||
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.19.tar.gz"
|
||||
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.20.tar.gz"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/ttp-core": "*",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
"./store": "./src/store.tsx",
|
||||
"./screen": "./src/screen.tsx",
|
||||
"./utils": "./src/utils.ts",
|
||||
"./sidebarBox": "./src/components/sidebarBox.tsx"
|
||||
"./sidebarBox": "./src/components/sidebarBox.tsx",
|
||||
"./popout": "./src/components/popout.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "bunx prettier --write .",
|
||||
|
|
@ -16,15 +17,11 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "^2.9.20",
|
||||
"@tanstack/react-query": "^5.100.7",
|
||||
"@tanstack/react-router": "^1.169.1",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/markdown": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/tauri": "workspace:*",
|
||||
"@tensamin/ttp": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
|
|
@ -34,7 +31,6 @@
|
|||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.8"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
SquareArrowOutDownLeft,
|
||||
SquareArrowOutUpRight,
|
||||
} from "lucide-react";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
|
||||
export default function Actions() {
|
||||
const sharedClasses = "w-14 h-10";
|
||||
|
|
@ -42,6 +43,12 @@ export default function Actions() {
|
|||
focusedParticipantId != null &&
|
||||
watchedStreamParticipantIds.includes(focusedParticipantId);
|
||||
|
||||
const { load } = useStorage();
|
||||
const [ownId, setOwnId] = useState(0);
|
||||
useEffect(() => {
|
||||
load("user_id").then(setOwnId);
|
||||
}, [load]);
|
||||
|
||||
// Fullscreen stuff
|
||||
const callIsPopout = useCall((state) => state.callIsPopout);
|
||||
const callIsFullscreen = useCall((state) => state.callIsFullscreen);
|
||||
|
|
@ -53,10 +60,13 @@ export default function Actions() {
|
|||
}, [screenRef]);
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
const screen = screenRef?.current;
|
||||
const fullscreenDocument = screen?.ownerDocument ?? document;
|
||||
|
||||
if (callIsFullscreen) {
|
||||
await document.exitFullscreen().catch(() => undefined);
|
||||
await fullscreenDocument.exitFullscreen().catch(() => undefined);
|
||||
} else {
|
||||
await screenRef?.current?.requestFullscreen().catch(() => undefined);
|
||||
await screen?.requestFullscreen().catch(() => undefined);
|
||||
}
|
||||
|
||||
triggerCallLayoutCalculation();
|
||||
|
|
@ -119,7 +129,7 @@ export default function Actions() {
|
|||
iconSize={sharedIconSize}
|
||||
tooltip="Invite"
|
||||
/>
|
||||
{isWatchingFocusedStream ? (
|
||||
{isWatchingFocusedStream && focusedParticipantId !== ownId ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
||||
import { Button } from "@tensamin/ui";
|
||||
import { toggleDeaf, useCall } from "../../store";
|
||||
import { HeadphoneOff, Headphones } from "lucide-react";
|
||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
||||
|
||||
export default function DeafButton({
|
||||
className,
|
||||
iconSize,
|
||||
tooltip,
|
||||
portalContainer,
|
||||
}: {
|
||||
className?: string;
|
||||
iconSize?: number;
|
||||
tooltip?: string;
|
||||
portalContainer?: HTMLElement;
|
||||
}) {
|
||||
}: CallButtonProps) {
|
||||
const deaf = useCall((state) => state.deaf);
|
||||
|
||||
const button = (
|
||||
|
|
@ -22,27 +18,12 @@ export default function DeafButton({
|
|||
className={className}
|
||||
>
|
||||
{deaf ? (
|
||||
<HeadphoneOff
|
||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||
/>
|
||||
<HeadphoneOff style={iconScale(iconSize)} />
|
||||
) : (
|
||||
<Headphones
|
||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||
/>
|
||||
<Headphones style={iconScale(iconSize)} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
return withTooltip(button, tooltip, portalContainer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
import { LeaveIcon } from "@livekit/components-react";
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
||||
import { Button } from "@tensamin/ui";
|
||||
import { disconnect, useCall } from "../../store";
|
||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
||||
|
||||
export default function LeaveButton({
|
||||
className,
|
||||
iconSize,
|
||||
tooltip,
|
||||
portalContainer,
|
||||
}: {
|
||||
className?: string;
|
||||
iconSize?: number;
|
||||
tooltip?: string;
|
||||
portalContainer?: HTMLElement;
|
||||
}) {
|
||||
}: CallButtonProps) {
|
||||
const state = useCall((store) => store.state);
|
||||
|
||||
const button = (
|
||||
|
|
@ -22,20 +18,9 @@ export default function LeaveButton({
|
|||
disabled={state === "closing" || state === "closed"}
|
||||
variant="destructive"
|
||||
>
|
||||
<LeaveIcon style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />
|
||||
<LeaveIcon style={iconScale(iconSize)} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
return withTooltip(button, tooltip, portalContainer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
||||
import { Button } from "@tensamin/ui";
|
||||
import { toggleMute, useCall } from "../../store";
|
||||
import { Mic, MicOff } from "lucide-react";
|
||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
||||
|
||||
export default function MuteButton({
|
||||
className,
|
||||
iconSize,
|
||||
tooltip,
|
||||
portalContainer,
|
||||
}: {
|
||||
className?: string;
|
||||
iconSize?: number;
|
||||
tooltip?: string;
|
||||
portalContainer?: HTMLElement;
|
||||
}) {
|
||||
}: CallButtonProps) {
|
||||
const micEnabled = useCall((state) => state.micEnabled);
|
||||
|
||||
const button = (
|
||||
|
|
@ -22,23 +18,12 @@ export default function MuteButton({
|
|||
onClick={() => void toggleMute()}
|
||||
>
|
||||
{micEnabled ? (
|
||||
<Mic style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />
|
||||
<Mic style={iconScale(iconSize)} />
|
||||
) : (
|
||||
<MicOff style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} />
|
||||
<MicOff style={iconScale(iconSize)} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
return withTooltip(button, tooltip, portalContainer);
|
||||
}
|
||||
|
|
|
|||
32
packages/call/src/components/buttons/tooltipButton.tsx
Normal file
32
packages/call/src/components/buttons/tooltipButton.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { type ReactElement } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
||||
|
||||
export type CallButtonProps = {
|
||||
className?: string;
|
||||
iconSize?: number;
|
||||
tooltip?: string;
|
||||
portalContainer?: HTMLElement;
|
||||
};
|
||||
|
||||
export function iconScale(iconSize?: number) {
|
||||
return { scale: (iconSize ? iconSize + 100 : 100) + "%" };
|
||||
}
|
||||
|
||||
export function withTooltip(
|
||||
button: ReactElement,
|
||||
tooltip?: string,
|
||||
portalContainer?: HTMLElement,
|
||||
) {
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
DialogContent,
|
||||
} from "@tensamin/ui";
|
||||
import Wrapper from "@tensamin/user/wrapper";
|
||||
import { PhoneIncoming, PhoneMissed } from "lucide-react";
|
||||
import { PhoneIncoming, X } from "lucide-react";
|
||||
|
||||
export default function InvitePopup({
|
||||
open,
|
||||
|
|
@ -35,6 +35,13 @@ export default function InvitePopup({
|
|||
</Avatar>
|
||||
<p className="text-xl font-medium">{user.display}</p>
|
||||
<div className="w-full flex justify-center gap-3">
|
||||
<Button
|
||||
className="w-14 h-14"
|
||||
variant="destructive"
|
||||
onClick={() => onAccept(false)}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
className="w-14 h-14"
|
||||
variant="subtleDefault"
|
||||
|
|
@ -42,13 +49,6 @@ export default function InvitePopup({
|
|||
>
|
||||
<PhoneIncoming className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
className="w-14 h-14"
|
||||
variant="destructive"
|
||||
onClick={() => onAccept(false)}
|
||||
>
|
||||
<PhoneMissed className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import {
|
|||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Button,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenu as UIContextMenu,
|
||||
ContextMenuTrigger,
|
||||
} from "@tensamin/ui";
|
||||
import {
|
||||
|
|
@ -18,9 +16,11 @@ import {
|
|||
import { Track, type Participant } from "livekit-client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useUser, type User } from "@tensamin/user/context";
|
||||
import { useIsSpeaking } from "../../speakingIndicator";
|
||||
import { useIsSpeaking } from "../../speakingState";
|
||||
import VideoViewer from "../videoViewer";
|
||||
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";
|
||||
import ContextMenu from "./contextMenu";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
|
||||
function getTrackPublicationBySource(
|
||||
participant: Participant | undefined,
|
||||
|
|
@ -43,6 +43,69 @@ function TransparentButton({ children }: { children: React.ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
function getAverageImageColor(src: string) {
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
const image = new Image();
|
||||
|
||||
image.crossOrigin = "anonymous";
|
||||
image.referrerPolicy = "no-referrer";
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
|
||||
if (!context) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.width = 32;
|
||||
canvas.height = 32;
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
try {
|
||||
const { data } = context.getImageData(
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
let red = 0;
|
||||
let green = 0;
|
||||
let blue = 0;
|
||||
let total = 0;
|
||||
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
const alpha = data[index + 3];
|
||||
|
||||
if (alpha < 128) {
|
||||
continue;
|
||||
}
|
||||
|
||||
red += data[index] * alpha;
|
||||
green += data[index + 1] * alpha;
|
||||
blue += data[index + 2] * alpha;
|
||||
total += alpha;
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const darken = 0.7;
|
||||
|
||||
resolve(
|
||||
`rgb(${Math.round((red / total) * darken)}, ${Math.round((green / total) * darken)}, ${Math.round((blue / total) * darken)})`,
|
||||
);
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
}
|
||||
};
|
||||
image.onerror = () => resolve(undefined);
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function Overlay({
|
||||
type,
|
||||
user,
|
||||
|
|
@ -100,10 +163,14 @@ export default function Base({
|
|||
flush?: boolean;
|
||||
}) {
|
||||
const { get } = useUser();
|
||||
const { load } = useStorage();
|
||||
|
||||
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
||||
const view = useCall((state) => state.view);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const isSpeaking = useIsSpeaking(user?.user_id ?? -1);
|
||||
const screenSharePublication = getTrackPublicationBySource(
|
||||
participant,
|
||||
|
|
@ -111,6 +178,11 @@ export default function Base({
|
|||
);
|
||||
const screenSharePreview = participant?.attributes["screenSharePreview"];
|
||||
|
||||
const [ownId, setOwnId] = useState(0);
|
||||
useEffect(() => {
|
||||
load("user_id").then(setOwnId);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const participantId = Number(participant?.identity);
|
||||
|
||||
|
|
@ -119,7 +191,6 @@ export default function Base({
|
|||
!Number.isInteger(participantId) ||
|
||||
participantId <= 0
|
||||
) {
|
||||
// eslint-disable-next-line
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -137,6 +208,25 @@ export default function Base({
|
|||
};
|
||||
}, [participant, get]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type !== "user" || !user?.avatar) {
|
||||
setAvatarBackgroundColor(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
void getAverageImageColor(user.avatar).then((color) => {
|
||||
if (active) {
|
||||
setAvatarBackgroundColor(color);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [type, user?.avatar]);
|
||||
|
||||
// Avatar calc
|
||||
const currentCard = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -169,7 +259,7 @@ export default function Base({
|
|||
view === "focused" && user.user_id === focusedParticipantId;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<UIContextMenu>
|
||||
<ContextMenuTrigger
|
||||
render={
|
||||
<div
|
||||
|
|
@ -216,7 +306,10 @@ export default function Base({
|
|||
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"
|
||||
} ${type === "user" && isSpeaking ? "border-4 border-(--primary-foreground-alt)/75" : "border-0"}`}
|
||||
style={{ containerType: "size" }}
|
||||
style={{
|
||||
backgroundColor: avatarBackgroundColor,
|
||||
containerType: "size",
|
||||
}}
|
||||
>
|
||||
{/* Detect video / user and place here */}
|
||||
|
||||
|
|
@ -264,9 +357,7 @@ export default function Base({
|
|||
</div>
|
||||
}
|
||||
/>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Stop Watching</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<ContextMenu user={user} ownId={ownId} />
|
||||
</UIContextMenu>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
95
packages/call/src/components/modals/contextMenu.tsx
Normal file
95
packages/call/src/components/modals/contextMenu.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import {
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
Slider,
|
||||
} from "@tensamin/ui";
|
||||
import { User } from "@tensamin/user/context";
|
||||
import { useCall } from "../../store";
|
||||
import { useState } from "react";
|
||||
|
||||
function EmptyCheckboxIndicator({ checked }: { checked: boolean }) {
|
||||
if (checked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="pointer-events-none absolute right-2 size-4 rounded-[4px] border border-current opacity-70" />
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContextMenu({
|
||||
user,
|
||||
ownId,
|
||||
}: {
|
||||
user: User;
|
||||
ownId: number;
|
||||
}) {
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [soundboardMuted, setSoundboardMuted] = useState(false);
|
||||
const [serverDeafened, setServerDeafened] = useState(false);
|
||||
const [serverMuted, setServerMuted] = useState(false);
|
||||
const watchedStreamParticipantIds = useCall(
|
||||
(state) => state.watchedStreamParticipantIds,
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenuContent className="p-1">
|
||||
{watchedStreamParticipantIds.includes(user.user_id) &&
|
||||
user.user_id !== ownId ? (
|
||||
<ContextMenuItem variant="destructive">Stop Watching</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem>Profile</ContextMenuItem>
|
||||
<ContextMenuItem>Change Nickname</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex flex-col items-start pb-2"
|
||||
>
|
||||
<p>Volume</p>
|
||||
<Slider
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuCheckboxItem
|
||||
checked={muted}
|
||||
onCheckedChange={setMuted}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<p>Mute</p>
|
||||
<EmptyCheckboxIndicator checked={muted} />
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
checked={soundboardMuted}
|
||||
onCheckedChange={setSoundboardMuted}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<p>Mute Soundboard</p>
|
||||
<EmptyCheckboxIndicator checked={soundboardMuted} />
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
checked={serverDeafened}
|
||||
onCheckedChange={setServerDeafened}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex justify-between text-destructive focus:text-destructive"
|
||||
>
|
||||
<p>Server Deaf</p>
|
||||
<EmptyCheckboxIndicator checked={serverDeafened} />
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
checked={serverMuted}
|
||||
onCheckedChange={setServerMuted}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex justify-between text-destructive focus:text-destructive"
|
||||
>
|
||||
<p>Server Mute</p>
|
||||
<EmptyCheckboxIndicator checked={serverMuted} />
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuItem variant="destructive">Disconnect</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
);
|
||||
}
|
||||
479
packages/call/src/components/popout.tsx
Normal file
479
packages/call/src/components/popout.tsx
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
import { Participant, Track } from "livekit-client";
|
||||
import VideoViewer from "./videoViewer";
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
import { getRoom, stopWatchingStream, useCall } from "../store";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Button, cn } from "@tensamin/ui";
|
||||
import { ScreenShareOff } from "lucide-react";
|
||||
|
||||
function getTrackPublicationBySource(
|
||||
participant: Participant | undefined,
|
||||
source: Track.Source,
|
||||
) {
|
||||
if (!participant) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [...participant.trackPublications.values()].find(
|
||||
(publication) => publication.source === source,
|
||||
);
|
||||
}
|
||||
|
||||
type Positions = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
type ResizeEdge =
|
||||
| "top"
|
||||
| "right"
|
||||
| "bottom"
|
||||
| "left"
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-right"
|
||||
| "bottom-left";
|
||||
type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const MARGIN = 40;
|
||||
const MIN_SIZE = 240;
|
||||
const MAX_SIZE = 1000;
|
||||
const ASPECT_RATIO = 9 / 16;
|
||||
|
||||
export function Popout({ participant }: { participant: Participant }) {
|
||||
const screenSharePublication = getTrackPublicationBySource(
|
||||
participant,
|
||||
Track.Source.ScreenShare,
|
||||
);
|
||||
|
||||
const popoutRef = useRef<HTMLDivElement>(null);
|
||||
const coordsRef = useRef<Point>({ x: MARGIN, y: MARGIN });
|
||||
const dragOffsetRef = useRef<Point>({ x: 0, y: 0 });
|
||||
const sizeRef = useRef(400);
|
||||
const hideOverlayTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const resizeRef = useRef<{
|
||||
size: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
edge: ResizeEdge;
|
||||
}>({ size: 400, clientX: 0, clientY: 0, edge: "right" });
|
||||
|
||||
const [size, setSize] = useState(400);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isOverlayVisible, setIsOverlayVisible] = useState(false);
|
||||
const [position, setPosition] = useState<Positions>("top-left");
|
||||
const [coords, setCoords] = useState<Point>({ x: MARGIN, y: MARGIN });
|
||||
|
||||
const setCoordsSafe = (next: Point) => {
|
||||
coordsRef.current = next;
|
||||
setCoords(next);
|
||||
};
|
||||
|
||||
const setSizeSafe = (next: number) => {
|
||||
sizeRef.current = next;
|
||||
setSize(next);
|
||||
};
|
||||
|
||||
const getMaxSize = useCallback(() => {
|
||||
const maxWidth = window.innerWidth - MARGIN * 2;
|
||||
const maxHeightWidth = (window.innerHeight - MARGIN * 2) / ASPECT_RATIO;
|
||||
|
||||
return Math.max(MIN_SIZE, Math.min(MAX_SIZE, maxWidth, maxHeightWidth));
|
||||
}, []);
|
||||
|
||||
const clampSize = useCallback(
|
||||
(nextSize: number) => {
|
||||
return Math.min(getMaxSize(), Math.max(MIN_SIZE, nextSize));
|
||||
},
|
||||
[getMaxSize],
|
||||
);
|
||||
|
||||
const getPositionFromPoint = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): Positions => {
|
||||
const height = window.innerHeight / 2 > clientY ? "top" : "bottom";
|
||||
const width = window.innerWidth / 2 > clientX ? "left" : "right";
|
||||
|
||||
return `${height}-${width}` as Positions;
|
||||
};
|
||||
|
||||
const getCoordsForPosition = useCallback(
|
||||
(nextPosition: Positions, nextSize = size): Point => {
|
||||
const width = nextSize;
|
||||
const height = nextSize * ASPECT_RATIO;
|
||||
|
||||
return {
|
||||
x: nextPosition.endsWith("right")
|
||||
? window.innerWidth - width - MARGIN
|
||||
: MARGIN,
|
||||
y: nextPosition.startsWith("bottom")
|
||||
? window.innerHeight - height - MARGIN
|
||||
: MARGIN,
|
||||
};
|
||||
},
|
||||
[size],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) return;
|
||||
|
||||
setCoordsSafe(getCoordsForPosition(position));
|
||||
}, [position, size, isDragging, getCoordsForPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (isDragging) return;
|
||||
|
||||
const nextSize = clampSize(size);
|
||||
|
||||
setSizeSafe(nextSize);
|
||||
setCoordsSafe(getCoordsForPosition(position, nextSize));
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [position, size, isDragging, getCoordsForPosition, clampSize]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideOverlayTimeoutRef.current) {
|
||||
clearTimeout(hideOverlayTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleOverlayHide = () => {
|
||||
if (hideOverlayTimeoutRef.current) {
|
||||
clearTimeout(hideOverlayTimeoutRef.current);
|
||||
}
|
||||
|
||||
hideOverlayTimeoutRef.current = setTimeout(() => {
|
||||
setIsOverlayVisible(false);
|
||||
hideOverlayTimeoutRef.current = null;
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const showOverlay = () => {
|
||||
setIsOverlayVisible(true);
|
||||
scheduleOverlayHide();
|
||||
};
|
||||
|
||||
const hideOverlay = () => {
|
||||
if (hideOverlayTimeoutRef.current) {
|
||||
clearTimeout(hideOverlayTimeoutRef.current);
|
||||
hideOverlayTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setIsOverlayVisible(false);
|
||||
};
|
||||
|
||||
const getCornerResizeDelta = (
|
||||
horizontalDelta: number,
|
||||
verticalDelta: number,
|
||||
) => {
|
||||
return Math.abs(horizontalDelta) > Math.abs(verticalDelta)
|
||||
? horizontalDelta
|
||||
: verticalDelta;
|
||||
};
|
||||
|
||||
const getResizeDelta = (e: React.PointerEvent, edge: ResizeEdge) => {
|
||||
switch (edge) {
|
||||
case "left":
|
||||
return resizeRef.current.clientX - e.clientX;
|
||||
case "right":
|
||||
return e.clientX - resizeRef.current.clientX;
|
||||
case "top":
|
||||
return (resizeRef.current.clientY - e.clientY) / ASPECT_RATIO;
|
||||
case "bottom":
|
||||
return (e.clientY - resizeRef.current.clientY) / ASPECT_RATIO;
|
||||
case "top-left":
|
||||
return getCornerResizeDelta(
|
||||
resizeRef.current.clientX - e.clientX,
|
||||
(resizeRef.current.clientY - e.clientY) / ASPECT_RATIO,
|
||||
);
|
||||
case "top-right":
|
||||
return getCornerResizeDelta(
|
||||
e.clientX - resizeRef.current.clientX,
|
||||
(resizeRef.current.clientY - e.clientY) / ASPECT_RATIO,
|
||||
);
|
||||
case "bottom-right":
|
||||
return getCornerResizeDelta(
|
||||
e.clientX - resizeRef.current.clientX,
|
||||
(e.clientY - resizeRef.current.clientY) / ASPECT_RATIO,
|
||||
);
|
||||
case "bottom-left":
|
||||
return getCornerResizeDelta(
|
||||
resizeRef.current.clientX - e.clientX,
|
||||
(e.clientY - resizeRef.current.clientY) / ASPECT_RATIO,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const startResize = (
|
||||
e: React.PointerEvent<HTMLDivElement>,
|
||||
edge: ResizeEdge,
|
||||
) => {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
|
||||
resizeRef.current = {
|
||||
size,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
edge,
|
||||
};
|
||||
|
||||
setIsResizing(true);
|
||||
};
|
||||
|
||||
const resizePopout = (e: React.PointerEvent) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const nextSize = clampSize(
|
||||
resizeRef.current.size + getResizeDelta(e, resizeRef.current.edge),
|
||||
);
|
||||
|
||||
setSizeSafe(nextSize);
|
||||
setCoordsSafe(getCoordsForPosition(position, nextSize));
|
||||
};
|
||||
|
||||
const stopResize = (e: React.PointerEvent) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
e.stopPropagation();
|
||||
setIsResizing(false);
|
||||
setCoordsSafe(getCoordsForPosition(position, sizeRef.current));
|
||||
};
|
||||
|
||||
const resizeEdgeClassName = "absolute z-10 bg-transparent";
|
||||
const resizeCornerClassName = "absolute z-20 h-4 w-4 bg-transparent";
|
||||
|
||||
if (!screenSharePublication) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoutRef}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
if (isResizing) return;
|
||||
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
|
||||
const current = coordsRef.current;
|
||||
|
||||
dragOffsetRef.current = {
|
||||
x: e.clientX - current.x,
|
||||
y: e.clientY - current.y,
|
||||
};
|
||||
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
setCoordsSafe({
|
||||
x: e.clientX - dragOffsetRef.current.x,
|
||||
y: e.clientY - dragOffsetRef.current.y,
|
||||
});
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const nextPosition = getPositionFromPoint(e.clientX, e.clientY);
|
||||
|
||||
setPosition(nextPosition);
|
||||
setIsDragging(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setCoordsSafe(getCoordsForPosition(nextPosition));
|
||||
});
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setIsDragging(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setCoordsSafe(getCoordsForPosition(position));
|
||||
});
|
||||
}}
|
||||
onMouseEnter={showOverlay}
|
||||
onMouseMove={showOverlay}
|
||||
onMouseLeave={hideOverlay}
|
||||
className={cn(
|
||||
"fixed left-0 top-0 aspect-video z-200 rounded-lg border-2 border-muted-foreground bg-black",
|
||||
"select-none touch-none",
|
||||
isDragging ? "cursor-grabbing" : "cursor-grab",
|
||||
)}
|
||||
style={{
|
||||
width: size,
|
||||
transform: `translate3d(${coords.x}px, ${coords.y}px, 0)`,
|
||||
transition:
|
||||
isDragging || isResizing
|
||||
? "none"
|
||||
: "transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1)",
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
<VideoViewer
|
||||
participantId={participant.identity}
|
||||
publication={screenSharePublication}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-40 transition-opacity duration-200",
|
||||
isOverlayVisible ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/55 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/65 to-transparent" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-3 right-3 z-50 transition-all duration-200",
|
||||
isOverlayVisible
|
||||
? "opacity-100 translate-y-0 pointer-events-auto"
|
||||
: "opacity-0 translate-y-2 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div className="bg-[#070707] h-10 w-14 rounded-lg">
|
||||
<Button
|
||||
className="w-full h-full border-0!"
|
||||
variant="destructive"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
stopWatchingStream(Number(participant.identity ?? 0));
|
||||
}}
|
||||
>
|
||||
<ScreenShareOff style={{ scale: "115%" }} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Resize popout from top edge"
|
||||
role="separator"
|
||||
className={cn(
|
||||
resizeEdgeClassName,
|
||||
"inset-x-4 -top-1 h-2 cursor-n-resize",
|
||||
)}
|
||||
onPointerDown={(e) => startResize(e, "top")}
|
||||
onPointerMove={resizePopout}
|
||||
onPointerUp={stopResize}
|
||||
onPointerCancel={stopResize}
|
||||
/>
|
||||
<div
|
||||
aria-label="Resize popout from right edge"
|
||||
role="separator"
|
||||
className={cn(
|
||||
resizeEdgeClassName,
|
||||
"-right-1 inset-y-4 w-2 cursor-e-resize",
|
||||
)}
|
||||
onPointerDown={(e) => startResize(e, "right")}
|
||||
onPointerMove={resizePopout}
|
||||
onPointerUp={stopResize}
|
||||
onPointerCancel={stopResize}
|
||||
/>
|
||||
<div
|
||||
aria-label="Resize popout from bottom edge"
|
||||
role="separator"
|
||||
className={cn(
|
||||
resizeEdgeClassName,
|
||||
"inset-x-4 -bottom-1 h-2 cursor-s-resize",
|
||||
)}
|
||||
onPointerDown={(e) => startResize(e, "bottom")}
|
||||
onPointerMove={resizePopout}
|
||||
onPointerUp={stopResize}
|
||||
onPointerCancel={stopResize}
|
||||
/>
|
||||
<div
|
||||
aria-label="Resize popout from left edge"
|
||||
role="separator"
|
||||
className={cn(
|
||||
resizeEdgeClassName,
|
||||
"-left-1 inset-y-4 w-2 cursor-w-resize",
|
||||
)}
|
||||
onPointerDown={(e) => startResize(e, "left")}
|
||||
onPointerMove={resizePopout}
|
||||
onPointerUp={stopResize}
|
||||
onPointerCancel={stopResize}
|
||||
/>
|
||||
<div
|
||||
aria-label="Resize popout from top left corner"
|
||||
role="separator"
|
||||
className={cn(resizeCornerClassName, "-left-1 -top-1 cursor-nw-resize")}
|
||||
onPointerDown={(e) => startResize(e, "top-left")}
|
||||
onPointerMove={resizePopout}
|
||||
onPointerUp={stopResize}
|
||||
onPointerCancel={stopResize}
|
||||
/>
|
||||
<div
|
||||
aria-label="Resize popout from top right corner"
|
||||
role="separator"
|
||||
className={cn(
|
||||
resizeCornerClassName,
|
||||
"-right-1 -top-1 cursor-ne-resize",
|
||||
)}
|
||||
onPointerDown={(e) => startResize(e, "top-right")}
|
||||
onPointerMove={resizePopout}
|
||||
onPointerUp={stopResize}
|
||||
onPointerCancel={stopResize}
|
||||
/>
|
||||
<div
|
||||
aria-label="Resize popout from bottom right corner"
|
||||
role="separator"
|
||||
className={cn(
|
||||
resizeCornerClassName,
|
||||
"-bottom-1 -right-1 cursor-se-resize",
|
||||
)}
|
||||
onPointerDown={(e) => startResize(e, "bottom-right")}
|
||||
onPointerMove={resizePopout}
|
||||
onPointerUp={stopResize}
|
||||
onPointerCancel={stopResize}
|
||||
/>
|
||||
<div
|
||||
aria-label="Resize popout from bottom left corner"
|
||||
role="separator"
|
||||
className={cn(
|
||||
resizeCornerClassName,
|
||||
"-bottom-1 -left-1 cursor-sw-resize",
|
||||
)}
|
||||
onPointerDown={(e) => startResize(e, "bottom-left")}
|
||||
onPointerMove={resizePopout}
|
||||
onPointerUp={stopResize}
|
||||
onPointerCancel={stopResize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Wrapper() {
|
||||
const room = getRoom();
|
||||
const { pathname } = useLocation();
|
||||
const state = useCall((state) => state.state);
|
||||
const watchedStreamParticipantIds = useCall(
|
||||
(state) => state.watchedStreamParticipantIds,
|
||||
);
|
||||
const lastFocusedParticipantId = useCall(
|
||||
(state) => state.lastFocusedParticipantId,
|
||||
);
|
||||
const participant = room.getParticipantByIdentity(
|
||||
String(lastFocusedParticipantId),
|
||||
);
|
||||
|
||||
if (!participant || !lastFocusedParticipantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const active =
|
||||
!pathname.startsWith("/call") &&
|
||||
state === "open" &&
|
||||
watchedStreamParticipantIds.includes(Number(participant.identity ?? 0));
|
||||
|
||||
return active && <Popout participant={participant} />;
|
||||
}
|
||||
|
|
@ -92,7 +92,6 @@ export default function ScreenShareDialog({
|
|||
|
||||
let active = true;
|
||||
|
||||
// eslint-disable-next-line
|
||||
setLoading(true);
|
||||
setSelectedSourceId(null);
|
||||
setSelectedAudioOutputId(NONE_AUDIO_OUTPUT);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export default function TopBar() {
|
|||
<TooltipTrigger
|
||||
render={
|
||||
<Avatar className="size-7">
|
||||
<AvatarImage />
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{user.display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
import { useCall } from "./store";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
setCallIsFullscreen,
|
||||
setCallIsPopout,
|
||||
triggerCallLayoutCalculation,
|
||||
useCall,
|
||||
} from "./store";
|
||||
|
||||
import MainLayout from "./views/main/layout";
|
||||
import MainGrid from "./views/main/grid";
|
||||
import MainFocused from "./views/main/focused";
|
||||
import Preview from "./views/preview";
|
||||
|
||||
export default function Screen() {
|
||||
function ScreenContent() {
|
||||
const view = useCall((state) => state.view);
|
||||
|
||||
return view === "preview" ? (
|
||||
|
|
@ -14,3 +21,112 @@ export default function Screen() {
|
|||
<MainLayout>{view === "grid" ? <MainGrid /> : <MainFocused />}</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Popout Window
|
||||
function copyDocumentStyles(targetDocument: Document) {
|
||||
for (const node of document.querySelectorAll(
|
||||
'link[rel="stylesheet"], style',
|
||||
)) {
|
||||
targetDocument.head.appendChild(node.cloneNode(true));
|
||||
}
|
||||
}
|
||||
|
||||
function syncDocumentClasses(targetDocument: Document) {
|
||||
targetDocument.documentElement.className = document.documentElement.className;
|
||||
targetDocument.body.className = document.body.className;
|
||||
}
|
||||
|
||||
function PopoutScreen() {
|
||||
const closeCheckRef = useRef<number | null>(null);
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const popoutWindow = window.open(
|
||||
"",
|
||||
"tensamin-call-popout",
|
||||
"popup,width=1280,height=720",
|
||||
);
|
||||
|
||||
if (!popoutWindow) {
|
||||
setCallIsFullscreen(false);
|
||||
setCallIsPopout(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
popoutWindow.document.title = document.title;
|
||||
popoutWindow.document.body.innerHTML = "";
|
||||
popoutWindow.document.body.style.margin = "0";
|
||||
popoutWindow.document.documentElement.style.height = "100%";
|
||||
popoutWindow.document.body.style.height = "100%";
|
||||
|
||||
copyDocumentStyles(popoutWindow.document);
|
||||
syncDocumentClasses(popoutWindow.document);
|
||||
|
||||
const containerElement = popoutWindow.document.createElement("div");
|
||||
containerElement.style.width = "100%";
|
||||
containerElement.style.height = "100%";
|
||||
popoutWindow.document.body.appendChild(containerElement);
|
||||
queueMicrotask(() => {
|
||||
if (isMounted) {
|
||||
setContainer(containerElement);
|
||||
}
|
||||
});
|
||||
|
||||
const closePopout = () => {
|
||||
setCallIsFullscreen(false);
|
||||
setCallIsPopout(false);
|
||||
};
|
||||
const syncLayout = () => triggerCallLayoutCalculation();
|
||||
|
||||
popoutWindow.addEventListener("beforeunload", closePopout);
|
||||
popoutWindow.addEventListener("resize", syncLayout);
|
||||
popoutWindow.focus();
|
||||
|
||||
closeCheckRef.current = window.setInterval(() => {
|
||||
if (popoutWindow.closed) {
|
||||
closePopout();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
triggerCallLayoutCalculation();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
popoutWindow.removeEventListener("beforeunload", closePopout);
|
||||
popoutWindow.removeEventListener("resize", syncLayout);
|
||||
|
||||
if (closeCheckRef.current) {
|
||||
window.clearInterval(closeCheckRef.current);
|
||||
closeCheckRef.current = null;
|
||||
}
|
||||
|
||||
if (!popoutWindow.closed) {
|
||||
popoutWindow.close();
|
||||
}
|
||||
|
||||
setCallIsFullscreen(false);
|
||||
triggerCallLayoutCalculation();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(<ScreenContent />, container);
|
||||
}
|
||||
|
||||
export default function Screen() {
|
||||
const callIsPopout = useCall((state) => state.callIsPopout);
|
||||
|
||||
return callIsPopout ? (
|
||||
<div className="flex justify-center items-center h-full w-full">
|
||||
<p>The window is popped out.</p>
|
||||
<PopoutScreen />
|
||||
</div>
|
||||
) : (
|
||||
<ScreenContent />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { log } from "@tensamin/shared/log";
|
||||
import { useCall } from "./store";
|
||||
import {
|
||||
clearSpeakingParticipants,
|
||||
removeSpeakingParticipant,
|
||||
setMicGated,
|
||||
updateSpeakingParticipants,
|
||||
} from "./speakingState";
|
||||
|
||||
const SPEAKING_THRESHOLD = 0.01;
|
||||
const SPEAKING_HANGTIME_MS = 500;
|
||||
|
|
@ -85,12 +90,7 @@ class SpeakingDetector {
|
|||
}
|
||||
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 };
|
||||
});
|
||||
removeSpeakingParticipant(participantId);
|
||||
|
||||
if (participantId === this.localParticipantId && this.localMicGateClosed) {
|
||||
this.muteLocalTrack(false);
|
||||
|
|
@ -104,7 +104,7 @@ class SpeakingDetector {
|
|||
entry.isSpeaking = false;
|
||||
entry.lastSpeakingTime = 0;
|
||||
}
|
||||
useCall.setState({ speakingParticipantIds: new Set() });
|
||||
clearSpeakingParticipants();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ class SpeakingDetector {
|
|||
}
|
||||
|
||||
this.localMicGateClosed = muted;
|
||||
useCall.setState({ micGated: muted });
|
||||
setMicGated(muted);
|
||||
}
|
||||
|
||||
private applyNoiseGate(rms: number) {
|
||||
|
|
@ -197,24 +197,7 @@ class SpeakingDetector {
|
|||
}
|
||||
|
||||
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;
|
||||
});
|
||||
updateSpeakingParticipants(changed);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +229,3 @@ export function disposeSpeakingDetector(): void {
|
|||
detectorInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useIsSpeaking(participantId: number): boolean {
|
||||
return useCall((state) => state.speakingParticipantIds.has(participantId));
|
||||
}
|
||||
|
|
|
|||
55
packages/call/src/speakingState.ts
Normal file
55
packages/call/src/speakingState.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
type SpeakingState = {
|
||||
speakingParticipantIds: Set<number>;
|
||||
micGated: boolean;
|
||||
};
|
||||
|
||||
const useSpeakingState = create<SpeakingState>(() => ({
|
||||
speakingParticipantIds: new Set(),
|
||||
micGated: false,
|
||||
}));
|
||||
|
||||
export function setMicGated(micGated: boolean) {
|
||||
useSpeakingState.setState({ micGated });
|
||||
}
|
||||
|
||||
export function clearSpeakingParticipants() {
|
||||
useSpeakingState.setState({ speakingParticipantIds: new Set() });
|
||||
}
|
||||
|
||||
export function removeSpeakingParticipant(participantId: number) {
|
||||
useSpeakingState.setState((state) => {
|
||||
if (!state.speakingParticipantIds.has(participantId)) return state;
|
||||
const next = new Set(state.speakingParticipantIds);
|
||||
next.delete(participantId);
|
||||
return { speakingParticipantIds: next };
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSpeakingParticipants(changed: Map<number, boolean>) {
|
||||
useSpeakingState.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;
|
||||
});
|
||||
}
|
||||
|
||||
export function useIsSpeaking(participantId: number): boolean {
|
||||
return useSpeakingState((state) =>
|
||||
state.speakingParticipantIds.has(participantId),
|
||||
);
|
||||
}
|
||||
|
|
@ -109,8 +109,7 @@ type CallStore = {
|
|||
layoutVersion: number;
|
||||
screenRef: React.RefObject<HTMLDivElement | null> | null;
|
||||
runtime: Runtime | null;
|
||||
speakingParticipantIds: Set<number>;
|
||||
micGated: boolean;
|
||||
lastFocusedParticipantId: number | null;
|
||||
};
|
||||
|
||||
let _keyProvider: ExternalE2EEKeyProvider | null = null;
|
||||
|
|
@ -697,6 +696,7 @@ export function focusParticipant(
|
|||
) {
|
||||
useCall.setState({
|
||||
focusedParticipantId: participantId,
|
||||
lastFocusedParticipantId: participantId,
|
||||
focusedParticipantType: type,
|
||||
view: "focused",
|
||||
});
|
||||
|
|
@ -857,7 +857,8 @@ export async function disconnect() {
|
|||
watchedStreamParticipantIds: [],
|
||||
pendingWatchedParticipantIds: [],
|
||||
activeScreenShareParticipantIds: [],
|
||||
micGated: false,
|
||||
callIsFullscreen: false,
|
||||
lastFocusedParticipantId: null,
|
||||
});
|
||||
|
||||
getRoom().remoteParticipants.forEach((participant) => {
|
||||
|
|
@ -1019,6 +1020,7 @@ export function resetCallState() {
|
|||
watchedStreamParticipantIds: [],
|
||||
pendingWatchedParticipantIds: [],
|
||||
activeScreenShareParticipantIds: [],
|
||||
lastFocusedParticipantId: null,
|
||||
});
|
||||
|
||||
syncParticipantState();
|
||||
|
|
@ -1079,8 +1081,7 @@ export const useCall = create<CallStore>(() => ({
|
|||
layoutVersion: 0,
|
||||
screenRef: null,
|
||||
runtime: null,
|
||||
speakingParticipantIds: new Set(),
|
||||
micGated: false,
|
||||
lastFocusedParticipantId: null,
|
||||
}));
|
||||
|
||||
// Register app-level call listeners and wire React dependencies into the store.
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import { z } from "zod";
|
||||
import { ttp } from "@tensamin/shared/data";
|
||||
|
||||
export type RawMessages = z.infer<typeof ttp.messages_get.response>["messages"];
|
||||
|
||||
export type RawMessage = RawMessages[number];
|
||||
|
||||
export type LiveMessage = RawMessage & {
|
||||
failed?: boolean;
|
||||
localId: string;
|
||||
};
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import Actions from "../../components/actions";
|
||||
import TopBar from "../../components/top";
|
||||
import { setScreenRef, useCall } from "../../store";
|
||||
import {
|
||||
setCallIsFullscreen,
|
||||
setScreenRef,
|
||||
triggerCallLayoutCalculation,
|
||||
useCall,
|
||||
} from "../../store";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const screenRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -16,6 +21,30 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||
|
||||
useEffect(() => {
|
||||
setScreenRef(screenRef);
|
||||
|
||||
const screen = screenRef.current;
|
||||
const fullscreenDocument = screen?.ownerDocument;
|
||||
|
||||
if (!screen || !fullscreenDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleFullscreenChange = () => {
|
||||
setCallIsFullscreen(fullscreenDocument.fullscreenElement === screen);
|
||||
triggerCallLayoutCalculation();
|
||||
};
|
||||
|
||||
fullscreenDocument.addEventListener(
|
||||
"fullscreenchange",
|
||||
handleFullscreenChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
fullscreenDocument.removeEventListener(
|
||||
"fullscreenchange",
|
||||
handleFullscreenChange,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const usersInFocusedViewHidden = useCall(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
- Overlay for stream modals
|
||||
- User modals
|
||||
- Bg based on avatar
|
||||
- Mobile
|
||||
- Call invite popup
|
||||
- Save call invite in `calls` array
|
||||
- Sounds
|
||||
- Admin call actions
|
||||
- Timeout
|
||||
- Disconnect
|
||||
- Context menus
|
||||
- Popout Window
|
||||
- Implement context menu features
|
||||
- Add quality selection
|
||||
- Good preview page
|
||||
|
|
|
|||
|
|
@ -395,49 +395,12 @@ export async function getSharedSecret(
|
|||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns WebCrypto subtle API when available.
|
||||
* @returns SubtleCrypto instance or undefined.
|
||||
*/
|
||||
const getSubtle = () => globalThis.crypto?.subtle;
|
||||
|
||||
{
|
||||
/*
|
||||
const hkdfAesGcmFromShared = async (
|
||||
sharedSecret: BufferSource,
|
||||
infoStr: string
|
||||
): Promise<CryptoKey> => {
|
||||
const subtle = getSubtle();
|
||||
if (!subtle) throw new Error("WebCrypto subtle not available");
|
||||
const info = textEncoder.encode(infoStr);
|
||||
const baseKey = await subtle.importKey(
|
||||
"raw",
|
||||
sharedSecret,
|
||||
"HKDF",
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
return await subtle.deriveKey(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: new Uint8Array(0),
|
||||
info,
|
||||
},
|
||||
baseKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
};
|
||||
*/
|
||||
}
|
||||
|
||||
const myJwk: JWK = normalizeOkpX448Jwk(ownJwk, "own_jwk");
|
||||
const peerJwk: JWK = normalizeOkpX448Jwk(otherJwk, "other_jwk");
|
||||
|
||||
const subtle = getSubtle();
|
||||
//const infoStr = `ECDH-X448-AES-GCM-v1|my=${myJwk.x}|peer=${peerJwk.x}`;
|
||||
|
||||
if (subtle) {
|
||||
const algorithms = [{ name: "ECDH", namedCurve: "X448" }, { name: "X448" }];
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@tensamin/ui": "*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ const INLINE_TOKEN_REGEX =
|
|||
* @param input Parameter input.
|
||||
* @returns InlineNode[].
|
||||
*/
|
||||
export function parseInlineNodes(input: string): InlineNode[] {
|
||||
function parseInlineNodes(input: string): InlineNode[] {
|
||||
const nodes: InlineNode[] = [];
|
||||
|
||||
let cursor = 0;
|
||||
|
|
@ -369,7 +369,7 @@ export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
|
|||
* @param nodes Parameter nodes.
|
||||
* @returns React.ReactNode[].
|
||||
*/
|
||||
export function renderInline(nodes: InlineNode[]): React.ReactNode[] {
|
||||
function renderInline(nodes: InlineNode[]): React.ReactNode[] {
|
||||
return nodes.map((node, index) => {
|
||||
if (node.type === "text") {
|
||||
return node.value;
|
||||
|
|
@ -636,7 +636,7 @@ function readTable(
|
|||
};
|
||||
}
|
||||
|
||||
export const markdownStyles = `
|
||||
const markdownStyles = `
|
||||
.tm-md-root { color: hsl(var(--foreground)); line-height: 1.55; font-size: 0.95rem; }
|
||||
.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
|
||||
.tm-md-h1 { font-size: 1.65rem; }
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@
|
|||
"@tensamin/chat": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/ttp": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./code": "./src/code.ts",
|
||||
"./data": "./src/data.ts",
|
||||
"./desktopMedia": "./src/desktopMedia.tsx",
|
||||
"./log": "./src/log.tsx",
|
||||
|
|
@ -21,7 +22,6 @@
|
|||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
packages/shared/src/code.ts
Normal file
20
packages/shared/src/code.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export function toLossySixDigitCode(input: string): string {
|
||||
let firstHash = 0xdeadbeef;
|
||||
let secondHash = 0x41c6ce57;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const character = input.charCodeAt(index);
|
||||
|
||||
firstHash = Math.imul(firstHash ^ character, 2654435761);
|
||||
secondHash = Math.imul(secondHash ^ character, 1597334677);
|
||||
}
|
||||
|
||||
firstHash = Math.imul(firstHash ^ (firstHash >>> 16), 2246822507);
|
||||
firstHash ^= Math.imul(secondHash ^ (secondHash >>> 13), 3266489909);
|
||||
secondHash = Math.imul(secondHash ^ (secondHash >>> 16), 2246822507);
|
||||
secondHash ^= Math.imul(firstHash ^ (firstHash >>> 13), 3266489909);
|
||||
|
||||
const hash = 4294967296 * (2097151 & secondHash) + (firstHash >>> 0);
|
||||
|
||||
return String(hash % 1_000_000).padStart(6, "0");
|
||||
}
|
||||
|
|
@ -25,9 +25,15 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
|
|||
const { load, save } = useStorage();
|
||||
const [contacts, setContacts] = useState<Contacts>([]);
|
||||
const [communities, setCommunities] = useState<Communities>([]);
|
||||
const [calls, setCalls] = useState<Calls>([]);
|
||||
const [localCalls, setLocalCalls] = useState<Calls>([]);
|
||||
const calls = [
|
||||
...freshCalls,
|
||||
...localCalls.filter(
|
||||
(call) => !freshCalls.some((fresh) => fresh.call_id === call.call_id),
|
||||
),
|
||||
];
|
||||
|
||||
// Get cached data and merge fresh data
|
||||
// Cached session data fills in items the server did not return freshly.
|
||||
useEffect(() => {
|
||||
load("cached_contacts").then((cachedData) => {
|
||||
setContacts([
|
||||
|
|
@ -53,16 +59,6 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
|
|||
});
|
||||
}, [load, freshContacts, freshCommunities]);
|
||||
|
||||
useEffect(() => {
|
||||
setCalls((prevCalls) => [
|
||||
...freshCalls,
|
||||
...prevCalls.filter(
|
||||
(call) => !freshCalls.some((fresh) => fresh.call_id === call.call_id),
|
||||
),
|
||||
]);
|
||||
}, [freshCalls]);
|
||||
|
||||
// Save data
|
||||
useEffect(() => {
|
||||
save("cached_contacts", contacts);
|
||||
}, [contacts, save]);
|
||||
|
|
@ -98,7 +94,7 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
|
|||
};
|
||||
|
||||
const insertCall = (call: Calls[number]) => {
|
||||
setCalls((prevCalls) => {
|
||||
setLocalCalls((prevCalls) => {
|
||||
if (prevCalls.some((prevCall) => prevCall.call_id === call.call_id)) {
|
||||
return prevCalls;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@
|
|||
"@tensamin/user": "workspace:*",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6"
|
||||
"react-dom": "^19.2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,6 @@ export default function Wrapper({ children }: { children: ReactNode }) {
|
|||
const redirect = params.get("redirect");
|
||||
const challenge = params.get("challenge");
|
||||
if (!identifier || !redirect) {
|
||||
// eslint-disable-next-line
|
||||
setAllowChildern(true);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@
|
|||
"@tauri-apps/api": "^2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tauri-plugin-app-events-api": "^0.2.0",
|
||||
"zod": "^4.3.6"
|
||||
"tauri-plugin-app-events-api": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^10.0.3"
|
||||
|
|
|
|||
Loading…
Reference in a new issue