(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

@ -10,7 +10,7 @@ import { useStorage } from "@tensamin/storage/context";
import React, { useCallback, useEffect, useState, useRef } from "react";
import { Button } from "@methanium/ui";
import { Plus, Laugh, FileVideo } from "lucide-react";
import { Plus, Laugh, FileVideo, SendHorizonal } from "lucide-react";
import { useChat, useReplyMessage } from "../context";
import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
@ -267,19 +267,27 @@ export default function InputComponent({
)}
>
<CardHeader className="relative p-0 flex flex-col">
<Input
className="w-full"
onControllerChange={setComposer}
paddingY="13px"
paddingX="13px"
placeholder="Send a message..."
value={value}
setValue={setValue}
onSubmit={handleSubmit}
invertEnterBehavior={invertEnterBehavior}
emojiFrequencies={emojiFrequencies}
onEmojiSelect={recordUse}
/>
<div className="w-full flex items-center">
<Input
className="w-full"
onControllerChange={setComposer}
paddingY="13px"
paddingX="13px"
placeholder="Send a message..."
value={value}
setValue={setValue}
onSubmit={handleSubmit}
invertEnterBehavior={invertEnterBehavior}
emojiFrequencies={emojiFrequencies}
onEmojiSelect={recordUse}
/>
<Button
onClick={() => handleSubmit(value)}
className={cn("w-9! h-9!", isMobile ? "" : "hidden")}
>
<SendHorizonal />
</Button>
</div>
<div className="w-full flex justify-between gap-1 p-1 pt-0">
<div className="flex gap-1">
<Button className="w-9 h-9! p-0" variant="ghost">

View file

@ -21,7 +21,6 @@
"mtp": "*",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tauri-plugin-app-events-api": "^0.2.0",
"zod": "^4.4.2"
},
"devDependencies": {

View file

@ -8,8 +8,8 @@ import {
useRef,
useState,
} from "react";
import { isTauri } from "@tauri-apps/api/core";
import { onResume } from "tauri-plugin-app-events-api";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { MTPClient } from "mtp";
import { type z } from "zod";
import { ConnectionState } from "mtp";
@ -92,10 +92,6 @@ type ContextType = {
const MTPContext = createContext<ContextType | undefined>(undefined);
function isTauriMobile() {
return isTauri() && /Android|iPhone|iPad|iPod/.test(navigator.userAgent);
}
function getProtocolErrorDetails(error: unknown) {
if (typeof error !== "object" || error === null || !("type" in error)) {
return null;
@ -143,7 +139,21 @@ function validateResponse<T extends keyof Schemas & string>(
} as ProtocolMessage<T>;
}
export function Provider(props: {
function useMessageHandlers() {
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const pushHandlersRef = useRef(new Set<PushHandler>());
const subscribePush = useCallback((handler: PushHandler) => {
pushHandlersRef.current.add(handler);
return () => pushHandlersRef.current.delete(handler);
}, []);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
interceptorsRef.current.add(interceptor);
return () => interceptorsRef.current.delete(interceptor);
}, []);
return { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush };
}
function BrowserProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
@ -162,8 +172,8 @@ export function Provider(props: {
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
null,
);
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const pushHandlersRef = useRef(new Set<PushHandler>());
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } =
useMessageHandlers();
const connected = readyState === ConnectionState.Connected;
@ -203,16 +213,6 @@ export function Provider(props: {
});
}, []);
const subscribePush = useCallback((handler: PushHandler) => {
pushHandlersRef.current.add(handler);
return () => pushHandlersRef.current.delete(handler);
}, []);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
interceptorsRef.current.add(interceptor);
return () => interceptorsRef.current.delete(interceptor);
}, []);
// Reconnect stuff
const resolveConnectionRef = useRef(() => {});
useEffect(() => {
@ -223,7 +223,6 @@ export function Provider(props: {
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectScheduled = false;
let disposed = false;
let resumeListenerRegistered = false;
let connectionGeneration = 0;
const clearReconnectTimer = () => {
@ -292,8 +291,6 @@ export function Provider(props: {
setIdentified(false);
setIdentifying(false);
await MTPClient.init();
const [userId, keyring] = await Promise.all([
load("user_id"),
load("mtp_keyring"),
@ -526,37 +523,13 @@ export function Provider(props: {
}
}
async function reconnectAfterResume() {
if (disposed) return;
connectionGeneration += 1;
clientRef.current?.disconnect();
clientRef.current = null;
clearReconnectTimer();
clearReconnectResetTimer();
attempts = 0;
reconnectScheduled = false;
await connect();
}
void connect();
if (!props.blockConnection && isTauriMobile()) {
resumeListenerRegistered = true;
onResume(() => {
void reconnectAfterResume();
});
}
return () => {
disposed = true;
clearReconnectTimer();
clearReconnectResetTimer();
if (resumeListenerRegistered) {
onResume();
}
clientRef.current?.disconnect();
clientRef.current = null;
setReadyState(ConnectionState.Disconnected);
@ -564,7 +537,7 @@ export function Provider(props: {
setIdentifying(false);
sonnerToast.dismiss("mtp-connection-toast");
};
}, [mtpUrl, props.blockConnection, load]);
}, [mtpUrl, props.blockConnection, load, pushHandlersRef]);
// No Iota check
useEffect(() => {
@ -626,7 +599,7 @@ export function Provider(props: {
}
return response;
},
[mtpRef],
[interceptorsRef, mtpRef],
);
return (
@ -650,6 +623,235 @@ export function Provider(props: {
);
}
type NativeSnapshot = {
generation: number;
readyState: number;
identified: boolean;
state?: unknown;
error?: string;
};
type NativeEvent =
| { kind: "state"; snapshot: NativeSnapshot }
| { kind: "message"; generation: number; message: unknown }
| {
kind: "log";
level: number;
message: string;
details?: unknown;
};
function TauriProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const [snapshot, setSnapshot] = useState<NativeSnapshot>({
generation: 0,
readyState: ConnectionState.Disconnected,
identified: false,
});
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]);
const generationRef = useRef(0);
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } =
useMessageHandlers();
const subscriptionsRef = useRef(
new Map<string, Set<(message: ProtocolMessage) => void>>(),
);
const applySnapshot = useCallback((next: NativeSnapshot) => {
if (next.generation < generationRef.current) return;
generationRef.current = next.generation;
if (next.error) {
log(0, "android", "orange", "MTP connection failed", next.error);
}
setSnapshot(next);
if (!next.identified || next.state === undefined) return;
const parsed = schemas.ClientStateSync.response.safeParse(next.state);
if (!parsed.success) {
log(0, "mtp", "red", "Invalid native MTP state", parsed.error);
return;
}
setFreshContacts(parsed.data.Contacts);
setFreshCommunities(parsed.data.Communities);
setFreshCalls(parsed.data.Calls);
}, []);
const dispatchMessage = useCallback(
(raw: unknown) => {
if (!raw || typeof raw !== "object" || !("type" in raw)) return;
const message = raw as { id?: number; type: string; data: unknown };
let validated: ProtocolMessage;
try {
validated = validateResponse(
message.type as keyof Schemas & string,
message,
);
} catch (error) {
log(1, "mtp", "red", "Failed to validate native MTP message", error);
return;
}
for (const handler of subscriptionsRef.current.get(validated.type) ??
[]) {
handler(validated);
}
if (!(PUSH_TYPES as readonly string[]).includes(validated.type)) return;
for (const handler of [...pushHandlersRef.current]) {
void Promise.resolve(handler(validated)).catch((error) => {
log(1, "mtp", "red", "Native MTP push handler failed", error, {
type: validated.type,
});
});
}
},
[pushHandlersRef],
);
useEffect(() => {
if (props.blockConnection) return;
let disposed = false;
let unlisten: UnlistenFn | undefined;
void (async () => {
unlisten = await listen<NativeEvent>("mtp://event", ({ payload }) => {
if (disposed) return;
if (payload.kind === "state") {
applySnapshot(payload.snapshot);
return;
}
if (payload.kind === "message") {
if (payload.generation === generationRef.current) {
dispatchMessage(payload.message);
}
return;
}
log(
payload.level,
"android",
"orange",
payload.message,
payload.details,
);
});
const current = await invoke<NativeSnapshot>("mtp_status");
if (!disposed) applySnapshot(current);
})().catch((error) => {
log(0, "mtp", "red", "Failed to initialize native MTP bridge", error);
});
return () => {
disposed = true;
unlisten?.();
};
}, [applySnapshot, dispatchMessage, props.blockConnection]);
useEffect(() => {
if (props.blockConnection) return;
const updateVisibility = () => {
void invoke("mtp_set_ui_visible", {
visible: document.visibilityState === "visible" && document.hasFocus(),
});
};
updateVisibility();
document.addEventListener("visibilitychange", updateVisibility);
window.addEventListener("focus", updateVisibility);
window.addEventListener("blur", updateVisibility);
return () => {
document.removeEventListener("visibilitychange", updateVisibility);
window.removeEventListener("focus", updateVisibility);
window.removeEventListener("blur", updateVisibility);
void invoke("mtp_set_ui_visible", { visible: false });
};
}, [props.blockConnection]);
const send = useCallback<BoundSendFn>(
async (type, data, options) => {
const response = await invoke<ProtocolMessage>("mtp_request", {
typeName: type,
data: data ?? {},
id: options?.id,
});
const validated = validateResponse(type, response);
for (const interceptor of interceptorsRef.current) {
void Promise.resolve(
interceptor({ type, data, response: validated as ProtocolMessage }),
).catch((error) => {
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
});
}
return validated;
},
[interceptorsRef],
);
const subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
const handlers =
subscriptionsRef.current.get(type) ??
new Set<(message: ProtocolMessage) => void>();
handlers.add(handler as (message: ProtocolMessage) => void);
subscriptionsRef.current.set(type, handlers);
return () => {
handlers.delete(handler as (message: ProtocolMessage) => void);
if (handlers.size === 0) subscriptionsRef.current.delete(type);
};
}, []);
const connected = snapshot.readyState === ConnectionState.Connected;
const contextReady = connected && snapshot.identified;
return (
<MTPContext.Provider
value={{
send,
subscribe,
subscribePush,
addInterceptor,
readyState: snapshot.readyState,
identified: snapshot.identified,
freshContacts,
freshCommunities,
freshCalls,
contextReady,
loadingDescription: connected
? "Waiting for authenticated session"
: "Establishing native transport channel",
}}
>
{props.children}
</MTPContext.Provider>
);
}
export function Provider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const [wasmReady, setWasmReady] = useState(false);
const [wasmError, setWasmError] = useState<unknown>();
useEffect(() => {
let active = true;
void MTPClient.init().then(
() => {
if (active) setWasmReady(true);
},
(error: unknown) => {
if (active) setWasmError(() => error);
},
);
return () => {
active = false;
};
}, []);
if (wasmError) throw wasmError;
if (!wasmReady) return null;
return isTauri() ? (
<TauriProvider {...props} />
) : (
<BrowserProvider {...props} />
);
}
export function useMTP(): ContextType {
const context = useContext(MTPContext);
if (!context) {

View file

@ -102,6 +102,7 @@ export default function Provider(props: { children: React.ReactNode }) {
const user = await get(data.SenderId);
if (isTauri()) {
if (!appFocused) return;
const permissionGranted =
(await isTauriNotificationPermissionGranted()) ||
(await requestTauriNotificationPermission()) === "granted";

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>
);
}

View file

@ -454,6 +454,7 @@ export interface Storage extends SettingsStorageDefaults {
mtp_keyring: string;
onboarding_done: boolean;
onboarding_started: boolean;
tauri_permissions_done: boolean;
ppandtos_done: boolean;
accepted_terms_of_service: boolean;
accepted_privacy_policy: boolean;
@ -496,6 +497,7 @@ export const storageDefaults: Storage = {
mtp_keyring: "",
onboarding_done: false,
onboarding_started: false,
tauri_permissions_done: false,
ppandtos_done: false,
accepted_terms_of_service: false,
accepted_privacy_policy: false,

View file

@ -15,6 +15,7 @@ export function log(
| "red"
| "green"
| "yellow"
| "orange"
| "purple"
| "blue"
| "cyan"
@ -32,6 +33,7 @@ export function log(
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
orange: "\x1b[38;5;208m",
purple: "\x1b[35m",
blue: "\x1b[34m",
cyan: "\x1b[36m",

View file

@ -19,6 +19,7 @@ import {
} from "@tensamin/shared/indexedDb";
import { ErrorScreen } from "@methanium/ui";
import { log } from "@tensamin/shared/log";
import { invoke, isTauri } from "@tauri-apps/api/core";
import {
decodeSecureValue,
encodeSecureValue,
@ -94,6 +95,14 @@ export default function StorageProvider(props: { children: ReactNode }) {
const generation = generations.current.get(key) ?? 0;
const request = (async () => {
try {
if (key === "mtp_keyring" && isTauri()) {
const nativeValue = await invoke<string | null>("mtp_load_keyring");
const value = (nativeValue ?? defaults[key]) as StorageSchema[K];
if ((generations.current.get(key) ?? 0) === generation) {
commit(key, value);
}
return value;
}
const desktopStorage = window.tensaminDesktop?.secureStorage;
const desktopStatus = desktopStorage?.getStatus
? await desktopStorage.getStatus()
@ -143,6 +152,10 @@ export default function StorageProvider(props: { children: ReactNode }) {
options: SaveOptions = {},
): Promise<void> => {
generations.current.set(key, (generations.current.get(key) ?? 0) + 1);
if (key === "mtp_keyring" && isTauri()) {
commit(key, value);
return;
}
const desktopStorage = window.tensaminDesktop?.secureStorage;
if (JSON.stringify(value) === JSON.stringify(defaults[key])) {
if (desktopStorage?.delete)