feat: add window controls & screen creation util

This commit is contained in:
Alois 2026-04-06 18:44:15 +01:00
commit a4822948d1
20 changed files with 149 additions and 61 deletions

View file

@ -4,24 +4,25 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"exports": { "exports": {
"./context": "./src/context.tsx" "./context": "./src/context.tsx",
"./controls": "./src/windowControls.tsx"
}, },
"scripts": { "scripts": {
"dev:mobile:raw": "tauri android dev", "dev:mobile:raw": "tauri android dev",
"build:mobile:raw": "tauri android build", "build:mobile:raw": "tauri android build",
"dev:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:mobile:raw; else bun dev:mobile:raw; fi", "dev:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:mobile:raw; else bun dev:mobile:raw; fi",
"build:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun build:mobile:raw; else bun build:mobile:raw; fi", "build:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun build:mobile:raw; else bun build:mobile:raw; fi",
"dev:desktop:raw": "tauri dev", "dev:desktop:raw": "tauri dev",
"build:desktop:raw": "tauri build", "build:desktop:raw": "tauri build",
"dev:desktop": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:desktop:raw; else bun dev:desktop:raw; fi", "dev:desktop": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:desktop:raw; else bun dev:desktop:raw; fi",
"build:desktop": "if command -v nix >/dev/null 2>&1; then nix develop --command bun build:desktop:raw; else bun build:desktop:raw; fi", "build:desktop": "if command -v nix >/dev/null 2>&1; then nix develop --command bun build:desktop:raw; else bun build:desktop:raw; fi",
"gen-icons": "tauri icon ./logo.png" "gen-icons": "tauri icon ./logo.png"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tensamin/ui": "workspace:*",
"lucide-react": "^1.7.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
}, },

View file

@ -1,4 +1,5 @@
# Outputs # Outputs
- ./src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk - ./src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
- ./src-tauri/target/release/bundle/deb/Tensamin_{version}_{arch}.deb - ./src-tauri/target/release/bundle/deb/Tensamin*{version}*{arch}.deb
- ./src-tauri/target/release/bundle/rpm/Tensamin-{version}-1.{arch}.rpm - ./src-tauri/target/release/bundle/rpm/Tensamin-{version}-1.{arch}.rpm

View file

@ -2,6 +2,16 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": [
"permissions": ["core:default", "opener:default"] "main"
],
"permissions": [
"core:default",
"opener:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
"core:window:allow-toggle-maximize",
"core:window:allow-minimize"
]
} }

View file

@ -0,0 +1,53 @@
import { Maximize, Minus, X } from "lucide-react";
import { Button, buttonVariants } from "@tensamin/ui/cmp/button";
import { isTauri } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { cn, useIsMobile } from "@tensamin/ui/utils";
import { Card } from "@tensamin/ui/cmp/card";
export default function Controls({
className,
notPresentClassName,
}: {
className?: string;
notPresentClassName?: string;
}) {
const isMobile = useIsMobile();
if (!isTauri() || isMobile) return <div className={notPresentClassName} />;
const currentWindow = getCurrentWindow();
return (
<Card
className={cn(
"flex flex-row py-0 px-0 p-0.5 gap-0 rounded-lg",
className,
)}
>
<>
<Button
variant="ghost"
className="w-9 h-9 aspect-square rounded-lg"
onClick={() => currentWindow.minimize()}
>
<Minus />
</Button>
<Button
variant="ghost"
className="w-9 h-9 aspect-square rounded-lg"
onClick={() => currentWindow.toggleMaximize()}
>
<Maximize />
</Button>
<Button
variant="ghost"
className="w-9 h-9 aspect-square rounded-lg"
onClick={() => currentWindow.close()}
>
<X />
</Button>
</>
</Card>
);
}

View file

