feat: Implement Android share functionality and background sync
- Add ShareRoomPicker component for selecting rooms to share Android intents. - Introduce AndroidShare utility for handling shared files and payloads from Android. - Implement background synchronization with UnifiedPush for Android notifications. - Enhance notification handling in Tauri and Capacitor environments. - Update TypeScript configuration for better module resolution. - Create Capacitor configuration file for Android integration. - Add SVG icon for notifications. - Refactor media URL authentication checks in matrix utility.
This commit is contained in:
12
capacitor.config.json
Normal file
12
capacitor.config.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"appId": "com.paarrot.app",
|
||||
"appName": "Paarrot",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false,
|
||||
"plugins": {
|
||||
"LocalNotifications": {
|
||||
"smallIcon": "ic_stat_paarrot",
|
||||
"iconColor": "#FF8A00"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
15
public/res/svg/notification.svg
Normal file
15
public/res/svg/notification.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1.05922,0,0,1.05922,-0.562821,-0.480534)">
|
||||
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
|
||||
<path d="M4.624,14.382C4.624,14.382 1.358,10.79 5.108,8.336C5.501,8.316 7.14,8.899 7.18,10.368C7.22,11.836 5.641,11.172 5.108,11.856C4.575,12.54 4.454,14.121 4.624,14.382Z"/>
|
||||
</g>
|
||||
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
|
||||
<path d="M5.08,13.547C5.08,13.547 7.28,14.842 7.291,10.74C7.592,10.971 8.588,12.107 7.713,13.777C6.838,15.446 5.392,14.322 5.08,13.547Z"/>
|
||||
</g>
|
||||
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
|
||||
<path d="M5.921,14.763C5.584,14.449 6.374,15.403 6.704,16.642C6.873,16.232 8.282,17.562 11.166,14.247C12.324,12.581 13.244,11.279 11.249,8.874C9.254,6.469 6.507,6.615 5.469,7.581C4.431,8.546 5.35,7.566 6.653,8.667C7.52,9.399 7.444,10.431 7.444,10.431C7.774,10.551 9.12,12.332 7.935,14.099C7.205,15.187 6.259,15.076 5.921,14.763Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
255
src/app/features/ShareRoomPicker.tsx
Normal file
255
src/app/features/ShareRoomPicker.tsx
Normal file
@@ -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<HTMLDivElement>(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<string> = 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<HTMLInputElement> = (evt) => {
|
||||
const value = evt.currentTarget.value.trim();
|
||||
if (!value) {
|
||||
resetSearch();
|
||||
return;
|
||||
}
|
||||
searchRoom(value);
|
||||
};
|
||||
|
||||
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (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 (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: onDismiss,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal size="300">
|
||||
<Box grow="Yes" direction="Column" style={{ maxHeight: '85vh' }}>
|
||||
<Header
|
||||
size="500"
|
||||
style={{ padding: config.space.S200, paddingLeft: config.space.S400 }}
|
||||
>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="H4">Share to Room</Text>
|
||||
{(previewText || fileCount > 0) && (
|
||||
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
|
||||
{previewText
|
||||
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
|
||||
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton size="300" radii="300" onClick={onDismiss}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
padding: `0 ${config.space.S300}`,
|
||||
paddingTop: config.space.S200,
|
||||
paddingBottom: config.space.S200,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
onChange={handleSearchChange}
|
||||
before={<Icon size="200" src={Icons.Search} />}
|
||||
placeholder="Search rooms…"
|
||||
size="400"
|
||||
variant="Background"
|
||||
outlined
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
|
||||
<Box
|
||||
style={{ padding: config.space.S300, paddingTop: 0 }}
|
||||
direction="Column"
|
||||
>
|
||||
{vItems.length === 0 && (
|
||||
<Box
|
||||
style={{ padding: `${config.space.S700} 0` }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
>
|
||||
<Text size="H6" align="Center">
|
||||
{searchResult ? 'No Match Found' : 'No Rooms'}
|
||||
</Text>
|
||||
<Text size="T200" align="Center" priority="300">
|
||||
{searchResult
|
||||
? `No rooms found for "${searchResult.query}".`
|
||||
: 'You have no rooms to share into.'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
style={{ position: 'relative', height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{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 (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
style={{ paddingBottom: config.space.S100 }}
|
||||
ref={virtualizer.measureElement}
|
||||
key={roomId}
|
||||
>
|
||||
<MenuItem
|
||||
data-room-id={roomId}
|
||||
onClick={handleRoomClick}
|
||||
variant="Surface"
|
||||
size="400"
|
||||
radii="400"
|
||||
before={
|
||||
<Avatar size="300" radii={isDm ? '400' : '300'}>
|
||||
{avatarSrc ? (
|
||||
<RoomAvatar
|
||||
roomId={roomId}
|
||||
src={avatarSrc}
|
||||
alt={room.name}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(room.name)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon room={room} size="200" filled />
|
||||
)}
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="B400" truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</Button>
|
||||
) : (
|
||||
<Switch
|
||||
disabled={notifPermission !== 'granted'}
|
||||
disabled={!capacitorNative && notifPermission !== 'granted'}
|
||||
value={showNotifications}
|
||||
onChange={setShowNotifications}
|
||||
/>
|
||||
@@ -145,6 +153,7 @@ export function SystemNotification() {
|
||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{isBackgroundSyncSupported() && <AndroidPushNotifications />}
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
@@ -156,3 +165,126 @@ export function SystemNotification() {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type PushStatus = {
|
||||
registered: boolean;
|
||||
distributor: string;
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
/** Android-only section showing UnifiedPush registration status and controls. */
|
||||
function AndroidPushNotifications() {
|
||||
const [status, setStatus] = useState<PushStatus | undefined>(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 (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Android Push (UnifiedPush)</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Background Notifications"
|
||||
description={
|
||||
loading ? (
|
||||
'Loading status…'
|
||||
) : status === undefined ? (
|
||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||
Failed to read push status.
|
||||
</Text>
|
||||
) : status.registered ? (
|
||||
<>
|
||||
<Text as="span" size="T200">
|
||||
{`Distributor: ${status.distributor || 'unknown'}`}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
||||
Not registered. Tap "Reset" to choose a distributor app.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Change Distributor"
|
||||
description="Re-open the UnifiedPush distributor selection dialog."
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => void reset()}
|
||||
>
|
||||
{resetState.status === AsyncStatus.Loading ? (
|
||||
<Spinner variant="Secondary" size="200" />
|
||||
) : (
|
||||
<Text size="B300">Reset</Text>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Test Notification"
|
||||
description="Trigger a test push ping to verify the pipeline works."
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => void ping()}
|
||||
>
|
||||
{pingState.status === AsyncStatus.Loading ? (
|
||||
<Spinner variant="Secondary" size="200" />
|
||||
) : (
|
||||
<Text size="B300">Send Test</Text>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string> => {
|
||||
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;
|
||||
|
||||
@@ -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<AndroidSharePayload | null>(null);
|
||||
const [pickedRoomId, setPickedRoomId] = useState<string | null>(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<void> } | 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 (
|
||||
<ShareRoomPicker
|
||||
share={pendingShare}
|
||||
onPick={handlePickRoom}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ClientNonUIFeaturesProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
@@ -466,8 +656,10 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<FaviconUpdater />
|
||||
<InviteNotifications />
|
||||
<MessageNotifications />
|
||||
<BackgroundSyncSetup />
|
||||
<PaarrotAPIInitializer />
|
||||
<TaskbarFlashStopper />
|
||||
<AndroidShareIntentHandler />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
73
src/app/utils/androidShare.ts
Normal file
73
src/app/utils/androidShare.ts
Normal file
@@ -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<void>;
|
||||
addListener(
|
||||
eventName: 'shareReceived',
|
||||
listenerFunc: (payload: AndroidSharePayload) => void
|
||||
): Promise<PluginListenerHandle>;
|
||||
}
|
||||
|
||||
const AndroidShareHandler = registerPlugin<AndroidShareHandlerPlugin>('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<AndroidSharePayload | null> => {
|
||||
if (!isAndroidShareSupported()) return null;
|
||||
const result = await AndroidShareHandler.getPendingShare();
|
||||
return result.share;
|
||||
};
|
||||
|
||||
/** Clears the native pending share payload. */
|
||||
export const clearPendingAndroidShare = async (): Promise<void> => {
|
||||
if (!isAndroidShareSupported()) return;
|
||||
await AndroidShareHandler.clearPendingShare();
|
||||
};
|
||||
|
||||
/** Subscribes to new incoming native share payloads. */
|
||||
export const listenForAndroidShares = async (
|
||||
listener: (payload: AndroidSharePayload) => void
|
||||
): Promise<PluginListenerHandle | 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
|
||||
): Promise<File> => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
518
src/app/utils/backgroundSync.ts
Normal file
518
src/app/utils/backgroundSync.ts
Normal file
@@ -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<void>;
|
||||
/** Trigger a one-shot fetch as if a push ping arrived. */
|
||||
triggerPing(options: { reason?: string }): Promise<void>;
|
||||
/** Re-open distributor setup flow and retry registration. */
|
||||
requestDistributorSetup(): Promise<{ success: boolean }>;
|
||||
/** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */
|
||||
stop(): Promise<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/** Returns fetch state and current UnifiedPush registration details. */
|
||||
getStatus(): Promise<UnifiedPushStatus>;
|
||||
addListener(
|
||||
eventName: 'unifiedPushNewEndpoint',
|
||||
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
||||
): Promise<PluginListenerHandle>;
|
||||
addListener(
|
||||
eventName: 'unifiedPushUnregistered',
|
||||
listenerFunc: (event: UnifiedPushUnregisteredEvent) => void
|
||||
): Promise<PluginListenerHandle>;
|
||||
addListener(
|
||||
eventName: 'unifiedPushRegistrationFailed',
|
||||
listenerFunc: (event: UnifiedPushRegistrationFailedEvent) => void
|
||||
): Promise<PluginListenerHandle>;
|
||||
}
|
||||
|
||||
type StoredPusherState = {
|
||||
endpoint: string;
|
||||
appId: string;
|
||||
};
|
||||
|
||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('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<string> => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<UnifiedPushStatus | undefined> {
|
||||
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<void> => {
|
||||
await unifiedPushManager.start(mx);
|
||||
};
|
||||
|
||||
/**
|
||||
* Manually triggers a one-shot native fetch.
|
||||
* Useful for diagnostics and bridge testing.
|
||||
*/
|
||||
export const triggerBackgroundSyncPing = async (reason?: string): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<UnifiedPushStatus | undefined> => {
|
||||
if (!isBackgroundSyncSupported()) return undefined;
|
||||
try {
|
||||
return await MatrixBackgroundSync.getStatus();
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] getStatus failed:', err);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const ensureCapacitorNotificationChannel = async (): Promise<void> => {
|
||||
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<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';
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user