(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

@ -0,0 +1,128 @@
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={(nextValue) => {
setValue(nextValue);
save(id, nextValue);
}} />
<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];
return (typeof referenceItem === "number" ? Number(value) : 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

@ -0,0 +1,24 @@
import { createRoute, type AnyRoute } from "@tanstack/react-router";
import SettingsLayout from "./layout";
import { settingsPages } from "./manifest";
export function createSettingsRoute(parentRoute: AnyRoute) {
const settingsRoute = createRoute({
getParentRoute: () => parentRoute,
path: "settings",
component: SettingsLayout,
staticData: { showMobileNavbar: true },
});
return settingsRoute.addChildren(
settingsPages.map((page) =>
createRoute({
getParentRoute: () => settingsRoute,
path: page.path,
component: page.component,
staticData: { showMobileNavbar: true },
}),
),
);
}

View file

@ -0,0 +1,61 @@
import { Outlet, useLocation, useNavigate } from "@tanstack/react-router";
import { Button, ClearStorageButton, cn, useIsMobile } from "@tensamin/ui";
import { settingsNavigation } from "./manifest";
export default function SettingsLayout() {
const isMobile = useIsMobile();
const location = useLocation();
return (
<div className="flex h-full w-full">
{!isMobile && <SettingsSidebar />}
<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, (letter) => letter.toUpperCase())}
</h1>
<Outlet />
</div>
</div>
);
}
export function SettingsSidebar() {
const isMobile = useIsMobile();
const navigate = useNavigate();
const categories = [...new Set(settingsNavigation.map((page) => page.category))];
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",
)}
>
{categories.map((category) => (
<div key={category} className="flex flex-col gap-2">
<h2 className="font-bold text-xs uppercase">{category}</h2>
{settingsNavigation
.filter((page) => page.category === category)
.map((page) => (
<Button
key={page.path}
className="w-full"
variant="outline"
onClick={() => navigate({ to: `/settings/${page.path}` })}
>
{page.label}
</Button>
))}
</div>
))}
<div className="mt-auto">
<ClearStorageButton className="w-full" />
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
import Cache from "./pages/cache";
import Chat from "./pages/chat";
import Index from "./pages/index";
import Licenses from "./pages/licenses";
import Profile from "./pages/profile";
import Security from "./pages/security";
import Theme from "./pages/theme";
export const settingsPages = [
{ path: "/", component: Index },
{ category: "general", path: "chat", label: "Chat", component: Chat },
{
category: "account",
path: "profile",
label: "Profile",
component: Profile,
},
{
category: "account",
path: "security",
label: "Security",
component: Security,
},
{ category: "application", path: "cache", label: "Cache", component: Cache },
{ category: "application", path: "theme", label: "Theme", component: Theme },
{
category: "application",
path: "licenses",
label: "Licenses",
component: Licenses,
},
] as const;
export const settingsNavigation = settingsPages.filter(
(page): page is Exclude<(typeof settingsPages)[number], { path: "/" }> =>
page.path !== "/",
);

View file

