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:
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user