diff --git a/config.json b/config.json index 2272551..57c577a 100644 --- a/config.json +++ b/config.json @@ -9,13 +9,30 @@ "xmr.se" ], "allowCustomHomeservers": true, + + "calling": { + "livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt" + }, + "featuredCommunities": { "openAsDefault": false, "spaces": [ + "#cinny-space:matrix.org", + "#community:matrix.org", + "#space:envs.net", + "#science-space:matrix.org", + "#libregaming-games:tchncs.de", + "#mathematics-on:matrix.org" ], "rooms": [ + "#cinny:matrix.org", + "#freesoftware:matrix.org", + "#pcapdroid:matrix.org", + "#gentoo:matrix.org", + "#PrivSec.dev:arcticfoxes.net", + "#disroot:aria-net.org" ], - "servers": [] + "servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"] }, "hashRouter": { diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 4d89bac..1a3d96b 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -6,6 +6,8 @@ import React, { forwardRef, useCallback, useState, + useEffect, + useRef, } from 'react'; import { Box, Scroll, Text } from 'folds'; import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate'; @@ -167,6 +169,48 @@ export const CustomEditor = forwardRef( [editor, onKeyDown] ); + const handleBeforeInput = useCallback( + (event: Event) => { + const inputEvent = event as InputEvent; + + // Handle autocorrect replacement that causes text duplication + if (inputEvent.inputType === 'insertReplacementText' || + inputEvent.inputType === 'insertFromComposition') { + const { selection } = editor; + if (!selection) return; + + // Get the data being inserted + const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain'); + + if (data) { + event.preventDefault(); + + // If there's selected text, delete it first + if (selection && !Range.isCollapsed(selection)) { + Transforms.delete(editor, { at: selection }); + } + + // Insert the replacement text + editor.insertText(data); + } + } + }, + [editor] + ); + + const editableRef = useRef(null); + + useEffect(() => { + const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]'); + if (!editableElement) return; + + editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true }); + + return () => { + editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true }); + }; + }, [handleBeforeInput]); + const renderPlaceholder = useCallback( ({ attributes, children }: RenderPlaceholderProps) => ( @@ -196,6 +240,7 @@ export const CustomEditor = forwardRef( size="300" visibility="Hover" hideTrack + ref={editableRef} > (null); + const [confirmRoomId, setConfirmRoomId] = useState(null); const mDirects = useAtomValue(mDirectAtom); const allRoomsSet = useAllJoinedRoomsSet(); @@ -107,149 +109,208 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro const handleRoomClick: React.MouseEventHandler = (evt) => { const roomId = evt.currentTarget.getAttribute('data-room-id'); if (roomId) { - onPick(roomId); + setConfirmRoomId(roomId); } }; + const handleConfirm = useCallback(() => { + if (confirmRoomId) { + onPick(confirmRoomId); + } + }, [confirmRoomId, onPick]); + + const handleCancelConfirm = useCallback(() => { + setConfirmRoomId(null); + }, []); + 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' : ''}`} - - )} - - - - - - -
+
+ + 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 - /> - + + } + placeholder="Search rooms…" + size="400" + variant="Background" + outlined + autoFocus + style={{ width: '100%' }} + /> + - + + + {vItems.length === 0 && ( - {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 ( + - - {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 ( - - - {avatarSrc ? ( - ( - - {nameInitials(room.name)} - - )} - /> - ) : ( - + + {avatarSrc ? ( + ( + + {nameInitials(room.name)} + )} - - } - > - - - {room.name} - - - - - ); - })} + /> + ) : ( + + )} + + } + > + + + {room.name} + + + + + ); + })} + + + +
+
+
+ + {confirmRoomId && (() => { + const room = getRoom(confirmRoomId); + const roomName = room?.name ?? confirmRoomId; + const isDm = mDirects.has(confirmRoomId); + return ( + }> + + + + + + Share to {isDm ? 'Person' : 'Room'}? + + Send to {roomName}? + + + + + - - - - - - + +
+
+
+ ); + })()} + ); } diff --git a/src/app/pages/auth/ServerPicker.tsx b/src/app/pages/auth/ServerPicker.tsx index a2a7810..7b89041 100644 --- a/src/app/pages/auth/ServerPicker.tsx +++ b/src/app/pages/auth/ServerPicker.tsx @@ -1,5 +1,6 @@ import React, { ChangeEventHandler, + FocusEventHandler, KeyboardEventHandler, MouseEventHandler, useEffect, @@ -39,8 +40,13 @@ export function ServerPicker({ const serverInputRef = useRef(null); useEffect(() => { - // sync input with it outside server changes - if (serverInputRef.current && serverInputRef.current.value !== server) { + // Only sync input when server changes externally (e.g., from menu selection) + // and input is not focused to avoid cursor jumping during typing + if ( + serverInputRef.current && + serverInputRef.current.value !== server && + document.activeElement !== serverInputRef.current + ) { serverInputRef.current.value = server; } }, [server]); @@ -52,6 +58,13 @@ export function ServerPicker({ if (inputServer) debounceServerSelect(inputServer); }; + const handleBlur: FocusEventHandler = (evt) => { + const inputServer = evt.target.value.trim(); + if (inputServer && inputServer !== server) { + onServerChange(inputServer); + } + }; + const handleKeyDown: KeyboardEventHandler = (evt) => { if (evt.key === 'ArrowDown') { evt.preventDefault(); @@ -85,6 +98,7 @@ export function ServerPicker({ outlined defaultValue={server} onChange={handleServerChange} + onBlur={handleBlur} onKeyDown={handleKeyDown} size="500" readOnly={!allowCustomServer} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 8f6072f..1387ffa 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -517,86 +517,118 @@ function TaskbarFlashStopper() { function AndroidShareIntentHandler() { const mx = useMatrixClient(); - const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); + const navigate = useNavigate(); 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 mDirects = useAtomValue(mDirectAtom); + const roomToParents = useAtomValue(roomToParentsAtom); const applyPendingShare = useCallback( async (share: AndroidSharePayload, roomId: string) => { const room = mx.getRoom(roomId); if (!room) return false; + // Send text message if present 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)); + const textContent = { + msgtype: 'm.text' as const, + body: nextParts.join('\n'), + }; + await mx.sendMessage(roomId, textContent); } + // Upload and send files if present if (share.files.length > 0) { - const uploadItems: TUploadItem[] = []; for (const sharedFile of share.files) { const originalFile = await materializeSharedFile(sharedFile, share.receivedAt); + + let fileToUpload = originalFile; + let encInfo: any = undefined; + if (room.hasEncryptionStateEvent()) { const encrypted = await encryptFile(originalFile); - uploadItems.push({ - file: encrypted.file, - originalFile, - metadata: { markedAsSpoiler: false }, - encInfo: encrypted.encInfo, - }); - continue; + fileToUpload = encrypted.file; + encInfo = encrypted.encInfo; } - uploadItems.push({ - file: originalFile, - originalFile, - metadata: { markedAsSpoiler: false }, - encInfo: undefined, - }); - } + // Upload file + const uploadResult = await mx.uploadContent(fileToUpload); + const mxc = uploadResult?.content_uri; + if (!mxc) continue; - setUploadItems({ - type: 'PUT', - item: uploadItems, - }); + // Determine message type and send + const fileType = originalFile.type; + let msgtype = 'm.file' as const; + if (fileType.startsWith('image/')) msgtype = 'm.image' as const; + else if (fileType.startsWith('video/')) msgtype = 'm.video' as const; + else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const; + + const fileContent: any = { + msgtype, + body: originalFile.name, + filename: originalFile.name, + info: { + mimetype: originalFile.type, + size: originalFile.size, + }, + }; + + if (encInfo) { + fileContent.file = { + ...encInfo, + url: mxc, + }; + } else { + fileContent.url = mxc; + } + + await mx.sendMessage(roomId, fileContent); + } } await clearPendingAndroidShare(); return true; }, - [isMarkdown, msgDraft, mx, setMsgDraft, setUploadItems] + [mx] ); const handlePickRoom = useCallback( (roomId: string) => { - setPickedRoomId(roomId); if (!pendingShare) return; applyPendingShare(pendingShare, roomId) .then((applied) => { if (applied) { setPendingShare(null); - setPickedRoomId(null); + + // Navigate to the selected room + const isDirect = mDirects.has(roomId); + if (isDirect) { + navigate(getDirectRoomPath(roomId)); + } else { + const parents = roomToParents.get(roomId); + const parent = parents && parents.length > 0 ? parents[0] : undefined; + if (parent) { + navigate(getSpaceRoomPath(parent, roomId)); + } else { + navigate(getHomeRoomPath(roomId)); + } + } } }) .catch((err) => { console.error('[AndroidShare] Failed to apply share after pick:', err); setPendingShare(null); - setPickedRoomId(null); }); }, - [applyPendingShare, pendingShare] + [applyPendingShare, pendingShare, navigate, mDirects, roomToParents] ); const handleDismiss = useCallback(() => { clearPendingAndroidShare().catch(() => {}); setPendingShare(null); - setPickedRoomId(null); }, []); useEffect(() => { diff --git a/src/app/utils/androidShare.ts b/src/app/utils/androidShare.ts index 15cde3d..445cc96 100644 --- a/src/app/utils/androidShare.ts +++ b/src/app/utils/androidShare.ts @@ -1,3 +1,5 @@ +import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core'; + /** Metadata for a file received from an Android share intent. */ export type AndroidSharedFile = { path: string; @@ -14,33 +16,58 @@ export type AndroidSharePayload = { receivedAt: number; }; -/** Listener handle compatible with the mobile bridge signature. */ -type PluginListenerHandle = { - remove: () => Promise; -}; +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 => false; +export const isAndroidShareSupported = (): boolean => + Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android'; /** Fetches the current pending native share payload. */ export const getPendingAndroidShare = async (): Promise => { - return null; + if (!isAndroidShareSupported()) return null; + const result = await AndroidShareHandler.getPendingShare(); + return result.share; }; /** Clears the native pending share payload. */ -export const clearPendingAndroidShare = async (): Promise => undefined; +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 + listener: (payload: AndroidSharePayload) => void ): Promise => { - return undefined; + 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 + sharedFile: AndroidSharedFile, + receivedAt: number ): Promise => { - throw new Error('Android share intents are only available in the mobile overlay build.'); + 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 index af79541..9f31676 100644 --- a/src/app/utils/backgroundSync.ts +++ b/src/app/utils/backgroundSync.ts @@ -1,8 +1,6 @@ -import type { MatrixClient } from 'matrix-js-sdk'; +import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core'; +import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk'; -/** - * Status shape kept compatible with the mobile overlay implementation. - */ type UnifiedPushStatus = { running: boolean; endpoint: string; @@ -12,34 +10,509 @@ type UnifiedPushStatus = { distributors: string[] | string; }; -/** Returns true when native Android background sync is available. */ -export const isBackgroundSyncSupported = (): boolean => false; +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 => undefined; +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 => undefined; +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 => undefined; +export const stopBackgroundSync = async (): Promise => { + await unifiedPushManager.stop(); +}; /** * Tells the native service whether the app UI is in the foreground. - * @param _foreground true if the WebView UI is currently visible + * 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 => undefined; +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. - * In desktop/web builds this is unsupported and always reports false. + * 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 }> => ({ - success: false, -}); +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 => undefined; +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/tauri.ts b/src/app/utils/tauri.ts index a4d51a3..c8f3545 100644 --- a/src/app/utils/tauri.ts +++ b/src/app/utils/tauri.ts @@ -119,48 +119,6 @@ let notificationTapCallback: ((path: string) => void) | null = null; const ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot'; const ANDROID_NOTIFICATION_ICON_COLOR = '#FF8A00'; -type CapacitorPermissionResult = { - display: string; -}; - -type CapacitorLocalNotificationChannel = { - id: string; - name: string; - description: string; - importance: number; - sound: string; - visibility: number; - vibration: boolean; - lights: boolean; -}; - -type CapacitorLocalNotificationItem = { - id: number; - title: string; - body: string; - channelId?: string; - smallIcon?: string; - iconColor?: string; - extra?: { path: string }; -}; - -type CapacitorLocalNotificationsApi = { - addListener: ( - eventName: string, - listenerFunc: (event: any) => void | Promise - ) => Promise; - createChannel: (channel: CapacitorLocalNotificationChannel) => Promise; - checkPermissions: () => Promise; - requestPermissions: () => Promise; - schedule: (options: { notifications: CapacitorLocalNotificationItem[] }) => Promise; -}; - -const importCapacitorLocalNotifications = async (): Promise => { - const pkg = '@capacitor' + '/local-notifications'; - const mod = (await import(pkg)) as { LocalNotifications: CapacitorLocalNotificationsApi }; - return mod.LocalNotifications; -}; - /** * Bring the Tauri window to the front and focus it */ @@ -238,7 +196,7 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void if (isCapacitorNative()) { try { - const LocalNotifications = await importCapacitorLocalNotifications(); + const { LocalNotifications } = await import('@capacitor/local-notifications'); await LocalNotifications.addListener('localNotificationActionPerformed', async (event: any) => { await focusWindow(); const path = event?.notification?.extra?.path; @@ -378,7 +336,7 @@ const ensureCapacitorNotificationChannel = async (): Promise => { if (notificationChannelCreated || !isAndroid()) return; try { - const LocalNotifications = await importCapacitorLocalNotifications(); + const { LocalNotifications } = await import('@capacitor/local-notifications'); await LocalNotifications.createChannel({ id: 'messages', name: 'Messages', @@ -407,7 +365,7 @@ export const requestSystemNotificationPermission = async (): Promise => if (isCapacitorNative()) { try { - const LocalNotifications = await importCapacitorLocalNotifications(); + const { LocalNotifications } = await import('@capacitor/local-notifications'); let perm = await LocalNotifications.checkPermissions(); if (perm.display !== 'granted') { perm = await LocalNotifications.requestPermissions(); @@ -427,7 +385,7 @@ export const requestSystemNotificationPermission = async (): Promise => export const getSystemNotificationPermissionState = async (): Promise => { if (isCapacitorNative()) { try { - const LocalNotifications = await importCapacitorLocalNotifications(); + const { LocalNotifications } = await import('@capacitor/local-notifications'); const perm = await LocalNotifications.checkPermissions(); return perm.display === 'granted' ? 'granted' : 'prompt'; } catch (err) { @@ -513,7 +471,7 @@ export const sendNotification = async (options: { if (isCapacitorNative()) { try { - const LocalNotifications = await importCapacitorLocalNotifications(); + const { LocalNotifications } = await import('@capacitor/local-notifications'); let perm = await LocalNotifications.checkPermissions(); if (perm.display !== 'granted') { perm = await LocalNotifications.requestPermissions();