@ -14,6 +14,7 @@ import { displayCallId } from "@tensamin/call/utils";
import { useState } from "react"; import { useState } from "react";
import { SidebarTrigger, useSidebar } from "@tensamin/ui/cmp/sidebar"; import { SidebarTrigger, useSidebar } from "@tensamin/ui/cmp/sidebar";
import { useTTP } from "@tensamin/ttp/context"; import { useTTP } from "@tensamin/ttp/context";
import Controls from "@tensamin/tauri/controls";
export default function Navbar({ forMobile }: { forMobile: boolean }) { export default function Navbar({ forMobile }: { forMobile: boolean }) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -30,7 +31,8 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
return ( return (
<div <div
className={`${forMobile && "border-b"} w-full gap-2 h-13.5 flex items-center justify-center`} data-tauri-drag-region
className={`${forMobile && "border-b"} w-full gap-2 h-13.5 flex items-center justify-between`}
> >
{forMobile ? ( {forMobile ? (
<SidebarTrigger <SidebarTrigger
@ -57,7 +59,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
loading={<Skeleton className="ml-3 w-40 h-5" />} loading={<Skeleton className="ml-3 w-40 h-5" />}
/> />
)} )}
<div className="w-full" /> {/* Empty space */}
{pathname === "/chat" && id && ( {pathname === "/chat" && id && (
<> <>
{currentCalls.length === 0 ? ( {currentCalls.length === 0 ? (
@ -77,7 +79,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
onClick={() => { onClick={() => {
joinCall(id, currentCalls[0]); joinCall(id, currentCalls[0]);
}} }}
className="w-9 h-9 aspect-square rounded-lg mr-2" className="w-9 h-9 aspect-square rounded-lg"
> >
<Phone /> <Phone />
</Button> </Button>
@ -85,7 +87,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<> <>
<Button <Button
onClick={() => setSelectOpen((prev) => !prev)} onClick={() => setSelectOpen((prev) => !prev)}
className="w-9! h-9! aspect-square rounded-lg mr-2" className="w-9! h-9! aspect-square rounded-lg"
> >
<Phone /> <Phone />
</Button> </Button>
@ -109,6 +111,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
)} )}
</> </>
)} )}
<Controls className="mr-2 ring-border" notPresentClassName="mr-2" />
</div> </div>
); );
} }

View file

