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 =