Show circular sender avatars with MessagingStyle, notify on encrypted background sync events, deep-link tray taps into the right room, and suppress Firefox open-link contextual actions.
978 lines
30 KiB
TypeScript
978 lines
30 KiB
TypeScript
/**
|
|
* Open an external URL using Tauri opener plugin on desktop/mobile, or window.open in browser
|
|
* @param url The URL to open
|
|
*/
|
|
export const openExternalUrl = async (url: string): Promise<void> => {
|
|
console.log('[openExternalUrl] called with:', url);
|
|
|
|
// Use Electron's shell.openExternal if in Electron
|
|
if (isElectron()) {
|
|
try {
|
|
const electron = (window as any).electron;
|
|
if (electron?.shell?.openExternal) {
|
|
await electron.shell.openExternal(url);
|
|
console.log('[openExternalUrl] Electron shell.openExternal succeeded');
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[openExternalUrl] Electron shell.openExternal failed:', err);
|
|
}
|
|
}
|
|
|
|
// Use Tauri for actual Tauri builds
|
|
if (isTauri() && !isElectron()) {
|
|
try {
|
|
// First, try the plugin directly (works on both desktop and mobile)
|
|
console.log('[openExternalUrl] trying Tauri opener plugin...');
|
|
const { openUrl } = await import('@tauri-apps/plugin-opener');
|
|
await openUrl(url);
|
|
console.log('[openExternalUrl] Tauri plugin succeeded');
|
|
return;
|
|
} catch (pluginErr) {
|
|
console.warn('[openExternalUrl] Tauri opener plugin failed:', pluginErr);
|
|
|
|
// Fallback: try the custom command (useful if plugin fails due to ACL)
|
|
try {
|
|
console.log('[openExternalUrl] trying Tauri invoke command fallback...');
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
await invoke('open_external_url', { url });
|
|
console.log('[openExternalUrl] Tauri command fallback succeeded');
|
|
return;
|
|
} catch (invokeErr) {
|
|
console.error('[openExternalUrl] Tauri command fallback also failed:', invokeErr);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('[openExternalUrl] falling back to window.open');
|
|
window.open(url, '_blank');
|
|
};
|
|
|
|
/**
|
|
* YouTube stream info returned by yt-dlp
|
|
*/
|
|
export interface YouTubeStreamInfo {
|
|
/** Direct video stream URL */
|
|
video_url: string;
|
|
/** Title of the video */
|
|
title: string;
|
|
}
|
|
|
|
/**
|
|
* Get direct YouTube stream URL using yt-dlp
|
|
* Requires yt-dlp to be installed on the system
|
|
* @param url The YouTube video URL
|
|
* @returns Stream info with direct URL and title
|
|
* @throws If yt-dlp is not installed or fails
|
|
*/
|
|
export const getYouTubeStream = async (url: string): Promise<YouTubeStreamInfo> => {
|
|
if (typeof window === 'undefined') {
|
|
throw new Error('YouTube streaming requires desktop app with yt-dlp installed');
|
|
}
|
|
|
|
// Check for Electron
|
|
const electron = (window as any).electron;
|
|
if (electron?.youtube?.getStream) {
|
|
const result = await electron.youtube.getStream(url);
|
|
// Electron returns { success: true, data: { video_url, title } } or { success: false, error }
|
|
if (result.success === false) {
|
|
throw new Error(result.error || 'Failed to get YouTube stream');
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
// Check for Tauri
|
|
if ((window as any).__TAURI__) {
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
return invoke<YouTubeStreamInfo>('get_youtube_stream', { url });
|
|
}
|
|
|
|
throw new Error('YouTube streaming requires desktop app with yt-dlp installed');
|
|
};
|
|
|
|
/**
|
|
* Check if yt-dlp is available for YouTube streaming
|
|
* @returns True if yt-dlp streaming is available
|
|
*/
|
|
export const isYouTubeStreamingAvailable = (): boolean => {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
// Check for Electron
|
|
const electron = (window as any).electron;
|
|
if (electron?.youtube?.getStream) {
|
|
return true;
|
|
}
|
|
|
|
// Check for Tauri
|
|
return !!(window as any).__TAURI__;
|
|
};
|
|
|
|
/**
|
|
* Tauri-specific utilities for desktop and mobile platforms
|
|
*/
|
|
|
|
/** Callback for notification tap actions */
|
|
let notificationTapCallback: ((path: string) => void) | null = null;
|
|
|
|
const ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot';
|
|
const ANDROID_NOTIFICATION_ICON_COLOR = '#FF8A00';
|
|
|
|
/**
|
|
* Bring the Tauri window to the front and focus it
|
|
*/
|
|
export const focusWindow = async (): Promise<void> => {
|
|
// Use Electron's window focus if in Electron
|
|
if (isElectron()) {
|
|
try {
|
|
const electron = (window as any).electron;
|
|
if (electron?.window?.focus) {
|
|
await electron.window.focus();
|
|
}
|
|
return;
|
|
} catch (err) {
|
|
console.warn('Failed to focus Electron window:', err);
|
|
}
|
|
}
|
|
|
|
// Use Tauri's window focus for actual Tauri builds
|
|
if (isTauri() && !isElectron()) {
|
|
try {
|
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
|
const window = getCurrentWindow();
|
|
await window.unminimize();
|
|
await window.setFocus();
|
|
} catch (err) {
|
|
console.warn('Failed to focus Tauri window:', err);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Set up listener for notification tap/click actions
|
|
* Call this once at app startup with a navigation callback
|
|
*/
|
|
export const setupNotificationTapListener = async (onTap: (path: string) => void): Promise<void> => {
|
|
notificationTapCallback = onTap;
|
|
|
|
// Use Electron's native notification handler if in Electron
|
|
if (isElectron()) {
|
|
try {
|
|
const electron = (window as any).electron;
|
|
if (electron?.notification?.onNavigate) {
|
|
electron.notification.onNavigate((data: { path?: string }) => {
|
|
if (data.path && typeof data.path === 'string' && notificationTapCallback) {
|
|
notificationTapCallback(data.path);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.warn('Failed to set up Electron notification listener:', err);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Use Tauri plugin for actual Tauri builds (not Electron)
|
|
if (isTauri() && !isElectron()) {
|
|
try {
|
|
const { onAction } = await import('@tauri-apps/plugin-notification');
|
|
|
|
await onAction(async (notification) => {
|
|
// Bring window to front first
|
|
await focusWindow();
|
|
|
|
// 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 Tauri notification tap listener:', err);
|
|
}
|
|
}
|
|
|
|
if (isCapacitorNative()) {
|
|
try {
|
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
|
await LocalNotifications.addListener('localNotificationActionPerformed', async (event: any) => {
|
|
await focusWindow();
|
|
const path = event?.notification?.extra?.path;
|
|
if (path && typeof path === 'string' && notificationTapCallback) {
|
|
notificationTapCallback(path);
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.warn('Failed to set up Capacitor notification tap listener:', err);
|
|
}
|
|
|
|
// Native NotificationManager posts (MatrixBackgroundSync.showNotification) open the
|
|
// Activity with extras — LocalNotifications never sees those taps.
|
|
try {
|
|
const {
|
|
getPendingNotificationNav,
|
|
listenForNotificationOpens,
|
|
} = await import('./backgroundSync');
|
|
|
|
const deliver = (path?: string | null, roomId?: string | null) => {
|
|
if (path && notificationTapCallback) {
|
|
notificationTapCallback(path);
|
|
return;
|
|
}
|
|
if (roomId && notificationTapCallback) {
|
|
// Encode a synthetic marker path that ClientNonUIFeatures can resolve.
|
|
notificationTapCallback(`__room__:${roomId}`);
|
|
}
|
|
};
|
|
|
|
const pending = await getPendingNotificationNav();
|
|
deliver(pending.path, pending.roomId);
|
|
|
|
await listenForNotificationOpens((event) => {
|
|
void focusWindow();
|
|
deliver(event.path, event.roomId);
|
|
});
|
|
} catch (err) {
|
|
console.warn('Failed to set up native notification open 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 inside Electron (not Tauri)
|
|
*/
|
|
export const isElectron = (): boolean =>
|
|
typeof window !== 'undefined' && 'electron' in window;
|
|
|
|
/**
|
|
* Check if we're running inside a Capacitor native app
|
|
*/
|
|
export const isCapacitorNative = (): boolean => {
|
|
if (typeof window === 'undefined') return false;
|
|
const cap = (window as any).Capacitor;
|
|
return Boolean(cap?.isNativePlatform?.() || cap?.getPlatform?.() === 'android' || cap?.getPlatform?.() === 'ios');
|
|
};
|
|
|
|
/**
|
|
* Check if we're running on a mobile platform (Android/iOS)
|
|
*/
|
|
export const isTauriMobile = (): boolean => {
|
|
if (!isTauri() || isElectron()) 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');
|
|
};
|
|
|
|
/** Returns true when running as a Capacitor app on Android. */
|
|
export const isCapacitorAndroid = (): boolean => {
|
|
if (typeof window === 'undefined') return false;
|
|
const cap = (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor;
|
|
return Boolean(cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android');
|
|
};
|
|
|
|
/**
|
|
* Android WebView: keep emoji fonts out of the body text stack and use system UI fonts.
|
|
*/
|
|
export const applyAndroidTypographyFix = (): void => {
|
|
if (!isCapacitorAndroid()) return;
|
|
|
|
const root = document.documentElement;
|
|
root.classList.add('android-capacitor');
|
|
root.style.setProperty('--font-secondary', 'system-ui, Roboto, "Noto Sans", sans-serif');
|
|
};
|
|
|
|
/**
|
|
* 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 = async () => {
|
|
// Bring Tauri window to front if in Tauri
|
|
await focusWindow();
|
|
// Also use standard window.focus for browser
|
|
window.focus();
|
|
if (!window.closed) onClick();
|
|
notification.close();
|
|
};
|
|
}
|
|
|
|
return notification;
|
|
};
|
|
|
|
/** Flag to track if notification channel has been created on Android */
|
|
let notificationChannelCreated = false;
|
|
|
|
/**
|
|
* Create notification channel for Android
|
|
* Required for Android 8.0+ to show notifications
|
|
*/
|
|
const ensureNotificationChannel = async (): Promise<void> => {
|
|
if (notificationChannelCreated || !isAndroid()) return;
|
|
|
|
try {
|
|
const { createChannel, Importance } = await import('@tauri-apps/plugin-notification');
|
|
|
|
await createChannel({
|
|
id: 'messages',
|
|
name: 'Messages',
|
|
description: 'Message notifications from Matrix',
|
|
importance: Importance.High,
|
|
vibration: true,
|
|
sound: 'default',
|
|
});
|
|
|
|
notificationChannelCreated = true;
|
|
} catch (err) {
|
|
console.warn('Failed to create notification channel:', err);
|
|
}
|
|
};
|
|
|
|
const ensureCapacitorNotificationChannel = async (): Promise<void> => {
|
|
await ensureCapacitorNotificationChannels();
|
|
};
|
|
|
|
/**
|
|
* Request notification permission across Electron, Tauri, Capacitor, and browser
|
|
*/
|
|
export const requestSystemNotificationPermission = async (): Promise<boolean> => {
|
|
if (isElectron() || (isTauri() && !isElectron())) {
|
|
if (!('Notification' in window)) return false;
|
|
const permission = await Notification.requestPermission();
|
|
return permission === 'granted';
|
|
}
|
|
|
|
if (isCapacitorNative()) {
|
|
try {
|
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
|
let perm = await LocalNotifications.checkPermissions();
|
|
if (perm.display !== 'granted') {
|
|
perm = await LocalNotifications.requestPermissions();
|
|
}
|
|
return perm.display === 'granted';
|
|
} catch (err) {
|
|
console.warn('Capacitor notification permission request failed:', err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!('Notification' in window)) return false;
|
|
const permission = await Notification.requestPermission();
|
|
return permission === 'granted';
|
|
};
|
|
|
|
export const getSystemNotificationPermissionState = async (): Promise<PermissionState> => {
|
|
if (isCapacitorNative()) {
|
|
try {
|
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
|
const perm = await LocalNotifications.checkPermissions();
|
|
return perm.display === 'granted' ? 'granted' : 'prompt';
|
|
} catch (err) {
|
|
console.warn('Failed to check Capacitor notification permission:', err);
|
|
return 'denied';
|
|
}
|
|
}
|
|
|
|
if ('Notification' in window) {
|
|
if (window.Notification.permission === 'default') {
|
|
return 'prompt';
|
|
}
|
|
return window.Notification.permission;
|
|
}
|
|
|
|
return 'denied';
|
|
};
|
|
|
|
/** Foreground sync status notification — must not collide with room message IDs. */
|
|
const ANDROID_STATUS_NOTIFICATION_ID = 1001;
|
|
|
|
/** Android notification group keys for rooms outside a space. */
|
|
export const NOTIF_GROUP_DIRECTS = 'paarrot_directs';
|
|
export const NOTIF_GROUP_HOME = 'paarrot_home';
|
|
|
|
/** Capacitor / native channel ids (under the Paarrot app). */
|
|
export const NOTIF_CHANNEL_DIRECTS = 'messages_directs';
|
|
export const NOTIF_CHANNEL_SPACES = 'messages_spaces';
|
|
export const NOTIF_CHANNEL_HOME = 'messages_home';
|
|
/** Legacy channel — kept so older installs still receive. */
|
|
export const NOTIF_CHANNEL_MESSAGES_LEGACY = 'messages';
|
|
|
|
export type NotificationGroupKind = 'direct' | 'space' | 'home';
|
|
|
|
export type NotificationGroupInfo = {
|
|
groupId: string;
|
|
groupName: string;
|
|
kind: NotificationGroupKind;
|
|
roomName?: string;
|
|
};
|
|
|
|
/**
|
|
* Stable Android notification id for a Matrix room.
|
|
* Re-using the same id replaces the previous tray entry instead of stacking.
|
|
*/
|
|
export const notificationIdForRoom = (roomId: string): number => {
|
|
let hash = 0;
|
|
for (let i = 0; i < roomId.length; i += 1) {
|
|
hash = (hash << 5) - hash + roomId.charCodeAt(i);
|
|
hash |= 0;
|
|
}
|
|
let id = Math.abs(hash) % 2147483647;
|
|
if (id === 0 || id === ANDROID_STATUS_NOTIFICATION_ID) {
|
|
id = ANDROID_STATUS_NOTIFICATION_ID + 1;
|
|
}
|
|
return id;
|
|
};
|
|
|
|
/** Stable id for a space/group summary notification. */
|
|
export const notificationIdForGroupSummary = (groupId: string): number =>
|
|
notificationIdForRoom(`summary:${groupId}`);
|
|
|
|
const channelIdForKind = (kind: NotificationGroupKind): string => {
|
|
switch (kind) {
|
|
case 'direct':
|
|
return NOTIF_CHANNEL_DIRECTS;
|
|
case 'space':
|
|
return NOTIF_CHANNEL_SPACES;
|
|
default:
|
|
return NOTIF_CHANNEL_HOME;
|
|
}
|
|
};
|
|
|
|
const ensureCapacitorNotificationChannels = async (): Promise<void> => {
|
|
if (!isAndroid()) return;
|
|
|
|
try {
|
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
|
const channels: Array<{
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
}> = [
|
|
{
|
|
id: NOTIF_CHANNEL_DIRECTS,
|
|
name: 'Direct messages',
|
|
description: 'One-to-one Matrix conversations',
|
|
},
|
|
{
|
|
id: NOTIF_CHANNEL_SPACES,
|
|
name: 'Spaces',
|
|
description: 'Messages from rooms inside Spaces',
|
|
},
|
|
{
|
|
id: NOTIF_CHANNEL_HOME,
|
|
name: 'Other rooms',
|
|
description: 'Rooms that are not in a Space',
|
|
},
|
|
{
|
|
id: NOTIF_CHANNEL_MESSAGES_LEGACY,
|
|
name: 'Messages',
|
|
description: 'Legacy message channel',
|
|
},
|
|
];
|
|
|
|
await Promise.all(
|
|
channels.map((channel) =>
|
|
LocalNotifications.createChannel({
|
|
id: channel.id,
|
|
name: channel.name,
|
|
description: channel.description,
|
|
importance: 5,
|
|
sound: 'default',
|
|
visibility: 1,
|
|
vibration: true,
|
|
lights: true,
|
|
})
|
|
)
|
|
);
|
|
notificationChannelCreated = true;
|
|
} catch (err) {
|
|
console.warn('Failed to create Capacitor notification channels:', err);
|
|
}
|
|
};
|
|
|
|
const notificationBelongsToRoom = (
|
|
notification: { id?: number; extra?: Record<string, unknown> },
|
|
roomId: string
|
|
): boolean => {
|
|
if (notification.id === notificationIdForRoom(roomId)) return true;
|
|
const extra = notification.extra;
|
|
if (!extra || typeof extra !== 'object') return false;
|
|
if (extra.roomId === roomId) return true;
|
|
const path = extra.path;
|
|
if (typeof path === 'string') {
|
|
return path.includes(encodeURIComponent(roomId)) || path.includes(roomId);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const clearCapacitorGroupSummaryIfEmpty = async (
|
|
LocalNotifications: {
|
|
getDeliveredNotifications: () => Promise<{ notifications: Array<{ id?: number; extra?: Record<string, unknown>; group?: string }> }>;
|
|
cancel: (opts: { notifications: Array<{ id: number }> }) => Promise<void>;
|
|
removeDeliveredNotifications: (opts: { notifications: unknown[] }) => Promise<void>;
|
|
},
|
|
groupId: string
|
|
): Promise<void> => {
|
|
const summaryId = notificationIdForGroupSummary(groupId);
|
|
const delivered = await LocalNotifications.getDeliveredNotifications().catch(() => null);
|
|
if (!delivered?.notifications) return;
|
|
|
|
const siblings = delivered.notifications.filter((n) => {
|
|
if (n.id === summaryId) return false;
|
|
if (n.group === groupId) return true;
|
|
return n.extra?.groupId === groupId;
|
|
});
|
|
|
|
if (siblings.length > 0) return;
|
|
|
|
await LocalNotifications.cancel({ notifications: [{ id: summaryId }] }).catch(() => undefined);
|
|
const summary = delivered.notifications.find((n) => n.id === summaryId);
|
|
if (summary) {
|
|
await LocalNotifications.removeDeliveredNotifications({
|
|
notifications: [summary],
|
|
}).catch(() => undefined);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Dismiss system notifications tied to a room (after mark-as-read / receipt).
|
|
* Clears Capacitor LocalNotifications and native MatrixSyncService tray entries.
|
|
* Also drops the space/group summary when no child notifications remain.
|
|
*/
|
|
export const clearNotificationsForRoom = async (roomId: string): Promise<void> => {
|
|
if (!roomId) return;
|
|
|
|
if (isCapacitorNative()) {
|
|
try {
|
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
|
const id = notificationIdForRoom(roomId);
|
|
|
|
const deliveredBefore = await LocalNotifications.getDeliveredNotifications().catch(() => null);
|
|
const roomNotif = deliveredBefore?.notifications?.find((n: { id?: number; extra?: Record<string, unknown> }) =>
|
|
notificationBelongsToRoom(n, roomId)
|
|
);
|
|
const groupId =
|
|
(typeof roomNotif?.extra?.groupId === 'string' && roomNotif.extra.groupId) ||
|
|
(typeof (roomNotif as { group?: string } | undefined)?.group === 'string'
|
|
? (roomNotif as { group?: string }).group
|
|
: undefined);
|
|
|
|
await LocalNotifications.cancel({ notifications: [{ id }] }).catch(() => undefined);
|
|
|
|
if (deliveredBefore?.notifications?.length) {
|
|
const matching = deliveredBefore.notifications.filter((n: { id?: number; extra?: Record<string, unknown> }) =>
|
|
notificationBelongsToRoom(n, roomId)
|
|
);
|
|
if (matching.length > 0) {
|
|
await LocalNotifications.removeDeliveredNotifications({
|
|
notifications: matching,
|
|
}).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
if (groupId) {
|
|
await clearCapacitorGroupSummaryIfEmpty(LocalNotifications, groupId);
|
|
}
|
|
} catch (err) {
|
|
console.warn('Failed to clear Capacitor notifications for room:', err);
|
|
}
|
|
|
|
try {
|
|
const { clearNativeRoomNotifications } = await import('./backgroundSync');
|
|
await clearNativeRoomNotifications(roomId);
|
|
} catch (err) {
|
|
console.warn('Failed to clear native room notifications:', err);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isTauri() && !isElectron()) {
|
|
try {
|
|
const { cancel, removeActive } = await import('@tauri-apps/plugin-notification');
|
|
const id = notificationIdForRoom(roomId);
|
|
if (typeof cancel === 'function') {
|
|
await cancel([id]).catch(() => undefined);
|
|
}
|
|
if (typeof removeActive === 'function') {
|
|
await removeActive([{ id }]).catch(() => undefined);
|
|
}
|
|
} catch (err) {
|
|
console.warn('Failed to clear Tauri notifications for room:', err);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
iconBase64?: string;
|
|
bigPictureBase64?: string;
|
|
senderName?: string;
|
|
messageText?: string;
|
|
conversationTitle?: string;
|
|
path?: string;
|
|
roomId?: string;
|
|
group?: NotificationGroupInfo;
|
|
onClick?: () => void;
|
|
}): Promise<void> => {
|
|
const {
|
|
title,
|
|
body,
|
|
icon,
|
|
iconBase64,
|
|
bigPictureBase64,
|
|
senderName,
|
|
messageText,
|
|
conversationTitle,
|
|
path,
|
|
roomId,
|
|
group,
|
|
onClick,
|
|
} = options;
|
|
const extra = {
|
|
...(path ? { path } : {}),
|
|
...(roomId ? { roomId } : {}),
|
|
...(group?.groupId ? { groupId: group.groupId, groupName: group.groupName, groupKind: group.kind } : {}),
|
|
};
|
|
const hasExtra = Object.keys(extra).length > 0;
|
|
const channelId = group ? channelIdForKind(group.kind) : NOTIF_CHANNEL_HOME;
|
|
const groupId = group?.groupId;
|
|
const groupName = group?.groupName;
|
|
|
|
// Use Electron's native notification API if in Electron
|
|
if (isElectron()) {
|
|
try {
|
|
const electron = (window as any).electron;
|
|
if (electron?.notification?.show) {
|
|
await electron.notification.show({
|
|
title,
|
|
body,
|
|
icon,
|
|
path,
|
|
});
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.warn('Electron notification failed:', err);
|
|
}
|
|
}
|
|
|
|
// Use Tauri plugin for actual Tauri builds (not Electron)
|
|
if (isTauri() && !isElectron()) {
|
|
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) {
|
|
// Ensure notification channel exists on Android
|
|
await ensureNotificationChannel();
|
|
|
|
await tauriSendNotification({
|
|
title,
|
|
body,
|
|
// Stable per-room id replaces prior tray entry instead of stacking
|
|
id: roomId ? notificationIdForRoom(roomId) : undefined,
|
|
// Use the channel on Android
|
|
channelId: isAndroid() ? channelId : undefined,
|
|
group: groupId,
|
|
icon,
|
|
// Store path/roomId in extra data for tap handling + clear-on-read
|
|
extra: hasExtra ? extra : undefined,
|
|
});
|
|
|
|
if (isAndroid() && groupId && groupName) {
|
|
await tauriSendNotification({
|
|
title: groupName,
|
|
body: 'New messages',
|
|
id: notificationIdForGroupSummary(groupId),
|
|
channelId,
|
|
group: groupId,
|
|
groupSummary: true,
|
|
extra: { groupId, groupName, isGroupSummary: true },
|
|
}).catch(() => undefined);
|
|
}
|
|
}
|
|
return;
|
|
} catch (err) {
|
|
console.warn('Tauri notification failed:', err);
|
|
}
|
|
}
|
|
|
|
if (isCapacitorNative()) {
|
|
// Prefer native NotificationManager so we can set dynamic/authenticated avatars.
|
|
// Capacitor LocalNotifications only supports drawable resource largeIcon names.
|
|
if (roomId && groupId && groupName && group) {
|
|
try {
|
|
const { showNativeNotification } = await import('./backgroundSync');
|
|
const shown = await showNativeNotification({
|
|
title,
|
|
body,
|
|
senderName: senderName ?? title,
|
|
messageText: messageText ?? body,
|
|
conversationTitle,
|
|
path,
|
|
roomId,
|
|
groupId,
|
|
groupName,
|
|
kind: group.kind,
|
|
largeIconBase64: iconBase64,
|
|
bigPictureBase64,
|
|
});
|
|
if (shown) return;
|
|
} catch (err) {
|
|
console.warn('Native showNotification failed, falling back to LocalNotifications:', err);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
|
let perm = await LocalNotifications.checkPermissions();
|
|
if (perm.display !== 'granted') {
|
|
perm = await LocalNotifications.requestPermissions();
|
|
}
|
|
|
|
if (perm.display === 'granted') {
|
|
await ensureCapacitorNotificationChannels();
|
|
|
|
// One notification per room: same id updates the existing tray entry.
|
|
const id = roomId
|
|
? notificationIdForRoom(roomId)
|
|
: Math.floor(Date.now() % 2147483647);
|
|
|
|
const roomNotification: Record<string, unknown> = {
|
|
id,
|
|
title,
|
|
body,
|
|
channelId: isAndroid() ? channelId : undefined,
|
|
smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
|
|
iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
|
|
extra: hasExtra ? extra : undefined,
|
|
threadIdentifier: groupId,
|
|
};
|
|
|
|
if (isAndroid() && groupId) {
|
|
roomNotification.group = groupId;
|
|
if (groupName) {
|
|
roomNotification.summaryText = groupName;
|
|
}
|
|
if (group?.roomName && group.roomName !== title) {
|
|
roomNotification.largeBody = body;
|
|
}
|
|
}
|
|
|
|
const notifications: Array<Record<string, unknown>> = [roomNotification];
|
|
|
|
// Nest rooms under a space / Directs / Home summary in the shade.
|
|
if (isAndroid() && groupId && groupName) {
|
|
notifications.push({
|
|
id: notificationIdForGroupSummary(groupId),
|
|
title: groupName,
|
|
body: 'New messages',
|
|
channelId,
|
|
smallIcon: ANDROID_NOTIFICATION_SMALL_ICON,
|
|
iconColor: ANDROID_NOTIFICATION_ICON_COLOR,
|
|
group: groupId,
|
|
groupSummary: true,
|
|
extra: { groupId, groupName, isGroupSummary: true },
|
|
});
|
|
}
|
|
|
|
await LocalNotifications.schedule({
|
|
notifications: notifications as any,
|
|
});
|
|
}
|
|
return;
|
|
} catch (err) {
|
|
console.warn('Capacitor notification failed:', err);
|
|
}
|
|
}
|
|
|
|
// Fallback to browser notification
|
|
sendBrowserNotification({ title, body, icon, onClick });
|
|
};
|
|
|
|
/**
|
|
* Check if we're running on Linux
|
|
*/
|
|
export const isLinux = (): boolean => {
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
return ua.includes('linux') && !ua.includes('android');
|
|
};
|
|
|
|
/**
|
|
* Read clipboard image on Linux using Tauri command with arboard/Wayland support
|
|
* Returns a data URL if an image is found, null otherwise
|
|
*/
|
|
export const readClipboardImage = async (): Promise<File | null> => {
|
|
// Use Electron's clipboard API if in Electron
|
|
if (isElectron()) {
|
|
try {
|
|
const electron = (window as any).electron;
|
|
if (electron?.clipboard?.readImage) {
|
|
const dataUrl = await electron.clipboard.readImage();
|
|
if (!dataUrl) return null;
|
|
|
|
// Convert data URL to File
|
|
const response = await fetch(dataUrl);
|
|
const blob = await response.blob();
|
|
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
|
}
|
|
} catch (err) {
|
|
console.warn('Failed to read Electron clipboard image:', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Use Tauri's invoke for actual Tauri builds on Linux
|
|
if (isTauri() && !isElectron() && isLinux()) {
|
|
try {
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
const dataUrl = await invoke<string | null>('read_clipboard_image');
|
|
|
|
if (!dataUrl) return null;
|
|
|
|
// Convert data URL to File
|
|
const response = await fetch(dataUrl);
|
|
const blob = await response.blob();
|
|
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
|
} catch (err) {
|
|
console.warn('Failed to read Tauri clipboard image:', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Background Sync API for mobile platforms
|
|
* Starts a native Rust-based Matrix sync that runs even when the app is backgrounded
|
|
*/
|
|
export interface BackgroundSyncCredentials {
|
|
homeserverUrl: string;
|
|
userId: string;
|
|
accessToken: string;
|
|
deviceId: string;
|
|
}
|
|
|
|
/**
|
|
* Start background Matrix sync on mobile
|
|
* This runs the sync in native Rust code, allowing notifications even when the app is backgrounded
|
|
*/
|
|
export const startBackgroundSync = async (credentials: BackgroundSyncCredentials): Promise<void> => {
|
|
if (!isTauriMobile()) {
|
|
console.log('[BackgroundSync] Not on mobile, skipping');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
await invoke('start_background_sync', {
|
|
homeserverUrl: credentials.homeserverUrl,
|
|
userId: credentials.userId,
|
|
accessToken: credentials.accessToken,
|
|
deviceId: credentials.deviceId,
|
|
});
|
|
console.log('[BackgroundSync] Started successfully');
|
|
} catch (err) {
|
|
console.error('[BackgroundSync] Failed to start:', err);
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Stop background Matrix sync on mobile
|
|
*/
|
|
export const stopBackgroundSync = async (): Promise<void> => {
|
|
if (!isTauriMobile()) return;
|
|
|
|
try {
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
await invoke('stop_background_sync');
|
|
console.log('[BackgroundSync] Stopped');
|
|
} catch (err) {
|
|
console.error('[BackgroundSync] Failed to stop:', err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get the current background sync state
|
|
*/
|
|
export const getBackgroundSyncState = async (): Promise<string> => {
|
|
if (!isTauriMobile()) return 'NotApplicable';
|
|
|
|
try {
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
return await invoke<string>('get_background_sync_state');
|
|
} catch (err) {
|
|
console.error('[BackgroundSync] Failed to get state:', err);
|
|
return 'Error';
|
|
}
|
|
};
|