diff --git a/capacitor.config.json b/capacitor.config.json
new file mode 100644
index 0000000..1ed63b7
--- /dev/null
+++ b/capacitor.config.json
@@ -0,0 +1,12 @@
+{
+ "appId": "com.paarrot.app",
+ "appName": "Paarrot",
+ "webDir": "dist",
+ "bundledWebRuntime": false,
+ "plugins": {
+ "LocalNotifications": {
+ "smallIcon": "ic_stat_paarrot",
+ "iconColor": "#FF8A00"
+ }
+ }
+}
diff --git a/config.json b/config.json
index 80bd7a0..57c577a 100644
--- a/config.json
+++ b/config.json
@@ -1,8 +1,6 @@
{
- "defaultHomeserver": 0,
+ "defaultHomeserver": 2,
"homeserverList": [
- "ruv.wtf",
- "exau.dev",
"converser.eu",
"envs.net",
"matrix.org",
@@ -12,6 +10,10 @@
],
"allowCustomHomeservers": true,
+ "calling": {
+ "livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt"
+ },
+
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
diff --git a/public/res/svg/notification.svg b/public/res/svg/notification.svg
new file mode 100644
index 0000000..2d22bcc
--- /dev/null
+++ b/public/res/svg/notification.svg
@@ -0,0 +1,15 @@
+
+
+
diff --git a/src/app/features/ShareRoomPicker.tsx b/src/app/features/ShareRoomPicker.tsx
new file mode 100644
index 0000000..bd10d0f
--- /dev/null
+++ b/src/app/features/ShareRoomPicker.tsx
@@ -0,0 +1,255 @@
+import { useAtomValue } from 'jotai';
+import React, { ChangeEventHandler, useCallback, useMemo, useRef, useState } from 'react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import FocusTrap from 'focus-trap-react';
+import {
+ Avatar,
+ Box,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ MenuItem,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Scroll,
+ Text,
+ config,
+} from 'folds';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
+import { allRoomsAtom } from '../state/room-list/roomList';
+import { mDirectAtom } from '../state/mDirectList';
+import { useDirects, useRooms } from '../state/hooks/roomList';
+import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
+import { RoomAvatar, RoomIcon } from '../components/room-avatar';
+import { nameInitials } from '../utils/common';
+import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
+import { factoryRoomIdByActivity } from '../utils/sort';
+import {
+ SearchItemStrGetter,
+ useAsyncSearch,
+ UseAsyncSearchOptions,
+} from '../hooks/useAsyncSearch';
+import { VirtualTile } from '../components/virtualizer';
+import { AndroidSharePayload } from '../utils/androidShare';
+import { stopPropagation } from '../utils/keyboard';
+
+const SEARCH_OPTS: UseAsyncSearchOptions = {
+ limit: 200,
+ matchOptions: {
+ contain: true,
+ },
+};
+
+type ShareRoomPickerProps = {
+ /** The pending share payload to display a preview of. */
+ share: AndroidSharePayload;
+ /** Called when the user picks a room. */
+ onPick: (roomId: string) => void;
+ /** Called when the user dismisses without picking. */
+ onDismiss: () => void;
+};
+
+/**
+ * Full-screen modal that lets the user pick a room to send
+ * an incoming Android share intent into.
+ */
+export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const scrollRef = useRef(null);
+
+ const mDirects = useAtomValue(mDirectAtom);
+ const allRoomsSet = useAllJoinedRoomsSet();
+ const getRoom = useGetRoom(allRoomsSet);
+ const rooms = useRooms(mx, allRoomsAtom, mDirects);
+ const directs = useDirects(mx, allRoomsAtom, mDirects);
+
+ const allItems = useMemo(
+ () => [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)),
+ [mx, rooms, directs]
+ );
+
+ const getRoomNameStr: SearchItemStrGetter = useCallback(
+ (rId) => getRoom(rId)?.name ?? rId,
+ [getRoom]
+ );
+
+ const [searchResult, searchRoom, resetSearch] = useAsyncSearch(
+ allItems,
+ getRoomNameStr,
+ SEARCH_OPTS
+ );
+
+ const items = searchResult ? searchResult.items : allItems;
+
+ const virtualizer = useVirtualizer({
+ count: items.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 48,
+ overscan: 8,
+ });
+ const vItems = virtualizer.getVirtualItems();
+
+ const handleSearchChange: ChangeEventHandler = (evt) => {
+ const value = evt.currentTarget.value.trim();
+ if (!value) {
+ resetSearch();
+ return;
+ }
+ searchRoom(value);
+ };
+
+ const handleRoomClick: React.MouseEventHandler = (evt) => {
+ const roomId = evt.currentTarget.getAttribute('data-room-id');
+ if (roomId) {
+ onPick(roomId);
+ }
+ };
+
+ const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
+ const fileCount = share.files.length;
+
+ return (
+ }>
+
+
+
+
+
+
+ Share to Room
+ {(previewText || fileCount > 0) && (
+
+ {previewText
+ ? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
+ : `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
+
+ )}
+
+
+
+
+
+
+
+
+
+ }
+ placeholder="Search rooms…"
+ size="400"
+ variant="Background"
+ outlined
+ autoFocus
+ />
+
+
+
+
+ {vItems.length === 0 && (
+
+
+ {searchResult ? 'No Match Found' : 'No Rooms'}
+
+
+ {searchResult
+ ? `No rooms found for "${searchResult.query}".`
+ : 'You have no rooms to share into.'}
+
+
+ )}
+
+ {vItems.map((vItem) => {
+ const roomId = items[vItem.index];
+ const room = getRoom(roomId);
+ if (!room) return null;
+ const isDm = mDirects.has(roomId);
+
+ const avatarSrc = isDm
+ ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
+ : getRoomAvatarUrl(mx, room, 96, useAuthentication);
+
+ return (
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx
index b002575..4b31548 100644
--- a/src/app/features/settings/notifications/SystemNotification.tsx
+++ b/src/app/features/settings/notifications/SystemNotification.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
@@ -10,6 +10,13 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
+import {
+ isBackgroundSyncSupported,
+ getBackgroundSyncStatus,
+ requestResetPushRegistration,
+ triggerBackgroundSyncPing,
+} from '../../../utils/backgroundSync';
function EmailNotification() {
const mx = useMatrixClient();
@@ -86,14 +93,15 @@ function EmailNotification() {
export function SystemNotification() {
const notifPermission = usePermissionState('notifications', getNotificationState());
+ const capacitorNative = isCapacitorNative();
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
settingsAtom,
'isNotificationSounds'
);
- const requestNotificationPermission = () => {
- window.Notification.requestPermission();
+ const requestNotificationPermission = async () => {
+ await requestSystemNotificationPermission();
};
return (
@@ -125,7 +133,7 @@ export function SystemNotification() {
) : (
@@ -145,6 +153,7 @@ export function SystemNotification() {
after={}
/>
+ {isBackgroundSyncSupported() && }
);
}
+
+type PushStatus = {
+ registered: boolean;
+ distributor: string;
+ endpoint: string;
+};
+
+/** Android-only section showing UnifiedPush registration status and controls. */
+function AndroidPushNotifications() {
+ const [status, setStatus] = useState(undefined);
+ const [loading, setLoading] = useState(true);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ const s = await getBackgroundSyncStatus();
+ setStatus(
+ s
+ ? {
+ registered: s.registered,
+ distributor: s.distributor || '',
+ endpoint: s.endpoint || '',
+ }
+ : undefined
+ );
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const [resetState, reset] = useAsyncCallback(
+ useCallback(async () => {
+ await requestResetPushRegistration();
+ await refresh();
+ }, [refresh])
+ );
+
+ const [pingState, ping] = useAsyncCallback(
+ useCallback(async () => {
+ await triggerBackgroundSyncPing('manual-test');
+ }, [])
+ );
+
+ const isBusy =
+ loading ||
+ resetState.status === AsyncStatus.Loading ||
+ pingState.status === AsyncStatus.Loading;
+
+ return (
+
+ Android Push (UnifiedPush)
+
+
+ Failed to read push status.
+
+ ) : status.registered ? (
+ <>
+
+ {`Distributor: ${status.distributor || 'unknown'}`}
+
+ >
+ ) : (
+
+ Not registered. Tap "Reset" to choose a distributor app.
+
+ )
+ }
+ after={loading ? : undefined}
+ />
+ void reset()}
+ >
+ {resetState.status === AsyncStatus.Loading ? (
+
+ ) : (
+ Reset
+ )}
+
+ }
+ />
+ void ping()}
+ >
+ {pingState.status === AsyncStatus.Loading ? (
+
+ ) : (
+ Send Test
+ )}
+
+ }
+ />
+
+
+ );
+}
diff --git a/src/app/hooks/useAuthenticatedMediaUrl.ts b/src/app/hooks/useAuthenticatedMediaUrl.ts
index 97dd085..0570d00 100644
--- a/src/app/hooks/useAuthenticatedMediaUrl.ts
+++ b/src/app/hooks/useAuthenticatedMediaUrl.ts
@@ -29,7 +29,9 @@ export const useAuthenticatedMediaUrl = (
// Check if this is an authenticated media URL
const isAuthenticatedMediaUrl =
src.includes('/_matrix/client/v1/media/download') ||
- src.includes('/_matrix/client/v1/media/thumbnail');
+ src.includes('/_matrix/client/v1/media/thumbnail') ||
+ (src.includes('/_matrix/media/') &&
+ (src.includes('/download/') || src.includes('/thumbnail/')));
if (!isAuthenticatedMediaUrl) {
setBlobUrl(src);
@@ -99,7 +101,9 @@ export const useAuthenticatedMediaFetch = () => {
async (src: string): Promise => {
const isAuthenticatedMediaUrl =
src.includes('/_matrix/client/v1/media/download') ||
- src.includes('/_matrix/client/v1/media/thumbnail');
+ src.includes('/_matrix/client/v1/media/thumbnail') ||
+ (src.includes('/_matrix/media/') &&
+ (src.includes('/download/') || src.includes('/thumbnail/')));
if (!isAuthenticatedMediaUrl) {
return src;
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 5973009..8f6072f 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -1,5 +1,6 @@
import { useAtomValue } from 'jotai';
-import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
+import { useAtom, useSetAtom } from 'jotai';
+import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
@@ -28,8 +29,35 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
-import { isTauri, isElectron, sendNotification, setupNotificationTapListener } from '../../utils/tauri';
+import { plainToEditorInput, toPlainText } from '../../components/editor';
+import { ShareRoomPicker } from '../../features/ShareRoomPicker';
+import {
+ isTauri,
+ isElectron,
+ isCapacitorNative,
+ sendNotification,
+ setupNotificationTapListener,
+} from '../../utils/tauri';
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
+import {
+ startBackgroundSync,
+ stopBackgroundSync,
+ setAppForegroundState,
+} from '../../utils/backgroundSync';
+import {
+ TUploadItem,
+ roomIdToMsgDraftAtomFamily,
+ roomIdToUploadItemsAtomFamily,
+} from '../../state/room/roomInputDrafts';
+import { encryptFile } from '../../utils/matrix';
+import {
+ AndroidSharePayload,
+ clearPendingAndroidShare,
+ getPendingAndroidShare,
+ isAndroidShareSupported,
+ listenForAndroidShares,
+ materializeSharedFile,
+} from '../../utils/androidShare';
/**
* Applies the selected emoji style font to the document.
@@ -118,7 +146,7 @@ function InviteNotifications() {
});
}
- if (isTauri() && !isElectron()) {
+ if ((isTauri() && !isElectron()) || isCapacitorNative()) {
sendNotification({
title: 'Invitation',
body,
@@ -257,7 +285,7 @@ function MessageNotifications() {
console.warn('[Notifications] flashFrame not available, isElectron:', isElectron());
}
- if (isTauri() && !isElectron()) {
+ if ((isTauri() && !isElectron()) || isCapacitorNative()) {
const roomPath = isDm
? getDirectRoomPath(roomId, eventId)
: getHomeRoomPath(roomId, eventId);
@@ -350,7 +378,10 @@ function MessageNotifications() {
return;
}
- if (showNotifications && ((isTauri() && !isElectron()) || notificationPermission('granted'))) {
+ if (
+ showNotifications &&
+ ((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
+ ) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
const content = mEvent.getContent();
@@ -404,6 +435,36 @@ function MessageNotifications() {
return null;
}
+/**
+ * Configures native Android push-ping background sync on login, keeps it
+ * informed of foreground state so it doesn't double-fire notifications,
+ * and clears it cleanly on unmount (logout).
+ * Only active on Android Capacitor builds.
+ */
+function BackgroundSyncSetup() {
+ const mx = useMatrixClient();
+ // Try to make a simple fetch to verify component is rendering
+ try {
+ fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
+ } catch {}
+
+ useEffect(() => {
+ console.log('BackgroundSyncSetup: Starting background sync for', mx.getUserId());
+ startBackgroundSync(mx);
+
+ const onVisibility = () => setAppForegroundState(!document.hidden);
+ document.addEventListener('visibilitychange', onVisibility);
+ setAppForegroundState(!document.hidden);
+
+ return () => {
+ document.removeEventListener('visibilitychange', onVisibility);
+ stopBackgroundSync();
+ };
+ }, [mx]);
+
+ return null;
+}
+
/**
* Initializes the Paarrot API for Electron integration
* Registers the navigate function and sets up IPC handlers
@@ -454,6 +515,135 @@ function TaskbarFlashStopper() {
return null;
}
+function AndroidShareIntentHandler() {
+ const mx = useMatrixClient();
+ const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
+ const [pendingShare, setPendingShare] = useState(null);
+ const [pickedRoomId, setPickedRoomId] = useState(null);
+ const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(pickedRoomId ?? '__android_share__'));
+ const setUploadItems = useSetAtom(roomIdToUploadItemsAtomFamily(pickedRoomId ?? '__android_share__'));
+
+ const applyPendingShare = useCallback(
+ async (share: AndroidSharePayload, roomId: string) => {
+ const room = mx.getRoom(roomId);
+ if (!room) return false;
+
+ const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
+ (value): value is string => !!value
+ );
+ if (nextParts.length > 0) {
+ const existingText = toPlainText(msgDraft, isMarkdown).trim();
+ const nextText = existingText ? `${existingText}\n${nextParts.join('\n')}` : nextParts.join('\n');
+ setMsgDraft(plainToEditorInput(nextText, isMarkdown));
+ }
+
+ if (share.files.length > 0) {
+ const uploadItems: TUploadItem[] = [];
+ for (const sharedFile of share.files) {
+ const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
+ if (room.hasEncryptionStateEvent()) {
+ const encrypted = await encryptFile(originalFile);
+ uploadItems.push({
+ file: encrypted.file,
+ originalFile,
+ metadata: { markedAsSpoiler: false },
+ encInfo: encrypted.encInfo,
+ });
+ continue;
+ }
+
+ uploadItems.push({
+ file: originalFile,
+ originalFile,
+ metadata: { markedAsSpoiler: false },
+ encInfo: undefined,
+ });
+ }
+
+ setUploadItems({
+ type: 'PUT',
+ item: uploadItems,
+ });
+ }
+
+ await clearPendingAndroidShare();
+ return true;
+ },
+ [isMarkdown, msgDraft, mx, setMsgDraft, setUploadItems]
+ );
+
+ const handlePickRoom = useCallback(
+ (roomId: string) => {
+ setPickedRoomId(roomId);
+ if (!pendingShare) return;
+
+ applyPendingShare(pendingShare, roomId)
+ .then((applied) => {
+ if (applied) {
+ setPendingShare(null);
+ setPickedRoomId(null);
+ }
+ })
+ .catch((err) => {
+ console.error('[AndroidShare] Failed to apply share after pick:', err);
+ setPendingShare(null);
+ setPickedRoomId(null);
+ });
+ },
+ [applyPendingShare, pendingShare]
+ );
+
+ const handleDismiss = useCallback(() => {
+ clearPendingAndroidShare().catch(() => {});
+ setPendingShare(null);
+ setPickedRoomId(null);
+ }, []);
+
+ useEffect(() => {
+ if (!isAndroidShareSupported()) return;
+
+ let mounted = true;
+ let listenerHandle: { remove: () => Promise } | undefined;
+
+ getPendingAndroidShare()
+ .then((share) => {
+ if (mounted && share) {
+ setPendingShare(share);
+ }
+ })
+ .catch((err) => {
+ console.error('[AndroidShare] Failed to get pending share:', err);
+ });
+
+ listenForAndroidShares((share) => {
+ if (mounted) {
+ setPendingShare(share);
+ }
+ })
+ .then((handle) => {
+ listenerHandle = handle;
+ })
+ .catch((err) => {
+ console.error('[AndroidShare] Failed to listen for shares:', err);
+ });
+
+ return () => {
+ mounted = false;
+ void listenerHandle?.remove();
+ };
+ }, []);
+
+ if (!pendingShare) return null;
+
+ return (
+
+ );
+}
+
type ClientNonUIFeaturesProps = {
children: ReactNode;
};
@@ -466,8 +656,10 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
+
+
{children}
>
);
diff --git a/src/app/utils/androidShare.ts b/src/app/utils/androidShare.ts
new file mode 100644
index 0000000..445cc96
--- /dev/null
+++ b/src/app/utils/androidShare.ts
@@ -0,0 +1,73 @@
+import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
+
+/** Metadata for a file received from an Android share intent. */
+export type AndroidSharedFile = {
+ path: string;
+ name: string;
+ mimeType: string;
+ size: number;
+};
+
+/** Payload persisted by the native Android share handler. */
+export type AndroidSharePayload = {
+ text?: string;
+ subject?: string;
+ files: AndroidSharedFile[];
+ receivedAt: number;
+};
+
+interface AndroidShareHandlerPlugin {
+ /** Returns the last pending share payload, if one exists. */
+ getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
+ /** Clears the last pending share payload after it has been consumed. */
+ clearPendingShare(): Promise;
+ addListener(
+ eventName: 'shareReceived',
+ listenerFunc: (payload: AndroidSharePayload) => void
+ ): Promise;
+}
+
+const AndroidShareHandler = registerPlugin('AndroidShareHandler');
+
+/** Returns true when the Android share bridge is available. */
+export const isAndroidShareSupported = (): boolean =>
+ Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
+
+/** Fetches the current pending native share payload. */
+export const getPendingAndroidShare = async (): Promise => {
+ if (!isAndroidShareSupported()) return null;
+ const result = await AndroidShareHandler.getPendingShare();
+ return result.share;
+};
+
+/** Clears the native pending share payload. */
+export const clearPendingAndroidShare = async (): Promise => {
+ if (!isAndroidShareSupported()) return;
+ await AndroidShareHandler.clearPendingShare();
+};
+
+/** Subscribes to new incoming native share payloads. */
+export const listenForAndroidShares = async (
+ listener: (payload: AndroidSharePayload) => void
+): Promise => {
+ if (!isAndroidShareSupported()) return undefined;
+ return AndroidShareHandler.addListener('shareReceived', listener);
+};
+
+/** Converts a cached native shared file into a browser File for upload. */
+export const materializeSharedFile = async (
+ sharedFile: AndroidSharedFile,
+ receivedAt: number
+): Promise => {
+ const fileUrl = Capacitor.convertFileSrc(sharedFile.path);
+ const response = await fetch(fileUrl);
+ if (!response.ok) {
+ throw new Error(`Failed to read shared file: ${sharedFile.name}`);
+ }
+
+ const blob = await response.blob();
+ return new File([blob], sharedFile.name, {
+ type: sharedFile.mimeType || blob.type,
+ lastModified: receivedAt,
+ });
+};
\ No newline at end of file
diff --git a/src/app/utils/backgroundSync.ts b/src/app/utils/backgroundSync.ts
new file mode 100644
index 0000000..9f31676
--- /dev/null
+++ b/src/app/utils/backgroundSync.ts
@@ -0,0 +1,518 @@
+import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
+import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
+
+type UnifiedPushStatus = {
+ running: boolean;
+ endpoint: string;
+ instance: string;
+ registered: boolean;
+ distributor: string;
+ distributors: string[] | string;
+};
+
+type UnifiedPushEndpointEvent = {
+ endpoint: string;
+ previousEndpoint: string;
+ instance: string;
+};
+
+type UnifiedPushUnregisteredEvent = {
+ previousEndpoint: string;
+ instance: string;
+};
+
+type UnifiedPushRegistrationFailedEvent = {
+ reason: string;
+ instance: string;
+};
+
+interface MatrixBackgroundSyncPlugin {
+ /** Persist credentials and request UnifiedPush registration. */
+ start(options: {
+ homeserverUrl: string;
+ accessToken: string;
+ userId: string;
+ deviceId: string;
+ }): Promise;
+ /** Trigger a one-shot fetch as if a push ping arrived. */
+ triggerPing(options: { reason?: string }): Promise;
+ /** Re-open distributor setup flow and retry registration. */
+ requestDistributorSetup(): Promise<{ success: boolean }>;
+ /** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */
+ stop(): Promise;
+ /**
+ * Notify the service whether the app UI is visible.
+ * When foreground is true, the service suppresses native notifications
+ * because the JS layer handles them via LocalNotifications.
+ */
+ setAppForeground(options: { foreground: boolean }): Promise;
+ /** Returns fetch state and current UnifiedPush registration details. */
+ getStatus(): Promise;
+ addListener(
+ eventName: 'unifiedPushNewEndpoint',
+ listenerFunc: (event: UnifiedPushEndpointEvent) => void
+ ): Promise;
+ addListener(
+ eventName: 'unifiedPushUnregistered',
+ listenerFunc: (event: UnifiedPushUnregisteredEvent) => void
+ ): Promise;
+ addListener(
+ eventName: 'unifiedPushRegistrationFailed',
+ listenerFunc: (event: UnifiedPushRegistrationFailedEvent) => void
+ ): Promise;
+}
+
+type StoredPusherState = {
+ endpoint: string;
+ appId: string;
+};
+
+const MatrixBackgroundSync = registerPlugin('MatrixBackgroundSync');
+const DEFAULT_UNIFIED_PUSH_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify';
+const PUSHER_APP_ID_BASE = 'com.paarrot.app.android';
+const PUSHER_STORAGE_PREFIX = 'paarrot.unifiedpush';
+
+/** Returns true when the current platform is Android Capacitor. */
+export const isBackgroundSyncSupported = (): boolean =>
+ Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
+
+const getStoredPusherKey = (userId: string | null, deviceId: string | null): string =>
+ `${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`;
+
+const buildPusherAppId = (deviceId: string | null): string => {
+ const raw = deviceId ? `${PUSHER_APP_ID_BASE}.${deviceId}` : PUSHER_APP_ID_BASE;
+ return raw.length > 64 ? raw.slice(0, 64) : raw;
+};
+
+const loadStoredPusherState = (
+ userId: string | null,
+ deviceId: string | null
+): StoredPusherState | undefined => {
+ if (typeof window === 'undefined' || !window.localStorage) return undefined;
+
+ const raw = window.localStorage.getItem(getStoredPusherKey(userId, deviceId));
+ if (!raw) return undefined;
+
+ try {
+ return JSON.parse(raw) as StoredPusherState;
+ } catch {
+ return undefined;
+ }
+};
+
+const saveStoredPusherState = (
+ userId: string | null,
+ deviceId: string | null,
+ state: StoredPusherState
+): void => {
+ if (typeof window === 'undefined' || !window.localStorage) return;
+ window.localStorage.setItem(getStoredPusherKey(userId, deviceId), JSON.stringify(state));
+};
+
+const clearStoredPusherState = (userId: string | null, deviceId: string | null): void => {
+ if (typeof window === 'undefined' || !window.localStorage) return;
+ window.localStorage.removeItem(getStoredPusherKey(userId, deviceId));
+};
+
+const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[] => {
+ if (Array.isArray(raw)) return raw;
+ if (typeof raw !== 'string') return [];
+
+ const trimmed = raw.trim();
+ if (!trimmed || trimmed === '[]') return [];
+
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
+ return trimmed
+ .slice(1, -1)
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+ }
+
+ return [trimmed];
+};
+
+const resolveUnifiedPushGateway = async (endpoint: string): Promise => {
+ try {
+ const discoveryUrl = new URL(endpoint);
+ discoveryUrl.pathname = '/_matrix/push/v1/notify';
+ discoveryUrl.search = '';
+
+ const response = await fetch(discoveryUrl.toString(), { method: 'GET' });
+ if (!response.ok) return DEFAULT_UNIFIED_PUSH_GATEWAY;
+
+ const body = (await response.json()) as {
+ gateway?: string;
+ unifiedpush?: { gateway?: string };
+ };
+
+ if (body.gateway === 'matrix' || body.unifiedpush?.gateway === 'matrix') {
+ return discoveryUrl.toString();
+ }
+ } catch (err) {
+ console.warn('[BackgroundSync] UnifiedPush gateway discovery failed:', err);
+ }
+
+ return DEFAULT_UNIFIED_PUSH_GATEWAY;
+};
+
+class AndroidUnifiedPushManager {
+ private client: MatrixClient | undefined;
+
+ private listenerHandles: PluginListenerHandle[] = [];
+
+ private listenersReady = false;
+
+ private distributorSetupAttempted = false;
+
+ private distributorPromptShown = false;
+
+ /** Start native UnifiedPush registration and synchronize the Matrix pusher. */
+ async start(mx: MatrixClient): Promise {
+ console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform());
+
+ if (!isBackgroundSyncSupported()) {
+ console.log('[BackgroundSync] Background sync not supported on this platform');
+ return;
+ }
+
+ const homeserverUrl = mx.getHomeserverUrl();
+ const accessToken = mx.getAccessToken();
+ const userId = mx.getUserId();
+ const deviceId = mx.getDeviceId();
+
+ console.log('[BackgroundSync] Credentials check:', { userId, deviceId, hasToken: !!accessToken, hasUrl: !!homeserverUrl });
+
+ if (!homeserverUrl || !accessToken || !userId) {
+ console.warn('[BackgroundSync] Missing credentials, not starting');
+ return;
+ }
+
+ this.client = mx;
+ this.distributorSetupAttempted = false;
+ this.distributorPromptShown = false;
+ await this.ensureListeners();
+
+ try {
+ console.log('[BackgroundSync] Calling plugin.start()...');
+ await MatrixBackgroundSync.start({
+ homeserverUrl,
+ accessToken,
+ userId,
+ deviceId: deviceId ?? '',
+ });
+ console.log('[BackgroundSync] plugin.start() completed');
+ } catch (err) {
+ console.error('[BackgroundSync] plugin.start() failed:', err);
+ throw err;
+ }
+
+ await this.syncExistingEndpoint();
+ await this.ensureDistributorPromptFromStatus();
+ console.log('[BackgroundSync] UnifiedPush registration requested');
+ }
+
+ /** Stop native UnifiedPush integration and remove the Matrix pusher for this device. */
+ async stop(): Promise {
+ if (!isBackgroundSyncSupported()) return;
+
+ const client = this.client;
+ if (client) {
+ const deviceId = client.getDeviceId();
+ const userId = client.getUserId();
+ const status = await this.safeGetStatus();
+ const stored = loadStoredPusherState(userId, deviceId);
+ const endpoint = status?.endpoint || stored?.endpoint;
+ const appId = stored?.appId ?? buildPusherAppId(deviceId);
+
+ if (endpoint) {
+ await this.removePusher(client, endpoint, appId);
+ }
+ clearStoredPusherState(userId, deviceId);
+ }
+
+ await MatrixBackgroundSync.stop();
+ await this.disposeListeners();
+ this.client = undefined;
+ console.log('[BackgroundSync] UnifiedPush stopped');
+ }
+
+ /** Update the Matrix pusher when a new native endpoint is published. */
+ private async handleNewEndpoint(event: UnifiedPushEndpointEvent): Promise {
+ const client = this.client;
+ if (!client) return;
+
+ if (event.previousEndpoint) {
+ await this.removePusher(client, event.previousEndpoint);
+ }
+
+ await this.upsertPusher(client, event.endpoint);
+ }
+
+ /** Remove the Matrix pusher when UnifiedPush unregisters this instance. */
+ private async handleUnregistered(event: UnifiedPushUnregisteredEvent): Promise {
+ const client = this.client;
+ if (!client) return;
+
+ const stored = loadStoredPusherState(client.getUserId(), client.getDeviceId());
+ const endpoint = event.previousEndpoint || stored?.endpoint;
+ const appId = stored?.appId ?? buildPusherAppId(client.getDeviceId());
+
+ if (endpoint) {
+ await this.removePusher(client, endpoint, appId);
+ }
+
+ clearStoredPusherState(client.getUserId(), client.getDeviceId());
+ }
+
+ /** Log native registration failures so the missing distributor path is visible. */
+ private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void {
+ console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason);
+
+ if (event.reason !== 'ACTION_REQUIRED' || this.distributorSetupAttempted) {
+ return;
+ }
+
+ this.distributorSetupAttempted = true;
+ void this.tryRequestDistributorSetup();
+ }
+
+ /** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
+ private async tryRequestDistributorSetup(): Promise {
+ try {
+ const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
+ console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
+ const status = await this.safeGetStatus();
+ console.warn('[BackgroundSync] UnifiedPush status after setup attempt:', status);
+ const distributors = normalizeDistributors(status?.distributors ?? []);
+ if (distributors.length === 0) {
+ console.error(
+ '[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
+ );
+ this.showNoDistributorPrompt();
+ }
+ } catch (err) {
+ console.warn('[BackgroundSync] requestDistributorSetup failed:', err);
+ }
+ }
+
+ /** Fallback check so users still get prompted even when ACTION_REQUIRED event is missed. */
+ private async ensureDistributorPromptFromStatus(): Promise {
+ const status = await this.safeGetStatus();
+ if (!status) return;
+
+ const distributors = normalizeDistributors(status.distributors);
+ if (!status.registered && distributors.length === 0) {
+ console.error(
+ '[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
+ );
+ this.showNoDistributorPrompt();
+ }
+ }
+
+ /** Show a one-time actionable prompt when no distributor app is installed. */
+ private showNoDistributorPrompt(): void {
+ if (this.distributorPromptShown) return;
+ this.distributorPromptShown = true;
+
+ const message =
+ 'Android background notifications need a UnifiedPush distributor app. Install one (for example ntfy), then reopen Paarrot.';
+ const docsUrl = 'https://unifiedpush.org/users/distributors/';
+
+ if (typeof window === 'undefined') return;
+
+ try {
+ const openDocs = window.confirm(`${message}\n\nOpen distributor list now?`);
+ if (openDocs) {
+ window.open(docsUrl, '_blank', 'noopener,noreferrer');
+ }
+ } catch {
+ window.alert(message);
+ }
+ }
+
+ /** Install plugin listeners once for the active Matrix client. */
+ private async ensureListeners(): Promise {
+ if (this.listenersReady) return;
+
+ this.listenerHandles = [
+ await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
+ void this.handleNewEndpoint(event).catch((err) => {
+ console.error('[BackgroundSync] handleNewEndpoint failed:', err);
+ });
+ }),
+ await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => {
+ void this.handleUnregistered(event);
+ }),
+ await MatrixBackgroundSync.addListener('unifiedPushRegistrationFailed', (event) => {
+ this.handleRegistrationFailed(event);
+ }),
+ ];
+ this.listenersReady = true;
+ }
+
+ /** Remove all plugin listeners when the manager stops. */
+ private async disposeListeners(): Promise {
+ await Promise.all(this.listenerHandles.map((handle) => handle.remove()));
+ this.listenerHandles = [];
+ this.listenersReady = false;
+ }
+
+ /** Reconcile an already-persisted native endpoint after app startup. */
+ private async syncExistingEndpoint(): Promise {
+ const client = this.client;
+ if (!client) return;
+
+ const status = await this.safeGetStatus();
+ console.log('[BackgroundSync] Native status before pusher sync:', status);
+ if (status?.registered && status.endpoint) {
+ await this.upsertPusher(client, status.endpoint);
+ }
+ }
+
+ /** Create or refresh the Matrix HTTP pusher for the current device. */
+ private async upsertPusher(mx: MatrixClient, endpoint: string): Promise {
+ const deviceId = mx.getDeviceId();
+ const userId = mx.getUserId();
+ const appId = buildPusherAppId(deviceId);
+ const gatewayUrl = await resolveUnifiedPushGateway(endpoint);
+
+ console.log('[BackgroundSync] Upserting Matrix pusher', {
+ appId,
+ endpoint,
+ gatewayUrl,
+ deviceId,
+ userId,
+ });
+
+ try {
+ await mx.setPusher({
+ kind: 'http',
+ app_id: appId,
+ pushkey: endpoint,
+ app_display_name: 'Paarrot',
+ device_display_name: deviceId ?? 'Android',
+ lang: 'en',
+ data: {
+ url: gatewayUrl,
+ format: 'event_id_only',
+ },
+ append: false,
+ device_id: deviceId ?? undefined,
+ } as unknown as IPusherRequest);
+
+ const pushers = (await mx.getPushers())?.pushers ?? [];
+ const thisPusher = pushers.find((p) => p.pushkey === endpoint && p.app_id === appId);
+ console.log('[BackgroundSync] Matrix pusher upserted successfully', {
+ totalPushers: pushers.length,
+ foundThisPusher: Boolean(thisPusher),
+ });
+ } catch (err) {
+ console.error('[BackgroundSync] Matrix pusher upsert failed:', err);
+ throw err;
+ }
+
+ saveStoredPusherState(userId, deviceId, { endpoint, appId });
+ }
+
+ /** Remove a previously-registered Matrix HTTP pusher for this device. */
+ private async removePusher(
+ mx: MatrixClient,
+ endpoint: string,
+ appId = buildPusherAppId(mx.getDeviceId())
+ ): Promise {
+ try {
+ await mx.setPusher({
+ pushkey: endpoint,
+ app_id: appId,
+ kind: null,
+ } as unknown as IPusherRequest);
+ } catch (err) {
+ console.warn('[BackgroundSync] Failed to remove UnifiedPush pusher:', err);
+ }
+ }
+
+ /** Read native registration state without failing the caller. */
+ private async safeGetStatus(): Promise {
+ try {
+ return await MatrixBackgroundSync.getStatus();
+ } catch (err) {
+ console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err);
+ return undefined;
+ }
+ }
+}
+
+const unifiedPushManager = new AndroidUnifiedPushManager();
+
+/** Start native UnifiedPush registration and sync the Matrix pusher. */
+export const startBackgroundSync = async (mx: MatrixClient): Promise => {
+ await unifiedPushManager.start(mx);
+};
+
+/**
+ * Manually triggers a one-shot native fetch.
+ * Useful for diagnostics and bridge testing.
+ */
+export const triggerBackgroundSyncPing = async (reason?: string): Promise => {
+ if (!isBackgroundSyncSupported()) return;
+
+ try {
+ await MatrixBackgroundSync.triggerPing({ reason });
+ } catch (err) {
+ console.warn('[BackgroundSync] triggerPing failed:', err);
+ }
+};
+
+/** Stop UnifiedPush integration and remove the Matrix pusher for this session. */
+export const stopBackgroundSync = async (): Promise => {
+ await unifiedPushManager.stop();
+};
+
+/**
+ * Tells the native service whether the app UI is in the foreground.
+ * Call when document visibility changes so the service can suppress
+ * duplicate notifications while the JS layer is active.
+ * @param foreground true if the WebView UI is currently visible
+ */
+export const setAppForegroundState = async (foreground: boolean): Promise => {
+ if (!isBackgroundSyncSupported()) return;
+
+ try {
+ await MatrixBackgroundSync.setAppForeground({ foreground });
+ } catch (err) {
+ console.warn('[BackgroundSync] setAppForeground failed:', err);
+ }
+};
+
+/**
+ * Re-opens the UnifiedPush distributor selection dialog.
+ * Allows users to re-select or change their push notification distributor without terminal access.
+ * Useful when the current endpoint becomes unavailable.
+ */
+export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
+ if (!isBackgroundSyncSupported()) {
+ console.warn('[BackgroundSync] Background sync not supported on this platform');
+ return { success: false };
+ }
+
+ try {
+ const result = await MatrixBackgroundSync.requestDistributorSetup();
+ console.log('[BackgroundSync] requestDistributorSetup completed:', result);
+ return result ?? { success: false };
+ } catch (err) {
+ console.error('[BackgroundSync] requestDistributorSetup failed:', err);
+ return { success: false };
+ }
+};
+
+/** Returns the current native UnifiedPush status, or undefined if unavailable. */
+export const getBackgroundSyncStatus = async (): Promise => {
+ if (!isBackgroundSyncSupported()) return undefined;
+ try {
+ return await MatrixBackgroundSync.getStatus();
+ } catch (err) {
+ console.warn('[BackgroundSync] getStatus failed:', err);
+ return undefined;
+ }
+};
diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts
index 7058371..3de6911 100644
--- a/src/app/utils/matrix.ts
+++ b/src/app/utils/matrix.ts
@@ -23,7 +23,7 @@ const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/;
export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName);
-const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])([^\s:]+):(\S+)$/);
+const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])([^\s:]+):(\S+)$/);
const validMxId = (id: string): boolean => !!matchMxId(id);
@@ -301,7 +301,9 @@ export const mxcUrlToHttp = (
*/
export const isAuthenticatedMediaUrl = (url: string): boolean =>
url.includes('/_matrix/client/v1/media/download') ||
- url.includes('/_matrix/client/v1/media/thumbnail');
+ url.includes('/_matrix/client/v1/media/thumbnail') ||
+ url.includes('/_matrix/media/') &&
+ (url.includes('/download/') || url.includes('/thumbnail/'));
/**
* Downloads media with optional authentication.
diff --git a/src/app/utils/tauri.ts b/src/app/utils/tauri.ts
index 45f82f7..c8f3545 100644
--- a/src/app/utils/tauri.ts
+++ b/src/app/utils/tauri.ts
@@ -116,6 +116,9 @@ export const isYouTubeStreamingAvailable = (): boolean => {
/** 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
*/
@@ -190,6 +193,21 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void
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);
+ }
+ }
};
/**
@@ -204,6 +222,15 @@ export const isTauri = (): boolean =>
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)
*/
@@ -305,6 +332,78 @@ const ensureNotificationChannel = async (): Promise => {
}
};
+const ensureCapacitorNotificationChannel = async (): Promise => {
+ if (notificationChannelCreated || !isAndroid()) return;
+
+ try {
+ const { LocalNotifications } = await import('@capacitor/local-notifications');
+ await LocalNotifications.createChannel({
+ id: 'messages',
+ name: 'Messages',
+ description: 'Message notifications from Matrix',
+ importance: 5,
+ sound: 'default',
+ visibility: 1,
+ vibration: true,
+ lights: true,
+ });
+ notificationChannelCreated = true;
+ } catch (err) {
+ console.warn('Failed to create Capacitor notification channel:', err);
+ }
+};
+
+/**
+ * Request notification permission across Electron, Tauri, Capacitor, and browser
+ */
+export const requestSystemNotificationPermission = async (): Promise => {
+ 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 => {
+ 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';
+};
+
/**
* Send a notification using Tauri's notification plugin
* Falls back to browser Notification API if not in Tauri
@@ -369,6 +468,38 @@ export const sendNotification = async (options: {
console.warn('Tauri notification failed:', err);
}
}
+
+ if (isCapacitorNative()) {
+ 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 ensureCapacitorNotificationChannel();
+
+ const id = Math.floor(Date.now() % 2147483647);
+ await LocalNotifications.schedule({
+ notifications: [
+ {
+ id,
+ title,
+ body,
+ channelId: isAndroid() ? 'messages' : undefined,
+ smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
+ iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
+ extra: path ? { path } : undefined,
+ },
+ ],
+ });
+ }
+ return;
+ } catch (err) {
+ console.warn('Capacitor notification failed:', err);
+ }
+ }
// Fallback to browser notification
sendBrowserNotification({ title, body, icon, onClick });
diff --git a/tsconfig.json b/tsconfig.json
index 7608e55..2324e05 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,9 +7,10 @@
"allowJs": true,
"strict": true,
"esModuleInterop": true,
- "moduleResolution": "Node",
+ "moduleResolution": "bundler",
"resolveJsonModule": true,
"outDir": "dist",
+ "rootDir": "./src",
"skipLibCheck": true,
"lib": ["ES2016", "DOM"]
},