(feat): add mobile notifications
Some checks failed
/ build-web (push) Successful in 8m31s
/ build-desktop (linux) (push) Successful in 14m5s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled

(feat): improve mobile ui & ux
This commit is contained in:
Alois 2026-08-05 16:19:46 +02:00
commit 13fa3d8db7
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
29 changed files with 6638 additions and 6094 deletions

View file

@ -12,6 +12,8 @@
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "~2",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@methanium/ui": "*",

View file

@ -8,10 +8,12 @@ import {
import { useStorage } from "@tensamin/storage/context";
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
import { log } from "@tensamin/shared/log";
import { isTauri } from "@tauri-apps/api/core";
import type { z } from "zod";
import LegalPage from "./pages/legal";
import { onboardingSteps } from "./steps";
import TauriPermissionsPage from "./pages/tauriPermissions";
export {
useOnboardingStep,
@ -27,6 +29,7 @@ interface GateState {
acceptedTOS: boolean;
includeLegal: boolean;
includeOnboarding: boolean;
includeTauriPermissions: boolean;
}
export default function OnboardingGate({ children }: { children: ReactNode }) {
@ -72,12 +75,14 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
acceptedTOS,
onboardingDone,
onboardingStarted,
tauriPermissionsDone,
] = await Promise.all([
load("legal_docs"),
load("accepted_privacy_policy"),
load("accepted_terms_of_service"),
load("onboarding_done"),
load("onboarding_started"),
load("tauri_permissions_done"),
]);
if (!active) return;
@ -104,6 +109,10 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
acceptedTOS: currentAcceptedTOS,
includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
includeOnboarding,
includeTauriPermissions:
isTauri() &&
/Android/.test(navigator.userAgent) &&
!tauriPermissionsDone,
});
} catch (caught) {
if (!active) return;
@ -142,8 +151,11 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
save("onboarding_started", false),
]);
}
if (state?.includeTauriPermissions) {
await save("tauri_permissions_done", true);
}
setComplete(true);
}, [save, state?.includeOnboarding]);
}, [save, state?.includeOnboarding, state?.includeTauriPermissions]);
if (error && errorDescription) {
return <ErrorScreen error={error} description={errorDescription} />;
@ -174,6 +186,16 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
if (state.includeOnboarding) {
steps.push(...onboardingSteps(onboardingThemeId, setOnboardingThemeId));
}
if (state.includeTauriPermissions) {
steps.push({
id: "tauri-permissions",
title: "Enable notifications",
description:
"We need these permissions so that notifications can be independent of Google Play Services.",
defaultCanContinue: false,
content: <TauriPermissionsPage />,
});
}
if (complete || steps.length === 0) return <>{children}</>;

View file

@ -32,7 +32,7 @@ export default function LegalPage({
});
return (
<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="mx-auto flex min-h-[calc(100dvh-17rem)] w-full max-w-5xl flex-col gap-10 p-2 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

@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { Button, useOnboardingStep } from "@methanium/ui";
import { invoke } from "@tauri-apps/api/core";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { BatteryCharging, Bell } from "lucide-react";
export default function TauriPermissionsPage() {
const [notificationsGranted, setNotificationsGranted] = useState(false);
const [batteryExempt, setBatteryExempt] = useState(false);
const [notificationAttempted, setNotificationAttempted] = useState(false);
const [batteryAttempted, setBatteryAttempted] = useState(false);
useEffect(() => {
const refresh = () => {
void Promise.all([
isPermissionGranted(),
invoke<boolean>("mtp_is_ignoring_battery_optimizations"),
]).then(([notifications, battery]) => {
setNotificationsGranted(notifications);
setBatteryExempt(battery);
});
};
refresh();
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", refresh);
const interval = window.setInterval(refresh, 1_000);
return () => {
window.removeEventListener("focus", refresh);
document.removeEventListener("visibilitychange", refresh);
window.clearInterval(interval);
};
}, []);
useOnboardingStep({
canContinue:
(notificationsGranted || notificationAttempted) &&
(batteryExempt || batteryAttempted),
onContinue: async () => {
await invoke("mtp_set_enabled", { enabled: true });
},
});
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-12 py-6">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<div>
<Bell className="h-6! w-6!" />
</div>
<div>
<h3 className="font-semibold">Allow notifications</h3>
<p className="text-muted-foreground text-sm">
Allow Tensamin to notify you while it's closed.
</p>
</div>
</div>
<Button
variant={notificationsGranted ? "outline" : "default"}
disabled={notificationsGranted}
onClick={() => {
setNotificationAttempted(true);
void requestPermission().then((permission) => {
setNotificationsGranted(permission === "granted");
});
}}
>
{notificationsGranted ? "Allowed" : "Allow notifications"}
</Button>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<div>
<BatteryCharging className="h-6! w-6!" />
</div>
<div>
<h3 className="font-semibold">Allow running in the background</h3>
<p className="text-muted-foreground text-sm">
Exclude Tensamin from battery optimisation so Android does not
suspend it's connection for decryption of live messages.
</p>
</div>
</div>
<Button
variant={batteryExempt ? "outline" : "default"}
disabled={batteryExempt}
onClick={() => {
setBatteryAttempted(true);
void invoke("mtp_request_battery_exemption");
}}
>
{batteryExempt ? "Allowed" : "Open battery prompt"}
</Button>
</div>
</div>
);
}