Added deeplinks, added qr code login, added settings, other stuff

This commit is contained in:
Alois 2026-04-10 19:44:08 +02:00
commit 8ff8bd9528
32 changed files with 917 additions and 78 deletions

View file

@ -17,21 +17,22 @@
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-router": "^1.0.0",
"@tanstack/react-virtual": "^3.0.0",
"@tauri-apps/api": "^2",
"@tensamin/call": "workspace:*",
"@tensamin/chat": "workspace:*",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/tauri": "workspace:*",
"@tensamin/ttp": "workspace:*",
"@tensamin/ui": "workspace:*",
"@tensamin/user": "workspace:*",
"@tensamin/tauri": "workspace:*",
"@tauri-apps/api": "^2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"comlink": "^4.4.2",
"framer-motion": "^12.38.0",
"lucide-react": "^0.564.0",
"qrcode": "^1.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"shadcn": "^4.0.7",
@ -44,10 +45,11 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.4",
"@eslint/js": "^10.0.1",
"eslint": "^10.0.3",
"globals": "^17.4.0",
"typescript": "~5.9.3",

View file

@ -1,5 +1,5 @@
import { Button } from "@tensamin/ui/cmp/button";
import { ArrowLeft, House, Phone, User } from "lucide-react";
import { ArrowLeft, House, Phone, Settings, User } from "lucide-react";
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
import { useCall } from "@tensamin/call/context";
import Wrapper from "@tensamin/user/wrapper";
@ -22,6 +22,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
const { contacts } = useTTP();
const { pathname } = useLocation();
const { id } = useSearch({ strict: false });
const currentCalls = id ? contacts?.[id]?.calls || [] : [];
@ -43,13 +44,23 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<ArrowLeft className="size-4.5" />
</SidebarTrigger>
) : (
<Button
onClick={() => navigate({ to: "/" })}
className="w-9 h-9 aspect-square rounded-lg"
variant="outline"
>
<House className="size-4.5" />
</Button>
<>
<Button
onClick={() => navigate({ to: "/" })}
className="w-9 h-9 aspect-square rounded-lg"
variant="outline"
>
<House className="size-4.5" />
</Button>
<Button
// @ts-expect-error TanStack router doesn't properly detect the settings route
onClick={() => navigate({ to: "/settings" })}
className="w-9 h-9 aspect-square rounded-lg"
variant="outline"
>
<Settings className="size-4.5" />
</Button>
</>
)}
{pathname === "/chat" && (
<Wrapper
@ -126,22 +137,36 @@ export function MobileNavbar() {
return (
<div className="z-51 w-full pb-[env(safe-area-inset-bottom)] bg-card border-t">
<div className="z-51 w-full h-14 flex justify-center items-center gap-8">
<div className="z-51 w-full h-15 grid grid-cols-3 items-center place-items-center px-5">
<SidebarTrigger
className="text-foreground w-12! h-12! aspect-square rounded-xl"
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1"
variant="link"
>
<User className="size-5.5" />
<User className="size-4.5" />
<p className="text-xs">Chats</p>
</SidebarTrigger>
<Button
onClick={() => {
navigate({ to: "/" });
setOpenMobile(false);
}}
className="text-foreground w-12 h-12 aspect-square rounded-xl"
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
variant="link"
>
<House className="size-5.5" />
<House className="size-4.5" />
<p className="text-xs">Home</p>
</Button>
<Button
onClick={() => {
// @ts-expect-error TanStack router doesn't properly detect the settings route
navigate({ to: "/settings" });
setOpenMobile(false);
}}
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
variant="link"
>
<Settings className="size-4.5" />
<p className="text-xs">Settings</p>
</Button>
</div>
</div>

View file

@ -6,6 +6,8 @@ import { log, toast } from "@tensamin/shared/log";
import { File } from "lucide-react";
import * as React from "react";
import { z } from "zod";
import { isTauri } from "@tauri-apps/api/core";
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
const fetchedUser = z.object({
id: z.uuidv4(),
@ -145,15 +147,46 @@ export default function Form() {
[save],
);
const isTauriEnv = isTauri();
return (
<div className="flex md:flex-row flex-col gap-15">
<div
onClick={() => uploadRef.current?.click()}
className="flex flex-col gap-3 cursor-pointer w-55 aspect-square bg-input/13 hover:bg-input/30 transition-all duration-300 ease-in-out border-3 items-center justify-center rounded-lg"
>
<File className="text-foreground" size={27} />
<p className="text-md">Select .tu file</p>
</div>
{isTauriEnv ? (
<>
<QrCodeScanner
onData={(data) => {
if (!data.startsWith("tensamin://tu::")) {
toast("error", "Invalid QR code");
return;
} else {
const decoded = data.replace("tensamin://tu::", "");
try {
const { userId, privateKey } = parseTuFileContent(decoded);
save("session_id", Date.now());
save("user_id", userId);
save("private_key", privateKey);
location.href = "/";
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to parse QR code data");
}
}
}}
/>
<Button onClick={() => uploadRef.current?.click()}>
Select .tu file
</Button>
</>
) : (
<div
onClick={() => uploadRef.current?.click()}
className="flex flex-col gap-3 cursor-pointer w-55 aspect-square bg-input/13 hover:bg-input/30 transition-all duration-300 ease-in-out border-3 items-center justify-center rounded-lg"
>
<File className="text-foreground" size={27} />
<p className="text-md">Select .tu file</p>
</div>
)}
<input
accept=".tu"
onChange={handleFileInputChange}

View file

@ -10,9 +10,9 @@ import {
import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@tensamin/ui/utils";
import { MobileNavbar } from "./navbar";
import { useLocation } from "@tanstack/react-router";
import SidebarBox from "@tensamin/call/sidebarBox";
import { useShowMobileNavbar } from "@/routes/app/layout";
/**
* Renders the conversation sidebar with account summary and conversation list.
@ -20,7 +20,7 @@ import SidebarBox from "@tensamin/call/sidebarBox";
*/
export default function Sidebar() {
const isMobile = useIsMobile();
const location = useLocation();
const showMobileNavbar = useShowMobileNavbar();
return (
<SidebarRoot>
@ -42,7 +42,7 @@ export default function Sidebar() {
</div>
</div>
</SidebarContent>
{isMobile && location.pathname === "/chat" && (
{isMobile && !showMobileNavbar && (
<SidebarFooter className="p-0!">
<MobileNavbar />
</SidebarFooter>

View file

@ -162,7 +162,7 @@ export default function Screen(props: { children: React.ReactNode }) {
return (
<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 py-20 md:p-40 w-full lg:w-2/3">
<h1 className="text-3xl md:text-4xl font-bold">
Privacy Policy & ToS
<p className="text-muted-foreground text-[20px] font-normal pt-3">

View file

@ -0,0 +1,35 @@
import { Label } from "@tensamin/ui/cmp/label";
import { Switch as UISwitch } from "@tensamin/ui/cmp/switch";
import { useEffect, useState } from "react";
import { settingsStorageDefaults } from "@tensamin/shared/settings";
import { useStorage } from "@tensamin/storage/context";
export function Switch({
label,
id,
}: {
label: string;
id: keyof typeof settingsStorageDefaults;
}) {
const { save, load } = useStorage();
const [value, setValue] = useState<boolean>(settingsStorageDefaults[id]);
useEffect(() => {
load(id).then((value) => setValue(value));
}, [id, load]);
return (
<div className="flex gap-1">
<UISwitch
id={id}
checked={value}
onCheckedChange={(value) => {
setValue(value);
save(id, value);
}}
/>
<Label htmlFor={id}>{label}</Label>
</div>
);
}

View file

@ -0,0 +1,64 @@
import { Button } from "@tensamin/ui/cmp/button";
import options from "@tensamin/shared/settings";
import { cn, useIsMobile } from "@tensamin/ui/utils";
import { Outlet, useNavigate } from "@tanstack/react-router";
export default function Screen() {
const isMobile = useIsMobile();
return (
<div className="flex h-full w-full">
{!isMobile && <SettingsSidebar />}
{/* Page */}
<div className="bg-background w-full h-full p-3">
<Outlet />
</div>
</div>
);
}
export function SettingsSidebar() {
const settingsOptions = options as Record<
string,
Record<string, Record<string, unknown>>
>;
const isMobile = useIsMobile();
const navigate = useNavigate();
return (
<div
className={cn(
isMobile ? "w-full" : "rounded-tl-2xl border-r bg-input/15 w-50",
"flex flex-col gap-6 p-3",
)}
>
{/* Settings */}
{Object.keys(settingsOptions).map((category) => (
// Category
<div key={category} className="flex flex-col gap-1">
<h2 className="font-bold text-xs uppercase">{category}</h2>
{Object.keys(settingsOptions[category]).map((page) => (
// Page
<div key={page}>
<Button
className="w-full"
variant="outline"
onClick={() =>
navigate({
to: "/settings/" + page.toLowerCase(),
})
}
>
{(page as string).charAt(0).toUpperCase() +
(page as string).slice(1)}
</Button>
</div>
))}
</div>
))}
</div>
);
}

View file

@ -10,8 +10,9 @@ import "./index.css";
import NotFound from "@/routes/404";
import RootLayout from "./routes/layout";
import AppLayout from "./routes/app/layout";
import RootLayout from "@/routes/layout";
import AppLayout from "@/routes/app/layout";
import SettingsLayout from "@/features/settings/layout";
import Home from "@/routes/app/home";
import ChatScreen from "@tensamin/chat/screen";
@ -22,6 +23,7 @@ import ChatContext from "@tensamin/chat/context";
import CallContext from "@tensamin/call/context";
import SocketContext from "@tensamin/ttp/context";
import UserContext from "@tensamin/user/context";
import DeeplinkContext from "@tensamin/tauri/deeplinkHandler";
import { ThemeProvider } from "@tensamin/ui/theme";
import z from "zod";
@ -47,13 +49,15 @@ window.setLogLevelToMax = () => {
function RootShell() {
return (
<ThemeProvider>
<div className="w-screen h-screen overflow-hidden">
<RootLayout>
<Outlet />
</RootLayout>
</div>
</ThemeProvider>
<DeeplinkContext>
<ThemeProvider>
<div className="w-screen h-screen overflow-hidden">
<RootLayout>
<Outlet />
</RootLayout>
</div>
</ThemeProvider>
</DeeplinkContext>
);
}
@ -89,10 +93,55 @@ const appRoute = createRoute({
component: AppShell,
});
const settingsRoute = createRoute({
getParentRoute: () => appRoute,
path: "settings",
component: SettingsLayout,
staticData: {
showMobileNavbar: true,
},
});
type SettingsRouteModule = {
default?: () => React.JSX.Element;
component?: () => React.JSX.Element;
};
const settingsRouteModules = import.meta.glob<SettingsRouteModule>(
"./routes/settings/*.tsx",
{ eager: true },
);
const settingsChildren = Object.entries(settingsRouteModules).map(
([filePath, module]) => {
const fileName = filePath.split("/").pop()?.replace(".tsx", "") ?? "";
const path = fileName === "index" ? "/" : fileName;
const component = module.component ?? module.default;
if (!component) {
throw new Error(
`Settings route module "${filePath}" must export a default component`,
);
}
return createRoute({
getParentRoute: () => settingsRoute,
path,
component,
staticData: {
showMobileNavbar: true,
},
});
},
);
const homeRoute = createRoute({
getParentRoute: () => appRoute,
path: "/",
component: Home,
staticData: {
showMobileNavbar: true,
},
});
const chatRoute = createRoute({
@ -102,22 +151,36 @@ const chatRoute = createRoute({
validateSearch: z.object({
id: z.number().optional(),
}),
staticData: {
showMobileNavbar: false,
},
});
const callRoute = createRoute({
getParentRoute: () => appRoute,
path: "call",
component: CallScreen,
staticData: {
showMobileNavbar: false,
},
});
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: "login",
component: Login,
staticData: {
showMobileNavbar: false,
},
});
const routeTree = rootRoute.addChildren([
appRoute.addChildren([homeRoute, chatRoute, callRoute]),
appRoute.addChildren([
homeRoute,
chatRoute,
callRoute,
settingsRoute.addChildren(settingsChildren),
]),
loginRoute,
]);

View file

@ -7,31 +7,31 @@ import { SidebarProvider } from "@tensamin/ui/cmp/sidebar";
import { useIsMobile, cn } from "@tensamin/ui/utils";
import { isTauri } from "@tauri-apps/api/core";
import { useLocation } from "@tanstack/react-router";
import { useLocation, useMatches } from "@tanstack/react-router";
/**
* Executes Layout.
* @param props Parameter props.
* @returns unknown.
*/
export default function Layout(props: { children: ReactNode }) {
export default function Layout({ children }: { children: ReactNode }) {
const isMobile = useIsMobile();
const location = useLocation();
const showMobileNavbar = useShowMobileNavbar();
return (
<div className="w-full h-full flex bg-sidebar">
<SidebarProvider>
<Sidebar />
<div
// Background of ui that is overlapping with the system ui
className={cn(
"w-full h-full flex flex-col",
isTauri() && isMobile && "pt-[env(safe-area-inset-top)]",
isMobile && showMobileNavbar && "bg-background",
)}
>
{!isMobile && <Navbar forMobile={false} />}
{isMobile && location.pathname === "/chat" && (
<Navbar forMobile={true} />
)}
{isMobile && !showMobileNavbar && <Navbar forMobile={true} />}
<div
className={cn(
@ -39,11 +39,26 @@ export default function Layout(props: { children: ReactNode }) {
!isMobile && "rounded-tl-3xl border-t border-l",
)}
>
{props.children}
{children}
</div>
{isMobile && location.pathname !== "/chat" && <MobileNavbar />}
{isMobile && showMobileNavbar && <MobileNavbar />}
</div>
</SidebarProvider>
</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;
}

View file

@ -0,0 +1,12 @@
import { Switch } from "@/features/settings/components";
export default function Page() {
return (
<div>
<Switch
label="Reverse Enter Key Behavior"
id="settings.reverse_enter_behavior"
/>
</div>
);
}

View file

@ -0,0 +1,7 @@
import { SettingsSidebar } from "@/features/settings/layout";
import { useIsMobile } from "@tensamin/ui/utils";
export default function Page() {
const isMobile = useIsMobile();
return isMobile && <SettingsSidebar />;
}

View file

@ -0,0 +1,111 @@
import { useStorage } from "@tensamin/storage/context";
import { useEffect, useState } from "react";
import QRCode from "qrcode";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@tensamin/ui/cmp/alert-dialog";
import { Button } from "@tensamin/ui/cmp/button";
export default function Page() {
return (
<div>
<QrCodeLogin />
</div>
);
}
// QR Code Login
const generateQR = async (text: string): Promise<string> => {
try {
const url = await QRCode.toDataURL(text, {
errorCorrectionLevel: "H",
margin: 1,
color: {
dark: "#000000FF",
light: "#FFFFFFFF",
},
});
return url;
} catch (err) {
console.error(err);
throw err;
}
};
function QrCodeLogin() {
const { load } = useStorage();
const [userId, setUserId] = useState<number>(0);
const [privateKey, setPrivateKey] = useState<string>("");
const [qrCodeBase64, setQrCodeBase64] = useState<string | undefined>(
undefined,
);
useEffect(() => {
load("private_key").then((value) => {
if (value) {
setPrivateKey(value);
}
});
load("user_id").then((value) => {
if (value) {
setUserId(value);
}
});
}, [load]);
useEffect(() => {
if (userId && privateKey) {
generateQR(`tensamin://tu::${userId}::${privateKey}`).then(
setQrCodeBase64,
);
}
}, [userId, privateKey]);
const [qrCodeVisible, setQrCodeVisible] = useState(false);
return (
<div className="relative rounded-lg w-50 border-3 aspect-square overflow-hidden">
{qrCodeBase64 && (
<img className="w-full h-full" src={qrCodeBase64} alt="QR Code" />
)}
{!qrCodeVisible && (
<>
<div className="absolute top-0 left-0 w-full h-full backdrop-blur-sm bg-background/50" />
<div className="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<AlertDialog>
<AlertDialogTrigger render={<Button>Show QR Code</Button>} />
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will expose your private key! This is the same as
sharing the .tu file! It's very tedious to change the
private key, so only do this if you are sure no one can
steal it!
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
render={
<Button onClick={() => setQrCodeVisible(true)}>
Show QR Code
</Button>
}
/>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
)}
</div>
);
}