(feat): big call and chatting stuff #23

Merged
alois merged 33 commits from dev into main 2026-08-01 03:05:40 +03:00
24 changed files with 842 additions and 541 deletions
Showing only changes of commit ace7973dff - Show all commits

(feat): big methanium/ui migration
Some checks failed
/ build-web (push) Failing after 5m27s
/ build-desktop (linux) (push) Failing after 5m54s
/ build-mobile (push) Failing after 8m0s
/ release (push) Has been skipped

(feat): update message state icons
(qol): update todo
(qol): formatted
Alois 2026-07-26 18:55:46 +02:00
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "mtp-type-maps"]
path = mtp-type-maps
url = https://git.methanium.net/tensamin/mtp-type-maps

1
TODO
View file

@ -1,5 +1,6 @@
- Add a bunch of tests
- Add packages/hotkeys/
- Full accessability
- Add settings saving & update onboarding to use it
-> Most folders in packages/ have specific todos

View file

@ -8,14 +8,12 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function getTrayIconPath(filename: string) {
if (app.isPackaged) return path.join(process.resourcesPath, "icons", filename);
if (app.isPackaged)
return path.join(process.resourcesPath, "icons", filename);
return path.resolve(__dirname, "../../build/icons", filename);
}
export function setTrayCallStatus(
inCall: boolean,
iconDataUrl?: string,
) {
export function setTrayCallStatus(inCall: boolean, iconDataUrl?: string) {
if (!tray) return;
if (inCall && iconDataUrl) {

View file

@ -10,7 +10,12 @@ import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
import { joinCall, useCall } from "@tensamin/call/store";
import Wrapper from "@tensamin/user/wrapper";
import { Skeleton } from "@methanium/ui";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@methanium/ui";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@methanium/ui";
import { displayCallId } from "@tensamin/call/utils";
import { useState } from "react";
import { SidebarTrigger, useSidebar } from "@methanium/ui";
@ -43,7 +48,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<div className="flex items-center justify-center gap-2">
{forMobile ? (
<SidebarTrigger
className="w-9 h-9 aspect-square rounded-lg ml-2"
className="w-9 h-9! aspect-square rounded-lg ml-2 bg-(--input)/35!"
variant="outline"
>
<ArrowLeft className="size-4.5" />
@ -52,14 +57,14 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<>
<Button
onClick={() => navigate({ to: "/" })}
className="w-9 h-9 aspect-square rounded-lg"
className="w-9 h-9! aspect-square rounded-lg bg-(--input)/35!"
variant="outline"
>
<House className="size-4.5" />
</Button>
<Button
onClick={() => navigate({ to: "/settings" })}
className="w-9 h-9 aspect-square rounded-lg"
className="w-9 h-9! aspect-square rounded-lg bg-(--input)/35!"
variant="outline"
>
<Settings className="size-4.5" />
@ -109,7 +114,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
onClick={() => {
void joinCall(id);
}}
className="w-9 h-9 aspect-square rounded-lg"
className="w-9 h-9! aspect-square rounded-lg bg-(--input)/35!"
variant="outline"
>
<Phone />
@ -124,7 +129,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
currentCalls[0].CallId,
);
}}
className="w-9 h-9 aspect-square rounded-lg"
className="w-9 h-9! aspect-square rounded-lg bg-(--input)/35!"
>
<Phone />
</Button>
@ -175,7 +180,7 @@ export function MobileNavbar() {
<div className="z-51 w-full shrink-0 pb-[env(safe-area-inset-bottom)] bg-card border-t">
<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 flex flex-col gap-1"
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1 border-0!"
variant="link"
>
<User className="size-4.5" />
@ -186,7 +191,7 @@ export function MobileNavbar() {
navigate({ to: "/" });
setOpenMobile(false);
}}
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! hover:bg-transparent!"
variant="link"
>
<House className="size-4.5" />
@ -197,7 +202,7 @@ export function MobileNavbar() {
navigate({ to: "/settings" });
setOpenMobile(false);
}}
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! hover:bg-transparent!"
variant="link"
>
<Settings className="size-4.5" />

View file

@ -131,6 +131,11 @@ function ThemeStorageBridge() {
setThemeBorderRadius,
themeCustomCss,
setThemeCustomCss,
parentThemeId,
setParentThemeId,
applyThemePreset,
themeDesign,
setThemeDesign,
} = useTheme();
const loadedRef = useRef(false);
@ -145,6 +150,8 @@ function ThemeStorageBridge() {
load("theme_tint"),
load("theme_border_radius"),
load("theme_custom_css"),
load("theme_parent"),
load("theme_design"),
]).then(
([
color,
@ -154,18 +161,23 @@ function ThemeStorageBridge() {
tint,
borderRadius,
customCss,
parent,
design,
]) => {
if (!active) {
return;
}
if (customCss === "" && parent) applyThemePreset(parent);
else setParentThemeId(parent || null);
setThemeColor(color);
setThemePalette(palette);
setThemePrimaryColor(primaryColor);
setThemePolarity(polarity);
setThemeTint(tint);
setThemeBorderRadius(borderRadius);
setThemeCustomCss(customCss);
if (customCss !== "") setThemeCustomCss(customCss);
setThemeDesign(design);
loadedRef.current = true;
},
);
@ -175,9 +187,12 @@ function ThemeStorageBridge() {
};
}, [
load,
applyThemePreset,
setThemeBorderRadius,
setThemeColor,
setThemeCustomCss,
setParentThemeId,
setThemeDesign,
setThemePalette,
setThemePolarity,
setThemePrimaryColor,
@ -212,6 +227,14 @@ function ThemeStorageBridge() {
if (loadedRef.current) save("theme_custom_css", themeCustomCss);
}, [save, themeCustomCss]);
useEffect(() => {
if (loadedRef.current) save("theme_parent", parentThemeId ?? "");
}, [parentThemeId, save]);
useEffect(() => {
if (loadedRef.current) save("theme_design", themeDesign);
}, [save, themeDesign]);
return null;
}
@ -228,6 +251,8 @@ function RootShell() {
tintStorageKey={null}
borderRadiusStorageKey={null}
customCssStorageKey={null}
parentThemeStorageKey={null}
designStorageKey={null}
>
<div className="w-screen h-dvh overflow-hidden">
<Toaster

View file

@ -131,7 +131,11 @@ function AddConversationButton() {
setOpen(value);
}}
>
<DialogTrigger render={<Button>Add Conversation</Button>} />
<DialogTrigger
render={({ onClick }) => (
<Button onClick={onClick}>Add Conversation</Button>
)}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>