@ -23,7 +23,9 @@ export default function Sidebar() {
return ( return (
<SidebarRoot> <SidebarRoot>
<SidebarContent <SidebarContent
className={isTauri() && isMobile ? "pt-[env(safe-area-inset-top)]" : "pt-2"} className={
isTauri() && isMobile ? "pt-[env(safe-area-inset-top)]" : "pt-2"
}
> >
<div className="h-full w-full flex flex-col gap-3 p-2 pt-0!"> <div className="h-full w-full flex flex-col gap-3 p-2 pt-0!">
<div> <div>

View file

@ -10,6 +10,7 @@ import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
import { log } from "@tensamin/shared/log"; import { log } from "@tensamin/shared/log";
import Link from "@tensamin/ui/link"; import Link from "@tensamin/ui/link";
import { Label } from "@tensamin/ui/cmp/label"; import { Label } from "@tensamin/ui/cmp/label";
import CreateScreen from "@tensamin/ui/screens/util";
// Prevents the user from using Tensamin without accepting the privacy policy and terms of service. // Prevents the user from using Tensamin without accepting the privacy policy and terms of service.
export default function Screen(props: { children: React.ReactNode }) { export default function Screen(props: { children: React.ReactNode }) {
@ -160,7 +161,7 @@ export default function Screen(props: { children: React.ReactNode }) {
} }
return ( return (
<div className="w-full h-full flex items-center justify-center py-15"> <CreateScreen>
<div className="h-full flex flex-col gap-15 p-10 md:p-40 w-full lg:w-2/3"> <div className="h-full flex flex-col gap-15 p-10 md:p-40 w-full lg:w-2/3">
<h1 className="text-3xl md:text-4xl font-bold"> <h1 className="text-3xl md:text-4xl font-bold">
Privacy Policy & ToS Privacy Policy & ToS
@ -198,7 +199,7 @@ export default function Screen(props: { children: React.ReactNode }) {
onClick={handleContinueLegal} onClick={handleContinueLegal}
/> />
</div> </div>
</div> </CreateScreen>
); );
} }

View file

@ -17,7 +17,6 @@ import Home from "@/routes/app/home";
import ChatScreen from "@tensamin/chat/screen"; import ChatScreen from "@tensamin/chat/screen";
import CallScreen from "@tensamin/call/screen"; import CallScreen from "@tensamin/call/screen";
import Login from "@/routes/screens/login"; import Login from "@/routes/screens/login";
import Signup from "@/routes/screens/signup";
import ChatContext from "@tensamin/chat/context"; import ChatContext from "@tensamin/chat/context";
import CallContext from "@tensamin/call/context"; import CallContext from "@tensamin/call/context";
@ -117,16 +116,9 @@ const loginRoute = createRoute({
component: Login, component: Login,
}); });
const signupRoute = createRoute({
getParentRoute: () => rootRoute,
path: "signup",
component: Signup,
});
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
appRoute.addChildren([homeRoute, chatRoute, callRoute]), appRoute.addChildren([homeRoute, chatRoute, callRoute]),
loginRoute, loginRoute,
signupRoute,
]); ]);
const router = createRouter({ const router = createRouter({

View file

@ -1,4 +1,5 @@
import Form from "@/components/screens/login/form"; import Form from "@/components/screens/login/form";
import CreateScreen from "@tensamin/ui/screens/util";
/** /**
* Executes Page. * Executes Page.
@ -7,7 +8,7 @@ import Form from "@/components/screens/login/form";
*/ */
export default function Page() { export default function Page() {
return ( return (
<div className="w-full h-full flex flex-col gap-10 items-center justify-center"> <CreateScreen>
<Form /> <Form />
<div className="flex"> <div className="flex">
<a target="_blank" href="https://docs.tensamin.net/installation/#iota"> <a target="_blank" href="https://docs.tensamin.net/installation/#iota">
@ -17,6 +18,6 @@ export default function Page() {
</p> </p>
</a> </a>
</div> </div>
</div> </CreateScreen>
); );
} }

View file

@ -1,8 +0,0 @@
/**
* Executes Page.
* @param none This function has no parameters.
* @returns unknown.
*/
export default function Page() {
return <div>Signup Page</div>;
}

View file

@ -24,6 +24,8 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tensamin/ui": "workspace:*",
"lucide-react": "^1.7.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
}, },
@ -194,6 +196,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"@tensamin/tauri": "workspace:*",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
@ -1402,6 +1405,8 @@
"@tensamin/notifications/@tensamin/ttp": ["TTP@github:Tensamin/TTP#e246d1a", {}, "Tensamin-TTP-e246d1a"], "@tensamin/notifications/@tensamin/ttp": ["TTP@github:Tensamin/TTP#e246d1a", {}, "Tensamin-TTP-e246d1a"],
"@tensamin/tauri/lucide-react": ["lucide-react@1.7.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg=="],
"@tensamin/ui/sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "@tensamin/ui/sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],

View file

@ -45,7 +45,9 @@ export function log(
return; return;
} }
console.groupCollapsed(`${colorCodes[color]}${logger}${resetCode} ${args[0]}`); console.groupCollapsed(
`${colorCodes[color]}${logger}${resetCode} ${args[0]}`,
);
try { try {
for (const arg of args.slice(1)) { for (const arg of args.slice(1)) {
console.log(arg); console.log(arg);

View file

@ -189,8 +189,8 @@ export default function Provider(props: {
* Sends typed protocol messages through the active transport client. * Sends typed protocol messages through the active transport client.
* @param type Protocol message type. * @param type Protocol message type.
* @param data Optional request payload. * @param data Optional request payload.
* @param options Optional request id. * @param options Optional request id.
* @returns A promise for the typed message payload. * @returns A promise for the typed message payload.
*/ */
const send = useCallback<BoundSendFn<Schemas>>( const send = useCallback<BoundSendFn<Schemas>>(
(( ((

View file

@ -61,9 +61,9 @@ function createMockWebTransport() {
let readyResolve!: () => void; let readyResolve!: () => void;
let closedResolve!: () => void; let closedResolve!: () => void;
let incomingController: let incomingController: ReadableStreamDefaultController<
| ReadableStreamDefaultController<ReadableStream<Uint8Array>> ReadableStream<Uint8Array>
| null = null; > | null = null;
const outgoingMessages: TypedMessage[] = []; const outgoingMessages: TypedMessage[] = [];
const ready = new Promise<void>((resolve) => { const ready = new Promise<void>((resolve) => {
@ -426,7 +426,11 @@ describe("Core Protocol", () => {
const firstSend = client.send("ping", {}); const firstSend = client.send("ping", {});
const secondSend = client.send("ping", {}); const secondSend = client.send("ping", {});
for (let attempt = 0; attempt < 10 && outgoingMessages.length === 0; attempt += 1) { for (
let attempt = 0;
attempt < 10 && outgoingMessages.length === 0;
attempt += 1
) {
await Promise.resolve(); await Promise.resolve();
} }
@ -439,7 +443,11 @@ describe("Core Protocol", () => {
}); });
await firstSend; await firstSend;
for (let attempt = 0; attempt < 10 && outgoingMessages.length === 1; attempt += 1) { for (
let attempt = 0;
attempt < 10 && outgoingMessages.length === 1;
attempt += 1
) {
await Promise.resolve(); await Promise.resolve();
} }

View file

@ -523,8 +523,8 @@ export function createTransportClient<T extends SchemaMap>(
* Sends a typed protocol request over the current connection. * Sends a typed protocol request over the current connection.
* @param type Protocol message type. * @param type Protocol message type.
* @param input Optional request payload. * @param input Optional request payload.
* @param options Optional request id. * @param options Optional request id.
* @returns Promise for the typed response message. * @returns Promise for the typed response message.
*/ */
const send: BoundSendFn<T> = (( const send: BoundSendFn<T> = ((
type: string, type: string,
@ -612,14 +612,15 @@ export function createTransportClient<T extends SchemaMap>(
timeoutId, timeoutId,
}); });
void writeMessageOnPersistentStream(enqueuedConnection, messageBytes).catch( void writeMessageOnPersistentStream(
(error) => { enqueuedConnection,
handleConnectionFailure(enqueuedConnection, error); messageBytes,
clearTimeout(timeoutId); ).catch((error) => {
pending.delete(requestId); handleConnectionFailure(enqueuedConnection, error);
reject(error); clearTimeout(timeoutId);
}, pending.delete(requestId);
); reject(error);
});
}); });
}); });
} catch (error) { } catch (error) {

View file

@ -17,6 +17,7 @@
"build": "tsc -p tsconfig.json --noEmit" "build": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@tensamin/tauri": "workspace:*",
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View file

@ -12,7 +12,7 @@ const buttonVariants = cva(
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline: outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:hover:bg-input/50",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: ghost:

View file

@ -1,4 +1,5 @@
import Link from "../link"; import Link from "../link";
import CreateScreen from "./util";
/** /**
* Executes Screen. * Executes Screen.
@ -7,12 +8,12 @@ import Link from "../link";
*/ */
export default function Screen(props: { error: string; description: string }) { export default function Screen(props: { error: string; description: string }) {
return ( return (
<div className="bg-background w-full h-screen flex flex-col justify-center items-center"> <CreateScreen>
<p className="text-base font-semibold">{props.error}</p> <p className="text-base font-semibold">{props.error}</p>
<p className="pt-4 text-sm w-1/2 text-center text-muted-foreground pb-8"> <p className="pt-4 text-sm w-1/2 text-center text-muted-foreground pb-8">
{props.description} {props.description}
</p> </p>
<Link label="status.tensamin.net" link="https://status.tensamin.net" /> <Link label="status.tensamin.net" link="https://status.tensamin.net" />
</div> </CreateScreen>
); );
} }

View file

@ -1,4 +1,5 @@
import * as React from "react"; import * as React from "react";
import CreateScreen from "./util";
const DELAY = 250; const DELAY = 250;
@ -58,14 +59,8 @@ export default function Screen(props: ScreenProps) {
}; };
}, [props.progress]); }, [props.progress]);
const wrapperClass = props.fullscreen
? "fixed inset-0 z-50 bg-background"
: "bg-background w-full h-screen";
return ( return (
<div <CreateScreen>
className={`${wrapperClass} flex flex-col justify-center items-center px-6`}
>
<h2 className="text-base font-semibold text-foreground"> <h2 className="text-base font-semibold text-foreground">
{props.title ?? "Loading"} {props.title ?? "Loading"}
</h2> </h2>
@ -84,6 +79,6 @@ export default function Screen(props: ScreenProps) {
}} }}
/> />
</div> </div>
</div> </CreateScreen>
); );
} }

View file

@ -0,0 +1,19 @@
import Controls from "@tensamin/tauri/controls";
export default function CreateScreen({
children,
}: {
children: React.ReactNode;
}) {
return (
<div
className="bg-background w-screen h-screen flex flex-col justify-center items-center"
data-tauri-drag-region
>
<>
<Controls className="fixed top-0 right-0 m-[7px] mr-[7.6px]! ring-foreground/15" />
{children}
</>
</div>
);
}