(feat): add cache package
All checks were successful
/ build-web (push) Successful in 7m26s
/ build-desktop (linux) (push) Successful in 11m29s
/ build-mobile (push) Successful in 19m15s
/ release (push) Successful in 3m3s

(feat): improve local storage security
(feat): move settings to dedicated settings package
This commit is contained in:
Alois 2026-07-11 22:08:01 +02:00
commit 790a1db788
54 changed files with 1984 additions and 947 deletions

View file

@ -17,6 +17,13 @@ import {
type DesktopScreenShareCapabilities,
} from "../shared/ipc.js";
import { initTray, setTrayCallStatus } from "./tray.js";
import {
clearSecureStorage,
deleteSecureStorage,
getSecureStorageStatus,
loadSecureStorage,
saveSecureStorage,
} from "./secureStorage.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const verbose = process.argv.includes("--verbose");
@ -190,6 +197,18 @@ function registerIpc() {
);
ipcMain.handle(ipcChannels.getVersion, () => app.getVersion());
ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates);
ipcMain.handle(ipcChannels.getSecureStorageStatus, getSecureStorageStatus);
ipcMain.handle(ipcChannels.loadSecureStorage, (_event, key: unknown) =>
loadSecureStorage(key),
);
ipcMain.handle(
ipcChannels.saveSecureStorage,
(_event, key: unknown, value: unknown) => saveSecureStorage(key, value),
);
ipcMain.handle(ipcChannels.deleteSecureStorage, (_event, key: unknown) =>
deleteSecureStorage(key),
);
ipcMain.handle(ipcChannels.clearSecureStorage, clearSecureStorage);
ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => {
if (
typeof status !== "object" ||

View file

@ -0,0 +1,130 @@
import { app, safeStorage } from "electron";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
secureStorageLimits,
type DesktopSecureStorageStatus,
} from "../shared/ipc.js";
type StoredValues = Record<string, string>;
let pendingWrite = Promise.resolve();
function storagePath() {
return join(app.getPath("userData"), "secure-storage.json");
}
function validateKey(key: unknown): asserts key is string {
if (
typeof key !== "string" ||
key.length === 0 ||
Buffer.byteLength(key, "utf8") > secureStorageLimits.maxKeyBytes
) {
throw new Error("Invalid secure storage key.");
}
}
function validateValue(value: unknown): asserts value is string {
if (
typeof value !== "string" ||
Buffer.byteLength(value, "utf8") > secureStorageLimits.maxValueBytes
) {
throw new Error("Invalid secure storage value.");
}
}
export function getSecureStorageStatus(): DesktopSecureStorageStatus {
if (!safeStorage.isEncryptionAvailable()) {
return { available: false, backend: null };
}
const backend =
process.platform === "linux"
? safeStorage.getSelectedStorageBackend()
: process.platform === "darwin"
? "keychain"
: process.platform === "win32"
? "dpapi"
: null;
return {
available: process.platform !== "linux" || backend !== "basic_text",
backend,
};
}
function requireAvailable() {
if (!getSecureStorageStatus().available) {
throw new Error("Secure storage is unavailable.");
}
}
async function readValues(): Promise<StoredValues> {
try {
const parsed: unknown = JSON.parse(await readFile(storagePath(), "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Invalid secure storage data.");
}
const values = parsed as Record<string, unknown>;
if (Object.values(values).some((value) => typeof value !== "string")) {
throw new Error("Invalid secure storage data.");
}
return values as StoredValues;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
throw error;
}
}
async function writeValues(values: StoredValues) {
const path = storagePath();
const temporaryPath = `${path}.tmp`;
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await writeFile(temporaryPath, JSON.stringify(values), { mode: 0o600 });
await rename(temporaryPath, path);
}
function mutateValues(mutation: (values: StoredValues) => void) {
const operation = pendingWrite.then(async () => {
const values = await readValues();
mutation(values);
await writeValues(values);
});
pendingWrite = operation.catch(() => undefined);
return operation;
}
export async function loadSecureStorage(key: unknown): Promise<string | null> {
requireAvailable();
validateKey(key);
await pendingWrite;
const encrypted = (await readValues())[key];
if (encrypted === undefined) return null;
return safeStorage.decryptString(Buffer.from(encrypted, "base64"));
}
export function saveSecureStorage(key: unknown, value: unknown) {
requireAvailable();
validateKey(key);
validateValue(value);
const encrypted = safeStorage.encryptString(value).toString("base64");
return mutateValues((values) => {
values[key] = encrypted;
});
}
export function deleteSecureStorage(key: unknown) {
requireAvailable();
validateKey(key);
return mutateValues((values) => {
delete values[key];
});
}
export function clearSecureStorage() {
requireAvailable();
return mutateValues((values) => {
for (const key of Object.keys(values)) delete values[key];
});
}

View file

@ -3,12 +3,21 @@ import {
ipcChannels,
type DesktopCallStatus,
type DesktopScreenShareSource,
secureStorageLimits,
} from "../shared/ipc.js";
function windowAction(channel: string) {
return () => ipcRenderer.invoke(channel);
}
function validKey(key: string) {
return (
typeof key === "string" &&
key.length > 0 &&
Buffer.byteLength(key, "utf8") <= secureStorageLimits.maxKeyBytes
);
}
const desktopApi = {
media: {
listScreenShareSources: () =>
@ -46,6 +55,24 @@ const desktopApi = {
return ipcRenderer.invoke(ipcChannels.setCallStatus, status);
},
},
secureStorage: {
getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus),
load: (key: string) =>
validKey(key)
? ipcRenderer.invoke(ipcChannels.loadSecureStorage, key)
: Promise.reject(new Error("Invalid secure storage key.")),
save: (key: string, value: string) =>
validKey(key) &&
typeof value === "string" &&
Buffer.byteLength(value, "utf8") <= secureStorageLimits.maxValueBytes
? ipcRenderer.invoke(ipcChannels.saveSecureStorage, key, value)
: Promise.reject(new Error("Invalid secure storage key or value.")),
delete: (key: string) =>
validKey(key)
? ipcRenderer.invoke(ipcChannels.deleteSecureStorage, key)
: Promise.reject(new Error("Invalid secure storage key.")),
clear: () => ipcRenderer.invoke(ipcChannels.clearSecureStorage),
},
window: {
minimize: () => windowAction(ipcChannels.minimizeWindow),
maximize: () => windowAction(ipcChannels.maximizeWindow),

View file

@ -26,6 +26,16 @@ export type DesktopCallStatus = {
iconDataUrl?: string;
};
export type DesktopSecureStorageStatus = {
available: boolean;
backend: string | null;
};
export const secureStorageLimits = {
maxKeyBytes: 256,
maxValueBytes: 1024 * 1024,
} as const;
export type ReleaseArtifact = {
name: string;
platform: string;
@ -62,4 +72,9 @@ export const ipcChannels = {
getVersion: "app:getVersion",
checkForUpdates: "updates:checkForUpdates",
setCallStatus: "call:setStatus",
getSecureStorageStatus: "secureStorage:getStatus",
loadSecureStorage: "secureStorage:load",
saveSecureStorage: "secureStorage:save",
deleteSecureStorage: "secureStorage:delete",
clearSecureStorage: "secureStorage:clear",
} as const;

View file

@ -50,9 +50,11 @@
"@tanstack/react-virtual": "^3.13.24",
"@tauri-apps/api": "^2",
"@tensamin/call": "workspace:*",
"@tensamin/cache": "workspace:*",
"@tensamin/chat": "workspace:*",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/settings": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/tauri": "workspace:*",
"@tensamin/mtp": "workspace:*",

View file

@ -58,7 +58,6 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<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"
@ -195,7 +194,6 @@ export function MobileNavbar() {
</Button>
<Button
onClick={() => {
// @ts-expect-error TanStack router doesn't properly detect the settings route
navigate({ to: "/settings" });
setOpenMobile(false);
}}

View file

@ -80,6 +80,30 @@ export default function Form() {
const uploadRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const { save } = useStorage();
const loginPendingRef = React.useRef(false);
const persistLogin = React.useCallback(
async (userId: number, privateKey: string, domain?: string | null) => {
if (loginPendingRef.current) return false;
loginPendingRef.current = true;
try {
if (domain) await save("omega_url", `https://${domain}/`);
await save("mtp_keyring", privateKey, { secure: true });
await save("session_id", Date.now());
await save("user_id", userId);
window.history.replaceState(
null,
"",
window.location.protocol === "file:" ? "#/" : "/",
);
window.location.reload();
return true;
} finally {
loginPendingRef.current = false;
}
},
[save],
);
// Process dropped files
const processDroppedFile = React.useCallback(
@ -93,20 +117,13 @@ export default function Form() {
const raw = await file.text();
const parsed = parseTuFileContent(raw);
await save("session_id", Date.now());
await save("user_id", parsed.userId);
await save("mtp_keyring", parsed.privateKey);
if (parsed.domain) {
await save("omega_url", `https://${parsed.domain}/`);
}
location.href = "/";
await persistLogin(parsed.userId, parsed.privateKey, parsed.domain);
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to load file");
}
},
[save],
[persistLogin],
);
// Handle .tu files
@ -236,20 +253,13 @@ export default function Form() {
const user = parse.data;
await save("session_id", Date.now());
await save("user_id", user.user_id);
await save("mtp_keyring", inputParse.data.mtp_keyring);
if (domain) {
await save("omega_url", `https://${domain}/`);
}
location.href = "/";
await persistLogin(user.user_id, inputParse.data.mtp_keyring, domain);
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to fetch user data");
}
},
[save],
[persistLogin],
);
return (
@ -269,15 +279,7 @@ export default function Form() {
const { userId, privateKey, domain } =
parseTuFileContent(decoded);
await save("session_id", Date.now());
await save("user_id", userId);
await save("mtp_keyring", privateKey);
if (domain) {
await save("omega_url", `https://${domain}/`);
}
location.href = "/";
await persistLogin(userId, privateKey, domain);
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to parse QR code data");

View file

@ -1,178 +0,0 @@
import {
Button,
Checkbox,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
Input,
Label,
Switch as UISwitch,
} from "@tensamin/ui";
import { useEffect, useState } from "react";
import { storageDefaults, type Storage } from "@tensamin/shared/data";
import { settingsStorageDefaults } from "@tensamin/shared/settings";
import { useStorage } from "@tensamin/storage/context";
type BooleanStorageKey = {
[K in keyof Storage]: Storage[K] extends boolean ? K : never;
}[keyof Storage];
type ListStorageKey = {
[K in keyof Storage]: Storage[K] extends (string | number)[] ? K : never;
}[keyof Storage];
type ListStorageItem<K extends ListStorageKey> =
Storage[K] extends Array<infer Item> ? Item : never;
export function Switch({
label,
id,
}: {
label: React.ReactNode;
id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
}) {
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>
);
}
export function List<K extends ListStorageKey>({
label,
id,
}: {
label: React.ReactNode;
id: K;
}) {
const { save, load } = useStorage();
const [items, setItems] = useState<Storage[K]>(storageDefaults[id]);
const [inputValue, setInputValue] = useState("");
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
useEffect(() => {
load(id).then((value) => setItems(value));
}, [id, load]);
const persistItems = (nextItems: Storage[K]) => {
setItems(nextItems);
save(id, nextItems);
};
const toStorageItem = (value: string): ListStorageItem<K> => {
const referenceItem = items[0] ?? storageDefaults[id][0];
if (typeof referenceItem === "number") {
return Number(value) as ListStorageItem<K>;
}
return value as ListStorageItem<K>;
};
const addItem = () => {
const trimmedValue = inputValue.trim();
if (!trimmedValue) return;
const nextItem = toStorageItem(trimmedValue);
if (typeof nextItem === "number" && Number.isNaN(nextItem)) return;
persistItems([...items, nextItem] as Storage[K]);
setInputValue("");
};
const deleteItems = (indexes: Set<number>) => {
const nextItems = items.filter(
(_, index) => !indexes.has(index),
) as Storage[K];
setItems(nextItems);
setSelectedItems(new Set());
save(id, nextItems);
};
return (
<div className="flex flex-col gap-2 pt-4">
<Label>{label}</Label>
<div className="flex flex-col gap-0 overflow-hidden p-1 border-2 rounded-xl">
<div className="flex gap-1">
<Input
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") addItem();
}}
/>
<Button onClick={addItem}>Add item</Button>
</div>
<div className="flex flex-col gap-0">
{items.map((item, index) => {
const labelId = `${String(id)}-${index}`;
const selected = selectedItems.has(index);
const deletingSelectedItems = selectedItems.size > 1;
return (
<ContextMenu key={`${String(item)}-${index}`}>
<ContextMenuTrigger
render={
<div className="grid grid-cols-[auto_auto_1fr] items-center gap-2 border-b px-1 py-2 last:border-b-0">
<Checkbox
id={labelId}
checked={selected}
onCheckedChange={(checked) => {
setSelectedItems((previous) => {
const nextSelected = new Set(previous);
if (checked) {
nextSelected.add(index);
} else {
nextSelected.delete(index);
}
return nextSelected;
});
}}
/>
<Label htmlFor={labelId}>{String(item)}</Label>
<div />
</div>
}
/>
<ContextMenuContent>
<ContextMenuItem
variant="destructive"
onClick={() =>
deleteItems(
deletingSelectedItems
? selectedItems
: new Set([index]),
)
}
>
{deletingSelectedItems ? "Delete Selected" : "Delete"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
</div>
</div>
);
}

View file

@ -1,77 +0,0 @@
import { Button, ClearStorageButton } from "@tensamin/ui";
import options from "@tensamin/shared/settings";
import { cn, useIsMobile } from "@tensamin/ui";
import { Outlet, useLocation, useNavigate } from "@tanstack/react-router";
export default function Screen() {
const isMobile = useIsMobile();
const location = useLocation();
return (
<div className="flex h-full w-full">
{!isMobile && <SettingsSidebar />}
{/* Page */}
<div className="bg-background w-full h-full p-3 flex flex-col gap-3">
<h1 className="text-xl font-semibold">
{location.pathname
.split("/")
.pop()
?.replace(/-/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())}
</h1>
<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 p-1"
: "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
"flex flex-col gap-6",
)}
>
{/* Settings */}
{Object.keys(settingsOptions).map((category) => (
// Category
<div key={category} className="flex flex-col gap-2">
<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 className="mt-auto">
<ClearStorageButton className="w-full" />
</div>
</div>
);
}

View file

@ -14,7 +14,7 @@ import "@tensamin/ui/index.css";
import NotFound from "@/routes/404";
import AppLayout from "@/routes/app/layout";
import SettingsLayout from "@/features/settings/layout";
import { createSettingsRoute } from "@tensamin/settings";
import Home from "@/routes/app/home";
import ChatScreen from "@tensamin/chat/screen";
@ -41,8 +41,10 @@ import Storage from "@tensamin/storage/context";
import Session from "@tensamin/storage/session";
import Crypto from "@tensamin/crypto/context";
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
import { log } from "@tensamin/shared/log";
import LegalWrapper from "@/features/legal/screen";
import CacheSync from "@tensamin/cache/sync";
import { useStorage } from "@tensamin/storage/context";
import { useLocation, useNavigate } from "@tanstack/react-router";
@ -71,34 +73,39 @@ window.setLogLevelToMax = () => {
function LoginWrapper({ children }: { children: ReactNode }) {
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
const { load } = useStorage();
const { load, secureStorage } = useStorage();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
if (secureStorage === null) return;
let active = true;
load("user_id").then((userId) => {
if (!active) {
return;
}
if (userId !== 0) {
setLoggedIn(true);
return;
}
setLoggedIn(false);
navigate({
to: "/login",
Promise.all([load("user_id"), load("mtp_keyring")])
.then(([userId, keyring]) => {
if (!active) return;
if (userId !== 0 && keyring !== "") {
setLoggedIn(true);
if (location.pathname === "/login") {
void navigate({ to: "/", replace: true });
}
return;
}
setLoggedIn(false);
void navigate({
to: "/login",
replace: true,
});
})
.catch((error) => {
log(0, "login", "red", "Failed to load login state", error);
});
});
return () => {
active = false;
};
}, [load, navigate]);
}, [load, location.pathname, navigate, secureStorage]);
if (loggedIn !== true && location.pathname !== "/login") {
return null;
@ -256,6 +263,7 @@ function RootShell() {
function AppShell() {
return (
<MTPProvider>
<CacheSync />
<Session>
<UserProvider>
<CallInit />
@ -382,47 +390,7 @@ const appRoute = createRoute({
notFoundComponent: NotFound,
});
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 settingsRoute = createSettingsRoute(appRoute);
const homeRoute = createRoute({
getParentRoute: () => appRoute,
@ -464,12 +432,7 @@ const loginRoute = createRoute({
});
const routeTree = rootRoute.addChildren([
appRoute.addChildren([
homeRoute,
chatRoute,
callRoute,
settingsRoute.addChildren(settingsChildren),
]),
appRoute.addChildren([homeRoute, chatRoute, callRoute, settingsRoute]),
loginRoute,
]);

View file

@ -16,15 +16,35 @@ import { useState } from "react";
import { Loader2 } from "lucide-react";
import { isTauri } from "@tauri-apps/api/core";
import { useSession } from "@tensamin/storage/session";
import { useStorage } from "@tensamin/storage/context";
import { ShieldAlert } from "lucide-react";
// The page
export default function Page() {
const isMobile = useIsMobile();
const { secureStorage } = useStorage();
return (
<div className={`px-3 flex gap-2 ${!(isTauri() && isMobile) && "pt-3"}`}>
<AddConversationButton />
<Button disabled>Add Community</Button>
<div
className={`px-3 flex flex-col gap-3 ${!(isTauri() && isMobile) && "pt-3"}`}
>
<div className="flex gap-2">
<AddConversationButton />
<Button disabled>Add Community</Button>
</div>
{secureStorage && !secureStorage.secure && (
<div className="flex max-w-2xl gap-3 rounded-lg border border-(--destructive)/60 bg-(--destructive)/10 p-3 text-sm">
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-destructive" />
<div>
<p className="font-medium">Secure storage is unavailable</p>
<p>{secureStorage.reason}</p>
<p className="text-muted-foreground">
Your keyring and cached messages will get saved in regular
storage.
</p>
</div>
</div>
)}
</div>
);
}

View file

@ -1,45 +0,0 @@
import { List, Switch } from "@/features/settings/components";
import { Button, Kbd } from "@tensamin/ui";
import { useStorage } from "@tensamin/storage/context";
import { storageDefaults } from "@tensamin/shared/data";
export default function Page() {
const { save } = useStorage();
return (
<div className="flex flex-col gap-2">
<Switch
label={
<p>
Change <Kbd>Enter</Kbd> behavior to <Kbd>Shift</Kbd> +{" "}
<Kbd>Enter</Kbd>
</p>
}
id="settings.reverse_enter_behavior"
/>
<Switch
label="Enable read confirmations"
id="settings.read_confirmations"
/>
<Switch
label="Enable receive confirmations"
id="settings.receive_confirmations"
/>
<Switch
label="Sidebar message preview"
id="settings.show_start_of_last_message_in_sidebar"
/>
<p className="text-destructive pt-6">
Trusted embed domains can get your IP-Address! Only add domains if you
really trust them!
</p>
<Button
variant="outline"
onClick={() => void save("reactions", storageDefaults.reactions)}
>
Reset Emoji Ranks
</Button>
<List label="Trusted embed domains" id="chat_trusted_domains" />
</div>
);
}

View file

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

View file

@ -1,144 +0,0 @@
import {
Card,
CardContent,
CardTitle,
CardHeader,
CardDescription,
CardFooter,
Button,
Badge,
Dialog,
DialogContent,
DialogTrigger,
} from "@tensamin/ui";
import {
packages,
packageCount,
generatedAt,
} from "../../../../../licenses/third-party-credits.json";
const licenseTexts = import.meta.glob("../../../../../licenses/**/*", {
eager: true,
import: "default",
query: "?raw",
}) as Record<string, string>;
function getLicenseFiles(licensePackage: (typeof packages)[number]) {
return licensePackage.files.map((fileName) => {
const path =
"../../../../../" + licensePackage.licenseFolder + "/" + fileName;
return {
fileName,
text: licenseTexts[path],
};
});
}
export default function Page() {
return (
<div className="flex h-full min-h-0 flex-col gap-7">
<div className="flex flex-col">
<p>Last generated: {generatedAt}</p>
<p>Package Count: {packageCount}</p>
</div>
<div className="min-h-0 flex-1 max-h-[calc(100vh-180px)] overflow-auto pr-2">
<div className="flex flex-col gap-5">
{packages.map((licensePackage) => (
<Card
key={licensePackage.name + licensePackage.version}
id={licensePackage.name + licensePackage.version}
>
<CardHeader>
<CardTitle className="flex gap-2 items-center">
<Badge>{licensePackage.license}</Badge> {licensePackage.name}{" "}
{licensePackage.version}
</CardTitle>
</CardHeader>
{licensePackage.description && (
<CardContent>
<CardDescription>
{licensePackage.description}
</CardDescription>
</CardContent>
)}
<CardFooter className="gap-2">
<LicenseDialog licensePackage={licensePackage} />
{licensePackage.repository ? (
<a
target="_blank"
rel="noreferrer"
href={licensePackage.repository
?.replace("git+", "")
.replace(".git", "")}
>
<Button variant="outline" className="cursor-pointer">
Open Repository
</Button>
</a>
) : (
<Button disabled variant="outline" className="cursor-pointer">
Open Repository
</Button>
)}
{licensePackage.homepage ? (
<a
target="_blank"
rel="noreferrer"
href={licensePackage.homepage}
>
<Button variant="outline" className="cursor-pointer">
Open Homepage
</Button>
</a>
) : (
<Button disabled variant="outline" className="cursor-pointer">
Open Homepage
</Button>
)}
</CardFooter>
</Card>
))}
</div>
</div>
</div>
);
}
function LicenseDialog({
licensePackage,
}: {
licensePackage: (typeof packages)[number];
}) {
const licenseFiles = getLicenseFiles(licensePackage);
const hasLicenseText = licenseFiles.some(({ text }) => text);
return (
<Dialog>
<DialogTrigger
render={
<Button disabled={!hasLicenseText} className="cursor-pointer">
Open License
</Button>
}
/>
<DialogContent className="flex max-h-[85vh] min-h-0 flex-col overflow-hidden sm:max-w-3xl">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-2">
{licenseFiles.map(({ fileName, text }, index) => (
<section key={fileName} className="border-b last:border-b-0">
<h3
className={`border-b pb-2 text-sm font-medium ${index >= 1 && "pt-2"}`}
>
{fileName}
</h3>
<pre className="pt-2 whitespace-pre-wrap wrap-break-word text-xs leading-relaxed">
{text ||
"License text could not be loaded. Please contact support@tensamin.net"}
</pre>
</section>
))}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,218 +0,0 @@
import { useStorage } from "@tensamin/storage/context";
import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
cn,
Input,
useIsMobile,
} from "@tensamin/ui";
import { useUser, type User } from "@tensamin/user/context";
import { useEffect, useRef, useState } from "react";
import MDInput from "@tensamin/markdown/input";
import { mtp } from "@tensamin/shared/data";
import { useMTP } from "@tensamin/mtp";
import { Check } from "lucide-react";
async function prepImage(
file: File,
size = 300,
quality = 0.8,
): Promise<string> {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not get canvas context");
const scale = Math.max(size / bitmap.width, size / bitmap.height);
const width = bitmap.width * scale;
const height = bitmap.height * scale;
const x = (size - width) / 2;
const y = (size - height) / 2;
ctx.drawImage(bitmap, x, y, width, height);
return canvas.toDataURL("image/webp", quality);
}
export default function Page() {
const { get } = useUser();
const { load } = useStorage();
const { send } = useMTP();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [draftUser, setDraftUser] = useState<Partial<User>>({});
const [errorMessage, setErrorMessage] = useState("");
const [saveSucceeded, setSaveSucceeded] = useState(false);
const avatarUploadRef = useRef<HTMLInputElement>(null);
const draftInitializedRef = useRef(false);
const effectiveAvatar =
draftUser.Avatar === "none" ? undefined : draftUser.Avatar;
const updateDraftUser = (
updater: (previous: Partial<User>) => Partial<User>,
) => {
setSaveSucceeded(false);
setErrorMessage("");
setDraftUser(updater);
};
useEffect(() => {
const fetchUser = async () => {
const user = await get(await load("user_id"));
setCurrentUser(user);
};
fetchUser();
}, [load, get]);
useEffect(() => {
if (!currentUser || draftInitializedRef.current) return;
setDraftUser(currentUser);
draftInitializedRef.current = true;
}, [currentUser]);
const handleAvatarUpload = async (file: File) => {
const final = await prepImage(file);
updateDraftUser((prev) => ({ ...prev, avatar: final }));
if (avatarUploadRef.current) {
avatarUploadRef.current.value = "";
}
};
const isMobile = useIsMobile();
return currentUser ? (
<>
<input
ref={avatarUploadRef}
hidden
onChange={(e) =>
e.target.files?.[0] && handleAvatarUpload(e.target.files[0])
}
type="file"
/>
<div className={cn("flex flex-col gap-5", isMobile ? "w-full" : "w-80")}>
<div className="flex items-center gap-3">
<Avatar className="size-14">
<AvatarImage src={effectiveAvatar} />
<AvatarFallback className="text-2xl">
{draftUser.Display?.slice(0, 2).toUpperCase() ||
currentUser.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-1">
<p>Avatar</p>
<div className="flex gap-1">
<Button onClick={() => avatarUploadRef.current?.click()}>
Upload avatar
</Button>
<Button
onClick={() => {
updateDraftUser((prev) => {
return { ...prev, avatar: "none" };
});
}}
variant="destructive"
disabled={effectiveAvatar === undefined}
>
Remove
</Button>
</div>
<p className="text-sm text-muted-foreground">
GIFs are supported in decentralised mode or with Tensamin Premium.
<br />
Maximum file size is 16mb.
</p>
</div>
</div>
<Input
className="w-full"
onChange={(event) =>
updateDraftUser((prev) => ({
...prev,
display: event.target.value,
}))
}
placeholder="Display Name"
value={draftUser.Display || ""}
/>
<Input
className="w-full"
onChange={(event) =>
updateDraftUser((prev) => ({
...prev,
username: event.target.value,
}))
}
placeholder="Username"
value={draftUser.Username || ""}
/>
<MDInput
styled
paddingY="4px"
paddingX="10px"
fontSize=".875rem"
placeholder="About Me"
setValue={(value) =>
updateDraftUser((prev) => ({ ...prev, about: value }))
}
value={draftUser.About || ""}
/>
<Button
onClick={async () => {
const { Avatar, ...draftUsersWithoutAvatar } = draftUser;
const payload = {
...draftUsersWithoutAvatar,
...(typeof Avatar === "string"
? {
avatar: Avatar.startsWith("data:")
? (Avatar.split(",", 2)[1] ?? "")
: Avatar,
}
: {}),
};
const validation = mtp.ChangeUserData.request.safeParse(payload);
if (!validation.success) {
setSaveSucceeded(false);
setErrorMessage(
validation.error.issues[0]?.message ?? "Invalid profile data",
);
return;
}
try {
await send("ChangeUserData", validation.data);
setSaveSucceeded(true);
setErrorMessage("");
} catch (err) {
setSaveSucceeded(false);
setErrorMessage("Failed to update profile: " + err);
}
}}
>
{saveSucceeded ? (
<span className="inline-flex items-center gap-1.5">
<Check className="size-4" />
Saved
</span>
) : (
"Save"
)}
</Button>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</div>
</>
) : (
<p>Loading...</p>
);
}

View file

@ -1,93 +0,0 @@
import { useStorage } from "@tensamin/storage/context";
import { Button, Input, Label } from "@tensamin/ui";
import { useEffect, useState } from "react";
export default function Page() {
const { save, load } = useStorage();
const [draftOmegaUrl, setDraftOmegaUrl] = useState("");
const [currentOmegaUrl, setCurrentOmegaUrl] = useState("");
const [draftForcedOmikronUrl, setDraftForcedOmikronUrl] = useState("");
const [currentForcedOmikronUrl, setForcedForcedOmikronUrl] = useState("");
const [draftForcedOmikronPublicKey, setDraftForcedOmikronPublicKey] =
useState("");
const [currentForcedOmikronPublicKey, setForcedForcedOmikronPublicKey] =
useState("");
useEffect(() => {
load("omega_url").then((value) => {
setDraftOmegaUrl(value);
setCurrentOmegaUrl(value);
});
load("forced_omikron_url").then((value) => {
setDraftForcedOmikronUrl(value || "");
setForcedForcedOmikronUrl(value || "");
});
load("forced_omikron_public_key").then((value) => {
setDraftForcedOmikronPublicKey(value || "");
setForcedForcedOmikronPublicKey(value || "");
});
}, [load]);
return (
<div className="flex flex-col gap-8">
<p className="text-destructive">
It's best not to touch these! They can be exploited to gain access to
your account!
</p>
<div className="flex flex-col gap-2">
<Label>Omega Url</Label>
<div className="flex gap-1">
<Input
value={draftOmegaUrl}
onChange={(e) => setDraftOmegaUrl(e.target.value)}
/>
<Button
disabled={currentOmegaUrl === draftOmegaUrl}
onClick={() => {
save("omega_url", draftOmegaUrl).then(() =>
setCurrentOmegaUrl(draftOmegaUrl),
);
}}
>
Save
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Forced Omikron</Label>
<div className="flex gap-1">
<Input
placeholder="URL..."
value={draftForcedOmikronUrl || ""}
onChange={(e) => setDraftForcedOmikronUrl(e.target.value)}
/>
<Input
placeholder="Public Key..."
value={draftForcedOmikronPublicKey || ""}
onChange={(e) => setDraftForcedOmikronPublicKey(e.target.value)}
/>
<Button
disabled={
currentForcedOmikronUrl === draftForcedOmikronUrl &&
currentForcedOmikronPublicKey === draftForcedOmikronPublicKey
}
onClick={() => {
save("forced_omikron_url", draftForcedOmikronUrl).then(() =>
setForcedForcedOmikronUrl(draftForcedOmikronUrl),
);
save(
"forced_omikron_public_key",
draftForcedOmikronPublicKey,
).then(() =>
setForcedForcedOmikronPublicKey(draftForcedOmikronPublicKey),
);
}}
>
Save
</Button>
</div>
</div>
</div>
);
}

View file

@ -1,20 +0,0 @@
import { StylePicker } from "@tensamin/ui";
export default function Page() {
return (
<div className="overflow-y-auto">
<StylePicker />
<div className="absolute bottom-0 right-0 mb-3 mr-2">
<a
className="block w-60 text-xs whitespace-pre-wrap"
href="https://git.methanium.net/tensamin/client/issues/new"
target="_blank"
rel="noreferrer"
>
Please open a Git issue to help us improve this feature. We want to
get it right.
</a>
</div>
</div>
);
}

View file

@ -85,6 +85,7 @@ export default defineConfig({
"@tanstack/router-core",
"@tanstack/store",
"@tensamin/crypto",
"@tensamin/settings",
"@tensamin/storage",
"@tensamin/mtp",
"@tensamin/user",
@ -138,6 +139,7 @@ export default defineConfig({
"@tensamin/shared",
"@tensamin/shared/data",
"@tensamin/shared/log",
"@tensamin/settings",
"@tensamin/storage",
"@tensamin/storage/context",
"@tensamin/tauri",