View file

@ -7,6 +7,7 @@ import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { mtp } from "mtp/vite";
import { methaniumUi } from "@methanium/ui/vite";
const host = process.env.TAURI_DEV_HOST;
const appDir = dirname(fileURLToPath(import.meta.url));
@ -153,8 +154,9 @@ export default defineConfig({
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
plugins: [
methaniumUi({ defaultThemeId: "tensamin" }),
deepFilterAssetHeaders(resolve(appDir, "public")),
mtp({ typeMaps: resolve(appDir, "../../type-maps.yaml") }),
mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }),
{
name: "workspace-realpath-resolution",
enforce: "post",

1
mtp-type-maps Submodule

@ -0,0 +1 @@
Subproject commit 6d97b5b9935b15d957444d38609378c66a24262a

View file

@ -46,7 +46,7 @@
"yaml": "^2.8.2"
},
"dependencies": {
"@methanium/ui": "*",
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.2/methanium-ui.tgz",
"mtp": "*",
"sonner": "^2.0.7"
}

View file

@ -175,12 +175,7 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
const userId = Number(key);
return retained.has(userId)
? []
: [
deleteDatabaseEntry(
"cache",
storedKey("conversations", key),
),
];
: [deleteDatabaseEntry("cache", storedKey("conversations", key))];
}),
);
},
@ -210,12 +205,7 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
const userId = Number(key);
return retained.has(userId)
? []
: [
deleteDatabaseEntry(
"cache",
storedKey("conversations", key),
),
];
: [deleteDatabaseEntry("cache", storedKey("conversations", key))];
}),
);
},
@ -241,12 +231,7 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
storedEntries.flatMap(([key]) =>
retained.has(Number(key))
? []
: [
deleteDatabaseEntry(
"cache",
storedKey("conversations", key),
),
],
: [deleteDatabaseEntry("cache", storedKey("conversations", key))],
),
);
},