@ -0,0 +1,101 @@
import { createCache } from "@tensamin/cache";
import { storageDefaults } from "@tensamin/shared/data";
import { useStorage } from "@tensamin/storage/context";
import { secureValueCodec } from "@tensamin/storage/secure";
import { Button, Input, Label } from "@tensamin/ui";
import { useEffect, useState } from "react";
const validLimit = (value: number) => Number.isSafeInteger(value) && value >= 0;
export default function Page() {
const { load, save } = useStorage();
const [contacts, setContacts] = useState(storageDefaults.cache_contacts);
const [messagesPerChat, setMessagesPerChat] = useState(
storageDefaults.cache_messages_per_chat,
);
const [savedContacts, setSavedContacts] = useState(contacts);
const [savedMessagesPerChat, setSavedMessagesPerChat] =
useState(messagesPerChat);
const [saving, setSaving] = useState(false);
useEffect(() => {
void Promise.all([
load("cache_contacts"),
load("cache_messages_per_chat"),
]).then(([nextContacts, nextMessages]) => {
setContacts(nextContacts);
setSavedContacts(nextContacts);
setMessagesPerChat(nextMessages);
setSavedMessagesPerChat(nextMessages);
});
}, [load]);
const valid = validLimit(contacts) && validLimit(messagesPerChat);
const changed =
contacts !== savedContacts || messagesPerChat !== savedMessagesPerChat;
async function persist() {
if (!valid || saving) return;
setSaving(true);
try {
await Promise.all([
save("cache_contacts", contacts),
save("cache_messages_per_chat", messagesPerChat),
]);
const accountId = await load("user_id");
if (accountId)
await createCache(String(accountId), {
codec: secureValueCodec,
}).conversations.prune();
setSavedContacts(contacts);
setSavedMessagesPerChat(messagesPerChat);
} finally {
setSaving(false);
}
}
return (
<div className="flex max-w-xl flex-col gap-6">
<div className="flex flex-col gap-2">
<Label htmlFor="cache-contacts">Contacts</Label>
<Input
id="cache-contacts"
type="number"
min={0}
step={1}
value={Number.isNaN(contacts) ? "" : contacts}
onChange={(event) => setContacts(event.currentTarget.valueAsNumber)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="cache-messages">Messages per chat</Label>
<Input
id="cache-messages"
type="number"
min={0}
step={1}
value={Number.isNaN(messagesPerChat) ? "" : messagesPerChat}
onChange={(event) =>
setMessagesPerChat(event.currentTarget.valueAsNumber)
}
/>
</div>
{!valid && (
<p className="text-sm text-destructive">
Cache limits must be whole numbers greater than or equal to 0.
</p>
)}
<div className="flex gap-2">
<Button disabled={!valid || !changed || saving} onClick={persist}>
Save
</Button>
<Button
variant="outline"
disabled={saving}
onClick={() => {
setContacts(storageDefaults.cache_contacts);
setMessagesPerChat(storageDefaults.cache_messages_per_chat);
}}
>
Reset
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,46 @@
import { storageDefaults } from "@tensamin/shared/data";
import { useStorage } from "@tensamin/storage/context";
import { Button, Kbd } from "@tensamin/ui";
import { List, Switch } from "../components";
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>
<div>
<Button
variant="outline"
onClick={() => void save("reactions", storageDefaults.reactions)}
>
Reset Emoji Ranks
</Button>
</div>
<List label="Trusted embed domains" id="chat_trusted_domains" />
</div>
);
}

View file

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

View file

@ -0,0 +1,32 @@
import { Badge, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Dialog, DialogContent, DialogTrigger } from "@tensamin/ui";
import { generatedAt, packageCount, packages } 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) => ({
fileName,
text: licenseTexts["../../../../" + licensePackage.licenseFolder + "/" + fileName],
}));
}
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);
return <Dialog><DialogTrigger render={<Button disabled={!licenseFiles.some(({ text }) => text)} 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

@ -0,0 +1,67 @@
import MDInput from "@tensamin/markdown/input";
import { useMTP } from "@tensamin/mtp";
import { mtp } from "@tensamin/shared/data";
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 { Check } from "lucide-react";
import { useEffect, useRef, useState } from "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 context = canvas.getContext("2d");
if (!context) 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;
context.drawImage(bitmap, (size - width) / 2, (size - height) / 2, width, height);
return canvas.toDataURL("image/webp", quality);
}
export default function Page() {
const { get } = useUser();
const { load } = useStorage();
const { send } = useMTP();
const isMobile = useIsMobile();
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(() => { void (async () => setCurrentUser(await get(await load("user_id"))))(); }, [get, load]);
useEffect(() => {
if (!currentUser || draftInitializedRef.current) return;
setDraftUser(currentUser); draftInitializedRef.current = true;
}, [currentUser]);
async function handleAvatarUpload(file: File) {
const avatar = await prepImage(file);
updateDraftUser((previous) => ({ ...previous, avatar }));
if (avatarUploadRef.current) avatarUploadRef.current.value = "";
}
if (!currentUser) return <p>Loading...</p>;
return <>
<input ref={avatarUploadRef} hidden onChange={(event) => event.target.files?.[0] && handleAvatarUpload(event.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((previous) => ({ ...previous, 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((previous) => ({ ...previous, display: event.target.value }))} placeholder="Display Name" value={draftUser.Display || ""} />
<Input className="w-full" onChange={(event) => updateDraftUser((previous) => ({ ...previous, username: event.target.value }))} placeholder="Username" value={draftUser.Username || ""} />
<MDInput styled paddingY="4px" paddingX="10px" fontSize=".875rem" placeholder="About Me" setValue={(value) => updateDraftUser((previous) => ({ ...previous, 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 (error) { setSaveSucceeded(false); setErrorMessage("Failed to update profile: " + error); }
}}>{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>
</>;
}

View file

@ -0,0 +1,25 @@
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, setCurrentForcedOmikronUrl] = useState("");
const [draftForcedOmikronPublicKey, setDraftForcedOmikronPublicKey] = useState("");
const [currentForcedOmikronPublicKey, setCurrentForcedOmikronPublicKey] = useState("");
useEffect(() => {
load("omega_url").then((value) => { setDraftOmegaUrl(value); setCurrentOmegaUrl(value); });
load("forced_omikron_url").then((value) => { setDraftForcedOmikronUrl(value || ""); setCurrentForcedOmikronUrl(value || ""); });
load("forced_omikron_public_key").then((value) => { setDraftForcedOmikronPublicKey(value || ""); setCurrentForcedOmikronPublicKey(value || ""); });
}, [load]);
return <div className="flex flex-col gap-8">
<p className="text-destructive">It's best not to touch these settings! 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={(event) => setDraftOmegaUrl(event.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={(event) => setDraftForcedOmikronUrl(event.target.value)} /><Input placeholder="Public Key..." value={draftForcedOmikronPublicKey} onChange={(event) => setDraftForcedOmikronPublicKey(event.target.value)} /><Button disabled={currentForcedOmikronUrl === draftForcedOmikronUrl && currentForcedOmikronPublicKey === draftForcedOmikronPublicKey} onClick={() => { save("forced_omikron_url", draftForcedOmikronUrl).then(() => setCurrentForcedOmikronUrl(draftForcedOmikronUrl)); save("forced_omikron_public_key", draftForcedOmikronPublicKey).then(() => setCurrentForcedOmikronPublicKey(draftForcedOmikronPublicKey)); }}>Save</Button></div></div>
</div>;
}

View file

@ -0,0 +1,5 @@
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>;
}