diff --git a/index.html b/index.html
index 2eefeee..e9014b0 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,7 @@
-
+
Cinny
diff --git a/package-lock.json b/package-lock.json
index 44c7ea6..02d6f21 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,9 @@
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",
+ "@tauri-apps/api": "2.9.1",
+ "@tauri-apps/plugin-fs": "2.4.5",
+ "@tauri-apps/plugin-notification": "2.3.3",
"@vanilla-extract/css": "1.9.3",
"@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1",
@@ -4523,6 +4526,34 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
+ "node_modules/@tauri-apps/api": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
+ "integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
+ "license": "Apache-2.0 OR MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/tauri"
+ }
+ },
+ "node_modules/@tauri-apps/plugin-fs": {
+ "version": "2.4.5",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
+ "integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.8.0"
+ }
+ },
+ "node_modules/@tauri-apps/plugin-notification": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
+ "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.8.0"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
diff --git a/package.json b/package.json
index 7d65af4..d8cccdc 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,9 @@
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",
+ "@tauri-apps/api": "2.9.1",
+ "@tauri-apps/plugin-fs": "2.4.5",
+ "@tauri-apps/plugin-notification": "2.3.3",
"@vanilla-extract/css": "1.9.3",
"@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1",
diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts
index cdbb9db..44678e5 100644
--- a/src/app/hooks/useTheme.ts
+++ b/src/app/hooks/useTheme.ts
@@ -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 = {
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index ce952bf..c866106 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -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,
});
}
diff --git a/src/app/utils/tauri.ts b/src/app/utils/tauri.ts
new file mode 100644
index 0000000..c335571
--- /dev/null
+++ b/src/app/utils/tauri.ts
@@ -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 => {
+ 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 => {
+ 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 });
+ }
+};
diff --git a/src/index.css b/src/index.css
index ca28536..3612138 100644
--- a/src/index.css
+++ b/src/index.css
@@ -41,19 +41,45 @@
html {
height: 100%;
overflow: hidden;
+ /* Enable safe area variables */
+ --safe-area-inset-top: env(safe-area-inset-top, 0px);
+ --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
+ --safe-area-inset-left: env(safe-area-inset-left, 0px);
+ --safe-area-inset-right: env(safe-area-inset-right, 0px);
}
body {
margin: 0;
padding: 0;
+ /* Safe area insets for mobile devices (notch, navigation bar) */
+ padding-top: var(--safe-area-inset-top);
+ padding-bottom: var(--safe-area-inset-bottom);
+ padding-left: var(--safe-area-inset-left);
+ padding-right: var(--safe-area-inset-right);
height: 100%;
font-family: var(--font-secondary);
font-size: 16px;
font-weight: 400;
+ /* Default to dark theme background for safe areas */
+ background-color: #1A1A1A;
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
font-variant-ligatures: no-contextual;
}
+
+/* Theme-specific body backgrounds for safe area padding */
+body.light-theme {
+ background-color: #F0F0F0;
+}
+body.silver-theme {
+ background-color: #DEDEDE;
+}
+body.dark-theme {
+ background-color: #1A1A1A;
+}
+body.butter-theme {
+ background-color: #1A1916;
+}
#root {
width: 100%;
height: 100%;
diff --git a/src/index.tsx b/src/index.tsx
index ddfd30b..e2035ef 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -12,12 +12,16 @@ import './index.css';
import { trimTrailingSlash } from './app/utils/common';
import App from './app/pages/App';
+import { applySafeAreaInsets } from './app/utils/tauri';
// import i18n (needs to be bundled ;))
import './app/i18n';
document.body.classList.add(configClass, varsClass);
+// Apply safe area insets for mobile devices
+applySafeAreaInsets();
+
// Register Service Worker
if ('serviceWorker' in navigator) {
const swUrl =