View file

@ -1,6 +1,6 @@
import type { RawMessage } from "../values";
import Text from "@tensamin/markdown/text";
import { AlertTriangle, Check, Ellipse, RefreshCw } from "lucide-react";
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
import { memo, useCallback, useEffect, useState } from "react";
import type { User } from "@tensamin/user/context";
@ -289,11 +289,11 @@ function MessageComponent({
color="var(--primary-foreground-alt)"
/>
) : message.MessageState === "received" ? (
<Check size={12} color="var(--muted-foreground)" />
<CheckLine size={12} color="var(--muted-foreground)" />
) : message.MessageState === "sent" ? (
<RefreshCw size={12} color="var(--muted-foreground)" />
<Check size={12} color="var(--muted-foreground)" />
) : message.MessageState === "sending" ? (
<Ellipse size={12} color="var(--muted-foreground)" />
<RefreshCw size={12} color="var(--muted-foreground)" />
) : null}
{actuallyFailed && (
<AlertTriangle color="var(--destructive)" size={12} />

View file

@ -36,6 +36,9 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
const [showLoading, setShowLoading] = useState(false);
const [error, setError] = useState("");
const [errorDescription, setErrorDescription] = useState("");
const [onboardingThemeId, setOnboardingThemeId] = useState<string | null>(
null,
);
useEffect(() => {
let active = true;
@ -63,12 +66,18 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
return;
}
const [localDocs, acceptedPP, acceptedTOS, onboardingDone] =
await Promise.all([
const [
localDocs,
acceptedPP,
acceptedTOS,
onboardingDone,
onboardingStarted,
] = await Promise.all([
load("legal_docs"),
load("accepted_privacy_policy"),
load("accepted_terms_of_service"),
load("onboarding_done"),
load("onboarding_started"),
]);
if (!active) return;
@ -78,11 +87,15 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
const currentAcceptedTOS =
acceptedTOS && localDocs.tos.hash === parsed.data.tos.hash;
const existingUser = acceptedPP && acceptedTOS;
const includeOnboarding = !onboardingDone && !existingUser;
const includeOnboarding =
!onboardingDone && (!existingUser || onboardingStarted);
if (!onboardingDone && existingUser) {
if (!onboardingDone && existingUser && !onboardingStarted) {
await save("onboarding_done", true);
if (!active) return;
} else if (includeOnboarding && !onboardingStarted) {
await save("onboarding_started", true);
if (!active) return;
}
setState({
@ -124,7 +137,10 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
const finish = useCallback(async () => {
if (state?.includeOnboarding) {
await save("onboarding_done", true);
await Promise.all([
save("onboarding_done", true),
save("onboarding_started", false),
]);
}
setComplete(true);
}, [save, state?.includeOnboarding]);
@ -142,6 +158,8 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
if (state.includeLegal) {
steps.push({
id: "legal",
title: "Privacy Policy & ToS",
description: `${state.docs.pp.version} / ${state.docs.tos.version}`,
defaultCanContinue: false,
content: (
<LegalPage
@ -154,7 +172,7 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
});
}
if (state.includeOnboarding) {
steps.push(...onboardingSteps);
steps.push(...onboardingSteps(onboardingThemeId, setOnboardingThemeId));
}
if (complete || steps.length === 0) return <>{children}</>;

View file

@ -32,16 +32,7 @@ export default function LegalPage({
});
return (
<div className="mx-auto flex h-full w-full max-w-5xl flex-col gap-10 p-10 py-20 md:p-24">
<div>
<h1 className="text-3xl font-bold md:text-4xl" tabIndex={-1}>
Privacy Policy &amp; ToS
</h1>
<p className="pt-3 text-xl text-muted-foreground">
{docs.pp.version} / {docs.tos.version}
</p>
</div>
<div className="mx-auto flex min-h-[calc(100dvh-17rem)] w-full max-w-5xl flex-col gap-10 p-10 py-20 md:min-h-[calc(100dvh-20.5rem)] md:p-24">
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-start gap-2">
<BigCheckbox

View file

@ -1,9 +0,0 @@
export default function OnboardingPage() {
return (
<div className="flex h-full items-center justify-center p-8">
<h1 className="text-3xl font-bold" tabIndex={-1}>
Onboarding
</h1>
</div>
);
}

View file

@ -1,10 +1,15 @@
import type { OnboardingStep } from "@methanium/ui";
import OnboardingPage from "./pages/onboarding";
import { ThemeOnboardingPage, type OnboardingStep } from "@methanium/ui";
// Add future onboarding pages here. Completed users skip this entire manifest.
export const onboardingSteps: OnboardingStep[] = [
export const onboardingSteps = (
themeId: string | null,
setThemeId: (id: string) => void,
): OnboardingStep[] => [
{
id: "onboarding",
content: <OnboardingPage />,
id: "theme",
title: "Theming",
description:
"Pick a main theme and after that ... let's just hope you don't get into decision paralysis",
defaultCanContinue: false,
content: <ThemeOnboardingPage value={themeId} onValueChange={setThemeId} />,
},
];

View file

@ -27,12 +27,16 @@ export default function SettingsLayout() {
export function SettingsSidebar() {
const isMobile = useIsMobile();
const navigate = useNavigate();
const categories = [...new Set(settingsNavigation.map((page) => page.category))];
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",
isMobile
? "w-full p-1"
: "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
"flex flex-col gap-6",
)}
>

View file

@ -1,32 +1,144 @@
import { Badge, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Dialog, DialogContent, DialogTrigger } from "@methanium/ui";
import { generatedAt, packageCount, packages } from "../../../../licenses/third-party-credits.json";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogTrigger,
} from "@methanium/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>;
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],
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>}
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
className="pb-0!"
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>;
</Card>
))}
</div>
</div>
</div>
);
}
function LicenseDialog({ licensePackage }: { licensePackage: (typeof packages)[number] }) {
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>;
return (
<Dialog>
<DialogTrigger
render={({ onClick }) => (
<Button
onClick={onClick}
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

@ -2,21 +2,40 @@ 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 "@methanium/ui";
import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
cn,
Input,
useIsMobile,
} from "@methanium/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> {
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;
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);
context.drawImage(
bitmap,
(size - width) / 2,
(size - height) / 2,
width,
height,
);
return canvas.toDataURL("image/webp", quality);
}
@ -31,14 +50,22 @@ export default function Page() {
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);
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(() => {
void (async () => setCurrentUser(await get(await load("user_id"))))();
}, [get, load]);
useEffect(() => {
if (!currentUser || draftInitializedRef.current) return;
setDraftUser(currentUser); draftInitializedRef.current = true;
setDraftUser(currentUser);
draftInitializedRef.current = true;
}, [currentUser]);
async function handleAvatarUpload(file: File) {
const avatar = await prepImage(file);
@ -46,22 +73,128 @@ export default function Page() {
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" />
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 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

@ -8,18 +8,87 @@ export default function Page() {
const [currentOmegaUrl, setCurrentOmegaUrl] = useState("");
const [draftForcedOmikronUrl, setDraftForcedOmikronUrl] = useState("");
const [currentForcedOmikronUrl, setCurrentForcedOmikronUrl] = useState("");
const [draftForcedOmikronPublicKey, setDraftForcedOmikronPublicKey] = useState("");
const [currentForcedOmikronPublicKey, setCurrentForcedOmikronPublicKey] = 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("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>;
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

@ -1,5 +1,18 @@
import { StylePicker } from "@methanium/ui";
import { ThemeCustomizer } from "@methanium/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>;
return (
<div className="h-full overflow-y-auto p-5 md:p-8">
<ThemeCustomizer />
<a
className="mt-8 block max-w-md text-xs text-muted-foreground"
href="https://git.methanium.net/tensamin/client/issues/new"
target="_blank"
rel="noreferrer"
>
Please open an issue on Git to tell us what you would like to see in
here.
</a>
</div>
);
}

View file

@ -1,4 +1,5 @@
import { z } from "zod";
import type { ThemeDesign } from "@methanium/ui";
import { legalDocsSchema } from "./features/legal/schema";
import {
@ -422,6 +423,7 @@ export interface Storage extends SettingsStorageDefaults {
user_id: number;
mtp_keyring: string;
onboarding_done: boolean;
onboarding_started: boolean;
ppandtos_done: boolean;
accepted_terms_of_service: boolean;
accepted_privacy_policy: boolean;
@ -445,6 +447,8 @@ export interface Storage extends SettingsStorageDefaults {
theme_tint: "soft" | "hard" | "extreme";
theme_border_radius: number;
theme_custom_css: string;
theme_parent: string;
theme_design: ThemeDesign;
chat_trusted_domains: string[];
chat_picker_saved_media: string[];
chat_picker_last_tab: "gif" | "meme" | "saved";
@ -460,6 +464,7 @@ export const storageDefaults: Storage = {
user_id: 0,
mtp_keyring: "",
onboarding_done: false,
onboarding_started: false,
ppandtos_done: false,
accepted_terms_of_service: false,
accepted_privacy_policy: false,
@ -498,8 +503,18 @@ export const storageDefaults: Storage = {
theme_primary_color: "",
theme_polarity: "system",
theme_tint: "soft",
theme_border_radius: 0.55,
theme_border_radius: 0.5,
theme_custom_css: "",
theme_parent: "tensamin",
theme_design: {
density: 1,
borderWidth: 1,
shadowStrength: 0,
fontScale: 1,
headingWeight: 600,
motion: 1,
fontFamily: "public-sans",
},
chat_trusted_domains: [
// Other platforms
"cdn.discordapp.com",

462
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -6,5 +6,5 @@ allowBuilds:
electron-winstaller: true
esbuild: true
overrides:
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.1/methanium-ui.tgz"
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.6/methanium-ui.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-3919eb46fd/mtp-0.2.0.tgz"

View file

@ -1,270 +0,0 @@
protocol_version: "1.0"
# Note that markers 0 to 31 are reserved for default use, manually working with them is not recommended
# Fixed CommunicationType markers are:
# Error: 0
# ErrorParsing: 1
# ErrorBadVersion: 2
# Disconnect: 3
# Redirect: 4
# Shutdown: 5
# BadRequest: 6
# Unauthorized: 7
# Forbidden: 8
# NotFound: 9
# TooManyRequests: 10
# InternalServerError: 11
# BadGateway: 12
# ServiceUnavailable: 13
# GatewayTimeout: 14
# Identification: 15
# IdentificationResponse: 16
# Register: 17
# RegisterResponse: 18
# Ping: 19
# Pong: 20
# Fixed Data Type markers are:
# Error: 0
# ErrorParsing: 1
# ErrorMessage: 2
# Version: 3
# Description: 4
# Timestamp: 5
# Id: 6
# ClientNonce: 7
# ServerNonce: 8
# PublicKeys: 9
# Signature: 10
# Connected: 11
type_maps:
"1.0":
CommunicationTypes:
ErrorProtocol: 33
ErrorAnonymous: 34
ErrorInternal: 35
ErrorInvalidData: 36
ErrorInvalidUserId: 37
ErrorInvalidOmikronId: 38
ErrorNotFound: 39
ErrorNotAuthenticated: 40
ErrorNoIota: 41
ErrorInvalidChallenge: 42
ErrorInvalidSecret: 43
ErrorInvalidPrivateKey: 44
ErrorInvalidPublicKey: 45
ErrorNoUserId: 46
ErrorNoCallId: 47
ErrorInvalidCallId: 48
Success: 49
ShortenLink: 50
SettingsSave: 51
SettingsLoad: 52
SettingsList: 53
GlobalSettingsSave: 54
GlobalSettingsLoad: 55
Message: 56
MessageState: 57
MessageSend: 58
MessageLive: 59
MessageOtherIota: 60
MessageChunk: 61
MessageGet: 143
MessagesGet: 62
MessageDelete: 149
PushNotification: 63
ReadNotification: 64
GetNotifications: 65
TauriIdentification: 66
ChangeConfirm: 67
ConfirmReceive: 68
ConfirmRead: 69
GetChats: 70
GetStates: 71
AddCommunity: 72
RemoveCommunity: 73
GetCommunities: 74
RegisterIota: 81
RegisterIotaSuccess: 82
AddConversation: 85
SendChat: 86
ClientChanged: 87
ClientConnected: 88
ClientDisconnected: 89
ClientClosed: 90
PublicKey: 91
PrivateKey: 92
WebrtcSdp: 93
WebrtcIce: 94
StartStream: 95
EndStream: 96
WatchStream: 97
CallToken: 98
CallInvite: 99
CallDisconnectUser: 100
CallTimeoutUser: 101
CallSetAnonymousJoining: 102
CallData: 103
EndCall: 104
Function: 105
Update: 106
CreateUser: 107
RhoUpdate: 108
UserConnected: 109
UserDisconnected: 110
IotaConnected: 111
IotaDisconnected: 112
SyncClientIotaStatus: 113
GetUserData: 114
GetIotaData: 115
IotaUserData: 116
ChangeUserData: 117
ChangeIotaData: 118
GetRegister: 119
CompleteRegisterUser: 120
CompleteRegisterIota: 121
DeleteUser: 122
DeleteIota: 123
StartRegister: 124
CompleteRegister: 125
GetApp: 126
CreateApp: 127
DeleteApp: 128
SaveAppData: 129
LoadAppData: 130
AppIdentification: 131
AppChallenge: 132
AppChallengeResponse: 133
AppIdentificationResponse: 134
LoadTxtRecord: 135
ErrorNotSet: 136
SetChatSecret: 139
GetChatSecret: 140
ChatSecretResponse: 141
ChatSecretForward: 142
MessageEditLive: 144
MessageEdit: 145
MessageReactionAdd: 146
MessageReactionRemove: 147
MessageReactionLive: 148
MessageDeleteLive: 150
DataTypes:
ErrorType: 32
ErrorProtocol: 33
AcceptedIds: 34
Uuid: 35
RegisterId: 36
Link: 37
Settings: 38
SettingsName: 39
ChatPartnerId: 40
ChatPartnerName: 41
IotaId: 42
UserId: 43
UserIds: 44
IotaIds: 45
UserState: 46
UserStates: 47
UserPings: 48
CallState: 49
ScreenShare: 50
PrivateKeyHash: 51
#Accepted: 52
AcceptedProfiles: 53
DeniedProfiles: 54
Content: 55
Messages: 56
Notifications: 57
SendTime: 58
GetTime: 59
GetVariant: 60
SharedSecretOwn: 61
SharedSecretOther: 62
SharedSecretSign: 63
SharedSecret: 64
CallId: 65
CallToken: 66
CallSecret: 67
Untill: 68
Enabled: 69
StartDate: 70
EndDate: 71
ReceiverId: 72
SenderId: 73
Signed: 75
Message: 76
MessageState: 77
LastPing: 78
PingIota: 79
PingClients: 80
Matches: 81
Omikron: 82
Offset: 83
Amount: 84
Position: 85
Name: 86
Path: 87
Codec: 88
Function: 89
Payload: 90
Result: 91
Interactables: 92
WantToWatch: 93
Watcher: 94
CreatedAt: 95
Username: 96
Display: 97
Avatar: 98
About: 99
Status: 100
PublicKey: 101
SubLevel: 102
SubEnd: 103
CommunityAddress: 104
CommunityTitle: 106
Communities: 107
RhoConnections: 108
User: 109
OnlineStatus: 110
OmikronId: 111
OmikronConnections: 112
ResetToken: 113
NewToken: 114
CallInvited: 115
CallMembers: 116
Calls: 117
Timeout: 118
HasAdmin: 119
LastMessageAt: 120
Height: 121
SentBySelf: 122
SessionId: 123
Contacts: 124
LastMessage: 125
AppIdentifier: 127
AppPrivateKey: 128
AppPublicKey: 129
AppSession: 130
AppData: 131
TauriToken: 132
Challenge: 133
EncryptedPayload: 134
SecurePayload: 135
DeviceId: 136
ClientId: 137
SecretId: 142
VersionNumber: 143
EncryptedSecret: 144
WrappingScheme: 146
UpdatedAt: 147
ChatId: 148
KemCiphertext: 149
SenderUserId: 152
RecipientUserId: 153
Recipients: 154
Edited: 155
Reactions: 156
Reaction: 157
ReplyId: 158