feat: enhance Tauri support with notification handling and safe area insets

This commit is contained in:
2026-01-24 07:48:21 +11:00
parent 54f5fa95a6
commit c0af925420
8 changed files with 294 additions and 28 deletions

View File

@@ -19,7 +19,7 @@ export type Theme = {
export const LightTheme: Theme = {
id: 'light-theme',
kind: ThemeKind.Light,
classNames: [lightTheme, onLightFontWeight, 'prism-light'],
classNames: ['light-theme', lightTheme, onLightFontWeight, 'prism-light'],
};
export const SilverTheme: Theme = {

View File

@@ -14,7 +14,7 @@ import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { usePreviousValue } from '../../hooks/usePreviousValue';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getInboxInvitesPath, getInboxNotificationsPath } from '../pathUtils';
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils';
import {
getMemberDisplayName,
getNotificationType,
@@ -26,6 +26,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { isTauri, sendNotification, setupNotificationTapListener } from '../../utils/tauri';
function SystemEmojiFeature() {
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
@@ -88,17 +89,29 @@ function InviteNotifications() {
const notify = useCallback(
(count: number) => {
const noti = new window.Notification('Invitation', {
icon: LogoSVG,
badge: LogoSVG,
body: `You have ${count} new invitation request.`,
silent: true,
});
const body = `You have ${count} new invitation request.`;
if (isTauri()) {
sendNotification({
title: 'Invitation',
body,
onClick: () => {
if (!window.closed) navigate(getInboxInvitesPath());
},
});
} else {
const noti = new window.Notification('Invitation', {
icon: LogoSVG,
badge: LogoSVG,
body,
silent: true,
});
noti.onclick = () => {
if (!window.closed) navigate(getInboxInvitesPath());
noti.close();
};
noti.onclick = () => {
if (!window.closed) navigate(getInboxInvitesPath());
noti.close();
};
}
},
[navigate]
);
@@ -141,35 +154,69 @@ function MessageNotifications() {
const notificationSelected = useInboxNotificationsSelected();
const selectedRoomId = useSelectedRoom();
// Set up notification tap listener for mobile
useEffect(() => {
setupNotificationTapListener((path) => {
navigate(path);
});
}, [navigate]);
const notify = useCallback(
({
roomName,
roomAvatar,
username,
messageBody,
roomId,
eventId,
isDm,
}: {
roomName: string;
roomAvatar?: string;
username: string;
messageBody?: string;
roomId: string;
eventId: string;
isDm: boolean;
}) => {
const noti = new window.Notification(roomName, {
icon: roomAvatar,
badge: roomAvatar,
body: `New inbox notification from ${username}`,
silent: true,
});
// In DMs, room name is the username, so don't duplicate it
const body = messageBody
? (isDm ? messageBody : `${username}: ${messageBody}`)
: `New message from ${username}`;
// Build the path to navigate to on click
const roomPath = isDm
? getDirectRoomPath(roomId, eventId)
: getHomeRoomPath(roomId, eventId);
if (isTauri()) {
sendNotification({
title: roomName,
body,
path: roomPath,
onClick: () => {
if (!window.closed) navigate(roomPath);
},
});
} else {
const noti = new window.Notification(roomName, {
icon: roomAvatar,
badge: roomAvatar,
body,
silent: true,
});
noti.onclick = () => {
if (!window.closed) navigate(getInboxNotificationsPath());
noti.close();
notifRef.current = undefined;
};
noti.onclick = () => {
if (!window.closed) navigate(roomPath);
noti.close();
notifRef.current = undefined;
};
notifRef.current?.close();
notifRef.current = noti;
notifRef.current?.close();
notifRef.current = noti;
}
},
[navigate]
[mx, navigate]
);
const playSound = useCallback(() => {
@@ -212,17 +259,22 @@ function MessageNotifications() {
return;
}
if (showNotifications && notificationPermission('granted')) {
if (showNotifications && (isTauri() || notificationPermission('granted'))) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
const content = mEvent.getContent();
const messageBody = typeof content.body === 'string' ? content.body : undefined;
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
messageBody,
roomId: room.roomId,
eventId,
isDm,
});
}

150
src/app/utils/tauri.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* Tauri-specific utilities for desktop and mobile platforms
*/
/** Callback for notification tap actions */
let notificationTapCallback: ((path: string) => void) | null = null;
/**
* Set up listener for notification tap actions on mobile
* Call this once at app startup with a navigation callback
*/
export const setupNotificationTapListener = async (onTap: (path: string) => void): Promise<void> => {
if (!isTauri()) return;
notificationTapCallback = onTap;
try {
const { onAction } = await import('@tauri-apps/plugin-notification');
await onAction((notification) => {
// Get the path from extra data and navigate
const path = notification.notification?.extra?.path;
if (path && typeof path === 'string' && notificationTapCallback) {
notificationTapCallback(path);
}
});
} catch (err) {
console.warn('Failed to set up notification tap listener:', err);
}
};
/**
* Check if we're running inside a Tauri application
*/
export const isTauri = (): boolean =>
'__TAURI__' in window || '__TAURI_INTERNALS__' in window;
/**
* Check if we're running on a mobile platform (Android/iOS)
*/
export const isTauriMobile = (): boolean => {
if (!isTauri()) return false;
const ua = navigator.userAgent.toLowerCase();
return ua.includes('android') || ua.includes('iphone') || ua.includes('ipad');
};
/**
* Check if we're running on Android
*/
export const isAndroid = (): boolean => {
const ua = navigator.userAgent.toLowerCase();
return ua.includes('android');
};
/**
* Apply safe area insets for mobile devices
* On Android, CSS env() may not work, so we apply fallback padding
*/
export const applySafeAreaInsets = (): void => {
if (!isTauriMobile()) return;
const root = document.documentElement;
// Check if env() is working by testing if it returns a value
const testValue = getComputedStyle(root).getPropertyValue('--safe-area-inset-top');
const envWorking = testValue && testValue !== '0px' && testValue !== '';
if (!envWorking && isAndroid()) {
// Apply fallback padding for Android status bar and navigation bar
// Status bar is typically 24-48dp, navigation bar is typically 48dp
// We use conservative values that work on most devices
root.style.setProperty('--safe-area-inset-top', '28px');
root.style.setProperty('--safe-area-inset-bottom', '24px');
}
};
/**
* Send a browser notification
*/
const sendBrowserNotification = (options: {
title: string;
body: string;
icon?: string;
onClick?: () => void;
}): Notification | undefined => {
const { title, body, icon, onClick } = options;
if (!('Notification' in window)) return undefined;
if (Notification.permission !== 'granted') return undefined;
const notification = new Notification(title, {
body,
icon,
silent: true,
});
if (onClick) {
notification.onclick = () => {
if (!window.closed) onClick();
notification.close();
};
}
return notification;
};
/**
* Send a notification using Tauri's notification plugin
* Falls back to browser Notification API if not in Tauri
*/
export const sendNotification = async (options: {
title: string;
body: string;
icon?: string;
path?: string;
onClick?: () => void;
}): Promise<void> => {
const { title, body, icon, path, onClick } = options;
if (isTauri()) {
try {
const {
sendNotification: tauriSendNotification,
isPermissionGranted,
requestPermission,
} = await import('@tauri-apps/plugin-notification');
let permissionGranted = await isPermissionGranted();
if (!permissionGranted) {
const permission = await requestPermission();
permissionGranted = permission === 'granted';
}
if (permissionGranted) {
await tauriSendNotification({
title,
body,
// Store path in extra data for notification tap handling
extra: path ? { path } : undefined,
});
}
} catch (err) {
console.warn('Tauri notification failed:', err);
// Fallback to browser notification on error
sendBrowserNotification({ title, body, icon, onClick });
}
} else {
sendBrowserNotification({ title, body, icon, onClick });
}
};