feat: add Android/Capacitor platform support via overlay system

- Add Android project (Capacitor-generated, app ID com.paarrot.app)
- Add capacitor.config.json at repo root (webDir: cinny/dist)
- Add overlay/ directory with mobile-specific source patches:
  - tauri.ts: Capacitor native detection, LocalNotifications, permission APIs
  - matrix.ts / useAuthenticatedMediaUrl.ts: legacy /_matrix/media/ URL support
  - SystemNotification.tsx: Capacitor permission request flow
  - ClientNonUIFeatures.tsx: Capacitor notification routing
  - tsconfig.json: moduleResolution bundler, rootDir set
  - package-additions.json: @capacitor/* deps to merge at build time
- Add scripts/apply-overlay.mjs: applies overlay onto clean cinny submodule before build
- Update android:prepare script to run apply-overlay before build + cap sync
This commit is contained in:
2026-04-07 15:11:59 +10:00
parent 7b604b5caf
commit adce77b73b
64 changed files with 2975 additions and 1 deletions

View File

@@ -0,0 +1,8 @@
{
"dependencies": {
"@capacitor/android": "8.3.0",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2"
}
}

View File

@@ -0,0 +1,160 @@
import React, { useCallback } from 'react';
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { getNotificationState, usePermissionState } from '../../../hooks/usePermission';
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
function EmailNotification() {
const mx = useMatrixClient();
const [result, refreshResult] = useEmailNotifications();
const [setState, setEnable] = useAsyncCallback(
useCallback(
async (email: string, enable: boolean) => {
if (enable) {
await mx.setPusher({
kind: 'email',
app_id: 'm.email',
pushkey: email,
app_display_name: 'Email Notifications',
device_display_name: email,
lang: 'en',
data: {
brand: 'Paarrot',
},
append: true,
});
return;
}
await mx.setPusher({
pushkey: email,
app_id: 'm.email',
kind: null,
} as unknown as IPusherRequest);
},
[mx]
)
);
const handleChange = (value: boolean) => {
if (result && result.email) {
setEnable(result.email, value).then(() => {
refreshResult();
});
}
};
return (
<SettingTile
title="Email Notification"
description={
<>
{result && !result.email && (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Your account does not have any email attached.
</Text>
)}
{result && result.email && <>Send notification to your email. {`("${result.email}")`}</>}
{result === null && (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Unexpected Error!
</Text>
)}
{result === undefined && 'Send notification to your email.'}
</>
}
after={
<>
{setState.status !== AsyncStatus.Loading &&
typeof result === 'object' &&
result?.email && <Switch value={result.enabled} onChange={handleChange} />}
{(setState.status === AsyncStatus.Loading || result === undefined) && (
<Spinner variant="Secondary" />
)}
</>
}
/>
);
}
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 = async () => {
await requestSystemNotificationPermission();
};
return (
<Box direction="Column" gap="100">
<Text size="L400">System</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Desktop Notifications"
description={
notifPermission === 'denied' ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{'Notification' in window
? 'Notification permission is blocked. Please allow notification permission from browser address bar.'
: 'Notifications are not supported by the system.'}
</Text>
) : (
<span>Show desktop notifications when message arrive.</span>
)
}
after={
notifPermission === 'prompt' ? (
<Button size="300" radii="300" onClick={requestNotificationPermission}>
<Text size="B300">Enable</Text>
</Button>
) : (
<Switch
disabled={!capacitorNative && notifPermission !== 'granted'}
value={showNotifications}
onChange={setShowNotifications}
/>
)
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Notification Sound"
description="Play sound when new message arrive."
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<EmailNotification />
</SequenceCard>
</Box>
);
}

View File

@@ -0,0 +1,142 @@
import { useCallback, useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { getCurrentAccessToken } from '../utils/auth';
/**
* Fetches media with authentication and returns a blob URL.
* This is needed because service workers may not work reliably in Tauri/WebKit environments,
* and cross-origin image requests cannot include Authorization headers.
*/
export const useAuthenticatedMediaUrl = (
src: string | undefined,
useAuthentication: boolean
): string | undefined => {
const mx = useMatrixClient();
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
useEffect(() => {
if (!src) {
setBlobUrl(undefined);
return;
}
// If not using authentication, just return the original URL
if (!useAuthentication) {
setBlobUrl(src);
return;
}
// 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/media/') &&
(src.includes('/download/') || src.includes('/thumbnail/')));
if (!isAuthenticatedMediaUrl) {
setBlobUrl(src);
return;
}
let cancelled = false;
let objectUrl: string | undefined;
const fetchMedia = async () => {
try {
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
let response = await fetch(src, {
method: 'GET',
headers: accessToken
? { Authorization: `Bearer ${accessToken}` }
: undefined,
});
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken) {
console.warn('[useAuthenticatedMediaUrl] Auth failed (401), attempting unauthenticated fallback for:', src);
response = await fetch(src, { method: 'GET' });
}
if (!response.ok) {
console.warn(`Failed to fetch authenticated media: ${response.status}`);
// Fall back to original URL in case server doesn't require auth
if (!cancelled) setBlobUrl(src);
return;
}
const blob = await response.blob();
if (!cancelled) {
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
}
} catch (error) {
console.warn('Error fetching authenticated media:', error);
// Fall back to original URL
if (!cancelled) setBlobUrl(src);
}
};
fetchMedia();
return () => {
cancelled = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [src, useAuthentication, mx]);
return blobUrl;
};
/**
* Creates an authenticated fetch function for media URLs.
* Useful for components that need to load multiple images.
*/
export const useAuthenticatedMediaFetch = () => {
const mx = useMatrixClient();
return useCallback(
async (src: string): Promise<string> => {
const isAuthenticatedMediaUrl =
src.includes('/_matrix/client/v1/media/download') ||
src.includes('/_matrix/client/v1/media/thumbnail') ||
(src.includes('/_matrix/media/') &&
(src.includes('/download/') || src.includes('/thumbnail/')));
if (!isAuthenticatedMediaUrl) {
return src;
}
try {
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
let response = await fetch(src, {
method: 'GET',
headers: accessToken
? { Authorization: `Bearer ${accessToken}` }
: undefined,
});
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken) {
console.warn('[useAuthenticatedMediaFetch] Auth failed (401), attempting unauthenticated fallback');
response = await fetch(src, { method: 'GET' });
}
if (!response.ok) {
console.warn(`Failed to fetch authenticated media: ${response.status}`);
return src;
}
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (error) {
console.warn('Error fetching authenticated media:', error);
return src;
}
},
[mx]
);
};

View File

@@ -0,0 +1,483 @@
import { useAtomValue } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/svg/paarrot.svg';
import LogoUnreadSVG from '../../../../public/res/svg/paarrot-unread.svg';
import LogoHighlightSVG from '../../../../public/res/svg/paarrot-highlight.svg';
import { notificationPermission, setFavicon } from '../../utils/dom';
import { useSetting } from '../../state/hooks/settings';
import { EmojiStyle, settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { usePreviousValue } from '../../hooks/usePreviousValue';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath, getInboxInvitesPath } from '../pathUtils';
import {
getMemberDisplayName,
getNotificationType,
getUnreadInfo,
isNotificationEvent,
getOrphanParents,
guessPerfectParent,
} from '../../utils/room';
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { mDirectAtom } from '../../state/mDirectList';
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,
isCapacitorNative,
sendNotification,
setupNotificationTapListener,
} from '../../utils/tauri';
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
/**
* Applies the selected emoji style font to the document.
* - System: Uses the native OS emoji font
* - Apple: Uses Apple Color Emoji (bundled font)
* - Twemoji: Uses Twitter's Twemoji font
*/
function EmojiStyleFeature() {
const [emojiStyle] = useSetting(settingsAtom, 'emojiStyle');
switch (emojiStyle) {
case EmojiStyle.Apple:
document.documentElement.style.setProperty('--font-emoji', 'AppleColorEmoji');
break;
case EmojiStyle.Twemoji:
document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
break;
case EmojiStyle.System:
default:
document.documentElement.style.setProperty('--font-emoji', 'SystemEmoji');
break;
}
return null;
}
function PageZoomFeature() {
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
if (pageZoom === 100) {
document.documentElement.style.removeProperty('font-size');
} else {
document.documentElement.style.setProperty('font-size', `calc(1em * ${pageZoom / 100})`);
}
return null;
}
function FaviconUpdater() {
const roomToUnread = useAtomValue(roomToUnreadAtom);
useEffect(() => {
let notification = false;
let highlight = false;
roomToUnread.forEach((unread) => {
if (unread.total > 0) {
notification = true;
}
if (unread.highlight > 0) {
highlight = true;
}
});
if (notification) {
setFavicon(highlight ? LogoHighlightSVG : LogoUnreadSVG);
} else {
setFavicon(LogoSVG);
}
}, [roomToUnread]);
return null;
}
function InviteNotifications() {
const invites = useAtomValue(allInvitesAtom);
const perviousInviteLen = usePreviousValue(invites.length, 0);
const mx = useMatrixClient();
const navigate = useNavigate();
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const notify = useCallback(
(count: number) => {
const body = `You have ${count} new invitation request.`;
const invitesPath = getInboxInvitesPath();
// Flash taskbar icon for desktop notifications (only visible when window is not focused)
if (isElectron() && (window as any).electron?.window?.flashFrame) {
(window as any).electron.window.flashFrame(true)
.then((result: { success: boolean }) => {
console.log('[InviteNotifications] flashFrame result:', result);
})
.catch((err: Error) => {
console.error('[InviteNotifications] flashFrame error:', err);
});
}
if ((isTauri() && !isElectron()) || isCapacitorNative()) {
sendNotification({
title: 'Invitation',
body,
path: invitesPath,
onClick: () => {
if (!window.closed) navigate(invitesPath);
},
});
} else {
const noti = new window.Notification('Invitation', {
icon: LogoSVG,
badge: LogoSVG,
body,
silent: true,
});
noti.onclick = () => {
window.focus();
if (!window.closed) navigate(invitesPath);
noti.close();
// Stop flashing when user clicks notification
if ((window as any).electron?.window?.flashFrame) {
(window as any).electron.window.flashFrame(false);
}
};
}
},
[navigate]
);
const playSound = useCallback(() => {
console.log('[InviteNotifications] playSound called, isElectron:', isElectron());
if (isElectron() && (window as any).electron?.audio?.playNotificationSound) {
console.log('[InviteNotifications] Using Electron audio API');
(window as any).electron.audio.playNotificationSound('invite')
.then((result: any) => console.log('[InviteNotifications] Sound result:', result))
.catch((err: any) => console.error('[InviteNotifications] Sound error:', err));
} else {
console.log('[InviteNotifications] Using HTML5 Audio fallback');
new Audio('./sound/invite.ogg').play().catch((err) => {
console.error('[Audio] Failed to play invite sound:', err);
});
}
}, []);
useEffect(() => {
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
if (showNotifications && notificationPermission('granted')) {
notify(invites.length - perviousInviteLen);
}
if (notificationSound) {
playSound();
}
}
}, [mx, invites, perviousInviteLen, showNotifications, notificationSound, notify, playSound]);
return null;
}
function MessageNotifications() {
const notifRef = useRef<Notification>();
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
const navigate = useNavigate();
const notificationSelected = useInboxNotificationsSelected();
const selectedRoomId = useSelectedRoom();
// Set up notification tap listener for mobile
useEffect(() => {
setupNotificationTapListener((path) => {
navigate(path);
});
}, [navigate]);
const notify = useCallback(
({
roomName,
roomAvatar,
username,
messageBody,
roomId,
eventId,
isDm,
}: {
roomName: string;
roomAvatar?: string;
username: string;
messageBody?: string;
roomId: string;
eventId: string;
isDm: boolean;
}) => {
const notificationTitle = username;
const notificationBody = messageBody || 'New message';
/** Replicates TitleBar click navigation logic */
const navigateToRoom = () => {
if (!mx) return;
try {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
if (mDirects.has(roomId)) {
navigate(getDirectRoomPath(roomIdOrAlias, eventId));
return;
}
const orphanParents = getOrphanParents(roomToParents, roomId);
if (orphanParents.length > 0) {
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId));
return;
}
navigate(getHomeRoomPath(roomIdOrAlias, eventId));
} catch (err) {
console.error('[Notifications] Navigate error:', err);
}
};
// Flash taskbar icon for desktop notifications (only visible when window is not focused)
console.log('[Notifications] Attempting flashFrame');
if (isElectron() && (window as any).electron?.window?.flashFrame) {
(window as any).electron.window.flashFrame(true)
.then((result: { success: boolean }) => {
console.log('[Notifications] flashFrame result:', result);
})
.catch((err: Error) => {
console.error('[Notifications] flashFrame error:', err);
});
} else {
console.warn('[Notifications] flashFrame not available, isElectron:', isElectron());
}
if ((isTauri() && !isElectron()) || isCapacitorNative()) {
const roomPath = isDm
? getDirectRoomPath(roomId, eventId)
: getHomeRoomPath(roomId, eventId);
sendNotification({
title: notificationTitle,
body: notificationBody,
path: roomPath,
onClick: () => {
if (!window.closed) navigate(roomPath);
},
});
} else {
// Use renderer window.Notification — works in both Electron and browser.
// Main-process Notification click events are unreliable on Windows without
// full COM/AUMID registration, so we keep everything in the renderer.
const iconUrl = roomAvatar ?? '/res/android/android-chrome-192x192.png';
const noti = new window.Notification(notificationTitle, {
icon: iconUrl,
body: notificationBody,
silent: true,
});
noti.onclick = () => {
// Tell main process to bring window to front
(window as any).electron?.window?.focus();
if (!window.closed) navigateToRoom();
noti.close();
notifRef.current = undefined;
// Stop flashing when user clicks notification
if ((window as any).electron?.window?.flashFrame) {
(window as any).electron.window.flashFrame(false);
}
};
notifRef.current?.close();
notifRef.current = noti;
}
},
[mx, navigate, mDirects, roomToParents]
);
const playSound = useCallback(() => {
console.log('[MessageNotifications] playSound called, isElectron:', isElectron());
if (isElectron() && (window as any).electron?.audio?.playNotificationSound) {
console.log('[MessageNotifications] Using Electron audio API');
(window as any).electron.audio.playNotificationSound('message')
.then((result: any) => console.log('[MessageNotifications] Sound result:', result))
.catch((err: any) => console.error('[MessageNotifications] Sound error:', err));
} else {
console.log('[MessageNotifications] Using HTML5 Audio fallback');
new Audio('./sound/notification.ogg').play().catch((err) => {
console.error('[Audio] Failed to play notification sound:', err);
});
}
}, []);
useEffect(() => {
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
mEvent,
room,
toStartOfTimeline,
removed,
data
) => {
if (mx.getSyncState() !== 'SYNCING') return;
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
if (
!room ||
!data.liveEvent ||
room.isSpaceRoom() ||
!isNotificationEvent(mEvent) ||
getNotificationType(mx, room.roomId) === NotificationType.Mute
) {
return;
}
const sender = mEvent.getSender();
const eventId = mEvent.getId();
if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return;
const unreadInfo = getUnreadInfo(room);
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
unreadCacheRef.current.set(room.roomId, unreadInfo);
if (unreadInfo.total === 0) return;
if (
cachedUnreadInfo &&
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo))
) {
return;
}
if (
showNotifications &&
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
const content = mEvent.getContent();
let messageBody: string | undefined;
if (mEvent.getType() === 'm.reaction') {
// For reactions, show "reacted with {emoji}"
const reactionKey = content['m.relates_to']?.key;
if (reactionKey) {
messageBody = `reacted with ${reactionKey}`;
} else {
messageBody = 'reacted to a message';
}
} else {
messageBody = typeof content.body === 'string' ? content.body : undefined;
}
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
messageBody,
roomId: room.roomId,
eventId,
isDm,
});
}
if (notificationSound) {
playSound();
}
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [
mx,
notificationSound,
notificationSelected,
showNotifications,
playSound,
notify,
selectedRoomId,
useAuthentication,
]);
return null;
}
/**
* Initializes the Paarrot API for Electron integration
* Registers the navigate function and sets up IPC handlers
*/
function PaarrotAPIInitializer() {
const navigate = useNavigate();
const mx = useMatrixClient();
useEffect(() => {
// Register navigate function for Paarrot API
setPaarrotNavigate(navigate);
// Initialize Paarrot API handlers
initPaarrotAPI(mx);
console.log('Paarrot API: Initialized with navigate function');
}, [navigate, mx]);
return null;
}
/**
* Stops taskbar icon flashing when window gains focus
*/
function TaskbarFlashStopper() {
useEffect(() => {
// Log what's available on mount
console.log('[TaskbarFlashStopper] window.electron:', (window as any).electron);
console.log('[TaskbarFlashStopper] Available APIs:', {
hasWindow: !!(window as any).electron?.window,
hasFlashFrame: !!(window as any).electron?.window?.flashFrame,
hasAudio: !!(window as any).electron?.audio,
hasPlaySound: !!(window as any).electron?.audio?.playNotificationSound,
});
const handleFocus = () => {
if ((window as any).electron?.window?.flashFrame) {
(window as any).electron.window.flashFrame(false);
}
};
window.addEventListener('focus', handleFocus);
return () => {
window.removeEventListener('focus', handleFocus);
};
}, []);
return null;
}
type ClientNonUIFeaturesProps = {
children: ReactNode;
};
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
<EmojiStyleFeature />
<PageZoomFeature />
<FaviconUpdater />
<InviteNotifications />
<MessageNotifications />
<PaarrotAPIInitializer />
<TaskbarFlashStopper />
{children}
</>
);
}

View File

@@ -0,0 +1,421 @@
import {
EncryptedAttachmentInfo,
decryptAttachment,
encryptAttachment,
} from 'browser-encrypt-attachment';
import {
EventTimeline,
MatrixClient,
MatrixError,
MatrixEvent,
Room,
RoomMember,
UploadProgress,
UploadResponse,
} from 'matrix-js-sdk';
import to from 'await-to-js';
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { getStateEvent } from './room';
import { Membership, StateEvent } from '../../types/matrix/room';
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 validMxId = (id: string): boolean => !!matchMxId(id);
export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3];
export const getMxIdLocalPart = (userId: string): string | undefined => matchMxId(userId)?.[2];
export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@');
export const isRoomId = (id: string): boolean => id.startsWith('!');
export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');
export const getCanonicalAliasRoomId = (mx: MatrixClient, alias: string): string | undefined =>
mx
.getRooms()
?.find(
(room) =>
room.getCanonicalAlias() === alias &&
getStateEvent(room, StateEvent.RoomTombstone) === undefined
)?.roomId;
export const getCanonicalAliasOrRoomId = (mx: MatrixClient, roomId: string): string => {
const room = mx.getRoom(roomId);
if (!room) return roomId;
if (getStateEvent(room, StateEvent.RoomTombstone) !== undefined) return roomId;
const alias = room.getCanonicalAlias();
if (alias && getCanonicalAliasRoomId(mx, alias) === roomId) {
return alias;
}
return roomId;
};
export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
const info: IImageInfo = {};
info.w = img.width;
info.h = img.height;
info.mimetype = fileOrBlob.type;
info.size = fileOrBlob.size;
return info;
};
export const getVideoInfo = (video: HTMLVideoElement, fileOrBlob: File | Blob): IVideoInfo => {
const info: IVideoInfo = {};
info.duration = Number.isNaN(video.duration) ? undefined : Math.floor(video.duration * 1000);
info.w = video.videoWidth;
info.h = video.videoHeight;
info.mimetype = fileOrBlob.type;
info.size = fileOrBlob.size;
return info;
};
export const getThumbnailContent = (thumbnailInfo: {
thumbnail: File | Blob;
encInfo: EncryptedAttachmentInfo | undefined;
mxc: string;
width: number;
height: number;
}): IThumbnailContent => {
const { thumbnail, encInfo, mxc, width, height } = thumbnailInfo;
const content: IThumbnailContent = {
thumbnail_info: {
mimetype: thumbnail.type,
size: thumbnail.size,
w: width,
h: height,
},
};
if (encInfo) {
content.thumbnail_file = {
...encInfo,
url: mxc,
};
} else {
content.thumbnail_url = mxc;
}
return content;
};
export const encryptFile = async (
file: File | Blob
): Promise<{
encInfo: EncryptedAttachmentInfo;
file: File;
originalFile: File | Blob;
}> => {
const dataBuffer = await file.arrayBuffer();
const encryptedAttachment = await encryptAttachment(dataBuffer);
const encFile = new File([encryptedAttachment.data], file.name, {
type: file.type,
});
return {
encInfo: encryptedAttachment.info,
file: encFile,
originalFile: file,
};
};
export const decryptFile = async (
dataBuffer: ArrayBuffer,
type: string,
encInfo: EncryptedAttachmentInfo
): Promise<Blob> => {
const dataArray = await decryptAttachment(dataBuffer, encInfo);
const blob = new Blob([dataArray], { type });
return blob;
};
export type TUploadContent = File | Blob;
export type ContentUploadOptions = {
name?: string;
fileType?: string;
hideFilename?: boolean;
onPromise?: (promise: Promise<UploadResponse>) => void;
onProgress?: (progress: UploadProgress) => void;
onSuccess: (mxc: string) => void;
onError: (error: MatrixError) => void;
};
export const uploadContent = async (
mx: MatrixClient,
file: TUploadContent,
options: ContentUploadOptions
) => {
const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;
const uploadPromise = mx.uploadContent(file, {
name,
type: fileType,
includeFilename: !hideFilename,
progressHandler: onProgress,
});
onPromise?.(uploadPromise);
try {
const data = await uploadPromise;
const mxc = data.content_uri;
if (mxc) onSuccess(mxc);
else onError(new MatrixError(data));
} catch (e: any) {
const error = typeof e?.message === 'string' ? e.message : undefined;
const errcode = typeof e?.name === 'string' ? e.message : undefined;
onError(new MatrixError({ error, errcode }));
}
};
export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs();
export const factoryEventSentBy = (senderId: string) => (ev: MatrixEvent) =>
ev.getSender() === senderId;
export const eventWithShortcode = (ev: MatrixEvent) =>
typeof ev.getContent().shortcode === 'string';
export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined => {
const dmLikeRooms = mx
.getRooms()
.filter(
(room) =>
room.getMyMembership() === Membership.Join &&
room.hasEncryptionStateEvent() &&
room.getMembers().length <= 2
);
return dmLikeRooms.find((room) => room.getMember(userId));
};
export const guessDmRoomUserId = (room: Room, myUserId: string): string => {
const getOldestMember = (members: RoomMember[]): RoomMember | undefined => {
let oldestMemberTs: number | undefined;
let oldestMember: RoomMember | undefined;
const pickOldestMember = (member: RoomMember) => {
if (member.userId === myUserId) return;
if (
oldestMemberTs === undefined ||
(member.events.member && member.events.member.getTs() < oldestMemberTs)
) {
oldestMember = member;
oldestMemberTs = member.events.member?.getTs();
}
};
members.forEach(pickOldestMember);
return oldestMember;
};
// Pick the joined user who's been here longest (and isn't us),
const member = getOldestMember(room.getJoinedMembers());
if (member) return member.userId;
// if there are no joined members other than us, use the oldest member
const member1 = getOldestMember(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getMembers() ?? []
);
return member1?.userId ?? myUserId;
};
export const addRoomIdToMDirect = async (
mx: MatrixClient,
roomId: string,
userId: string
): Promise<void> => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
let userIdToRoomIds: Record<string, string[]> = {};
if (typeof mDirectsEvent !== 'undefined')
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
// remove it from the lists of any others users
// (it can only be a DM room for one person)
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId];
if (targetUserId !== userId) {
const indexOfRoomId = roomIds.indexOf(roomId);
if (indexOfRoomId > -1) {
roomIds.splice(indexOfRoomId, 1);
}
}
});
const roomIds = userIdToRoomIds[userId] || [];
if (roomIds.indexOf(roomId) === -1) {
roomIds.push(roomId);
}
userIdToRoomIds[userId] = roomIds;
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
};
export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise<void> => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
let userIdToRoomIds: Record<string, string[]> = {};
if (typeof mDirectsEvent !== 'undefined')
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId];
const indexOfRoomId = roomIds.indexOf(roomId);
if (indexOfRoomId > -1) {
roomIds.splice(indexOfRoomId, 1);
}
});
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
};
export const mxcUrlToHttp = (
mx: MatrixClient,
mxcUrl: string,
useAuthentication?: boolean,
width?: number,
height?: number,
resizeMethod?: string,
allowDirectLinks?: boolean,
allowRedirects?: boolean
): string | null =>
mx.mxcUrlToHttp(
mxcUrl,
width,
height,
resizeMethod,
allowDirectLinks,
allowRedirects,
useAuthentication
);
/**
* Check if a URL is an authenticated media endpoint.
*/
export const isAuthenticatedMediaUrl = (url: string): boolean =>
url.includes('/_matrix/client/v1/media/download') ||
url.includes('/_matrix/client/v1/media/thumbnail') ||
url.includes('/_matrix/media/') &&
(url.includes('/download/') || url.includes('/thumbnail/'));
/**
* Downloads media with optional authentication.
* For authenticated media URLs, the access token is required.
* Falls back to unauthenticated request if authenticated request fails with 401.
* Returns an empty blob with error type if all attempts fail, to prevent cascading failures.
*
* @param src - The media URL to download
* @param accessToken - Optional access token (if not provided, will use current session's token)
*/
export const downloadMedia = async (src: string, accessToken?: string | null): Promise<Blob> => {
// Import here to avoid circular dependencies
const { getCurrentAccessToken } = await import('./auth');
// Use provided token, or fall back to current session's token
const token = accessToken ?? getCurrentAccessToken();
const needsAuth = isAuthenticatedMediaUrl(src);
const headers: HeadersInit = {};
if (needsAuth && token) {
headers.Authorization = `Bearer ${token}`;
}
try {
let res = await fetch(src, { method: 'GET', headers });
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!res.ok && res.status === 401 && needsAuth && token) {
console.warn('[downloadMedia] Auth failed (401), attempting unauthenticated fallback for:', src);
res = await fetch(src, { method: 'GET' });
}
if (!res.ok) {
console.warn(`[downloadMedia] Failed to download media (${res.status}) from:`, src);
throw new Error(`Failed to download media: ${res.status}`);
}
const blob = await res.blob();
return blob;
} catch (error) {
// Log but re-throw so callers can handle appropriately
console.warn('[downloadMedia] Error downloading media:', error);
throw error;
}
};
export const downloadEncryptedMedia = async (
src: string,
decryptContent: (buf: ArrayBuffer) => Promise<Blob>,
accessToken?: string | null
): Promise<Blob> => {
const encryptedContent = await downloadMedia(src, accessToken);
const decryptedContent = await decryptContent(await encryptedContent.arrayBuffer());
return decryptedContent;
};
export const rateLimitedActions = async <T, R = void>(
data: T[],
callback: (item: T, index: number) => Promise<R>,
maxRetryCount?: number
) => {
let retryCount = 0;
let actionInterval = 0;
const sleepForMs = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const performAction = async (dataItem: T, index: number) => {
const [err] = await to<R, MatrixError>(callback(dataItem, index));
if (err?.httpStatus === 429) {
if (retryCount === maxRetryCount) {
return;
}
const waitMS = err.getRetryAfterMs() ?? 3000;
actionInterval = waitMS * 1.5;
await sleepForMs(waitMS);
retryCount += 1;
await performAction(dataItem, index);
}
};
for (let i = 0; i < data.length; i += 1) {
const dataItem = data[i];
retryCount = 0;
// eslint-disable-next-line no-await-in-loop
await performAction(dataItem, i);
if (actionInterval > 0) {
// eslint-disable-next-line no-await-in-loop
await sleepForMs(actionInterval);
}
}
};
export const knockSupported = (version: string): boolean => {
const unsupportedVersion = ['1', '2', '3', '4', '5', '6'];
return !unsupportedVersion.includes(version);
};
export const restrictedSupported = (version: string): boolean => {
const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7'];
return !unsupportedVersion.includes(version);
};
export const knockRestrictedSupported = (version: string): boolean => {
const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
return !unsupportedVersion.includes(version);
};
export const creatorsSupported = (version: string): boolean => {
const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'];
return !unsupportedVersion.includes(version);
};

View File

@@ -0,0 +1,625 @@
/**
* Open an external URL using Tauri opener plugin on desktop/mobile, or window.open in browser
* @param url The URL to open
*/
export const openExternalUrl = async (url: string): Promise<void> => {
console.log('[openExternalUrl] called with:', url);
// Use Electron's shell.openExternal if in Electron
if (isElectron()) {
try {
const electron = (window as any).electron;
if (electron?.shell?.openExternal) {
await electron.shell.openExternal(url);
console.log('[openExternalUrl] Electron shell.openExternal succeeded');
return;
}
} catch (err) {
console.warn('[openExternalUrl] Electron shell.openExternal failed:', err);
}
}
// Use Tauri for actual Tauri builds
if (isTauri() && !isElectron()) {
try {
// First, try the plugin directly (works on both desktop and mobile)
console.log('[openExternalUrl] trying Tauri opener plugin...');
const { openUrl } = await import('@tauri-apps/plugin-opener');
await openUrl(url);
console.log('[openExternalUrl] Tauri plugin succeeded');
return;
} catch (pluginErr) {
console.warn('[openExternalUrl] Tauri opener plugin failed:', pluginErr);
// Fallback: try the custom command (useful if plugin fails due to ACL)
try {
console.log('[openExternalUrl] trying Tauri invoke command fallback...');
const { invoke } = await import('@tauri-apps/api/core');
await invoke('open_external_url', { url });
console.log('[openExternalUrl] Tauri command fallback succeeded');
return;
} catch (invokeErr) {
console.error('[openExternalUrl] Tauri command fallback also failed:', invokeErr);
}
}
}
console.log('[openExternalUrl] falling back to window.open');
window.open(url, '_blank');
};
/**
* YouTube stream info returned by yt-dlp
*/
export interface YouTubeStreamInfo {
/** Direct video stream URL */
video_url: string;
/** Title of the video */
title: string;
}
/**
* Get direct YouTube stream URL using yt-dlp
* Requires yt-dlp to be installed on the system
* @param url The YouTube video URL
* @returns Stream info with direct URL and title
* @throws If yt-dlp is not installed or fails
*/
export const getYouTubeStream = async (url: string): Promise<YouTubeStreamInfo> => {
if (typeof window === 'undefined') {
throw new Error('YouTube streaming requires desktop app with yt-dlp installed');
}
// Check for Electron
const electron = (window as any).electron;
if (electron?.youtube?.getStream) {
const result = await electron.youtube.getStream(url);
// Electron returns { success: true, data: { video_url, title } } or { success: false, error }
if (result.success === false) {
throw new Error(result.error || 'Failed to get YouTube stream');
}
return result.data;
}
// Check for Tauri
if ((window as any).__TAURI__) {
const { invoke } = await import('@tauri-apps/api/core');
return invoke<YouTubeStreamInfo>('get_youtube_stream', { url });
}
throw new Error('YouTube streaming requires desktop app with yt-dlp installed');
};
/**
* Check if yt-dlp is available for YouTube streaming
* @returns True if yt-dlp streaming is available
*/
export const isYouTubeStreamingAvailable = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
// Check for Electron
const electron = (window as any).electron;
if (electron?.youtube?.getStream) {
return true;
}
// Check for Tauri
return !!(window as any).__TAURI__;
};
/**
* Tauri-specific utilities for desktop and mobile platforms
*/
/** Callback for notification tap actions */
let notificationTapCallback: ((path: string) => void) | null = null;
const ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot';
const ANDROID_NOTIFICATION_ICON_COLOR = '#FF8A00';
/**
* Bring the Tauri window to the front and focus it
*/
export const focusWindow = async (): Promise<void> => {
// Use Electron's window focus if in Electron
if (isElectron()) {
try {
const electron = (window as any).electron;
if (electron?.window?.focus) {
await electron.window.focus();
}
return;
} catch (err) {
console.warn('Failed to focus Electron window:', err);
}
}
// Use Tauri's window focus for actual Tauri builds
if (isTauri() && !isElectron()) {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const window = getCurrentWindow();
await window.unminimize();
await window.setFocus();
} catch (err) {
console.warn('Failed to focus Tauri window:', err);
}
}
};
/**
* Set up listener for notification tap/click actions
* Call this once at app startup with a navigation callback
*/
export const setupNotificationTapListener = async (onTap: (path: string) => void): Promise<void> => {
notificationTapCallback = onTap;
// Use Electron's native notification handler if in Electron
if (isElectron()) {
try {
const electron = (window as any).electron;
if (electron?.notification?.onNavigate) {
electron.notification.onNavigate((data: { path?: string }) => {
if (data.path && typeof data.path === 'string' && notificationTapCallback) {
notificationTapCallback(data.path);
}
});
return;
}
} catch (err) {
console.warn('Failed to set up Electron notification listener:', err);
return;
}
}
// Use Tauri plugin for actual Tauri builds (not Electron)
if (isTauri() && !isElectron()) {
try {
const { onAction } = await import('@tauri-apps/plugin-notification');
await onAction(async (notification) => {
// Bring window to front first
await focusWindow();
// Get the path from extra data and navigate
const path = notification.notification?.extra?.path;
if (path && typeof path === 'string' && notificationTapCallback) {
notificationTapCallback(path);
}
});
} catch (err) {
console.warn('Failed to set up Tauri notification tap listener:', err);
}
}
if (isCapacitorNative()) {
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
await LocalNotifications.addListener('localNotificationActionPerformed', async (event: any) => {
await focusWindow();
const path = event?.notification?.extra?.path;
if (path && typeof path === 'string' && notificationTapCallback) {
notificationTapCallback(path);
}
});
} catch (err) {
console.warn('Failed to set up Capacitor notification tap listener:', err);
}
}
};
/**
* Check if we're running inside a Tauri application
*/
export const isTauri = (): boolean =>
'__TAURI__' in window || '__TAURI_INTERNALS__' in window;
/**
* Check if we're running inside Electron (not Tauri)
*/
export const isElectron = (): boolean =>
typeof window !== 'undefined' && 'electron' in window;
/**
* Check if we're running inside a Capacitor native app
*/
export const isCapacitorNative = (): boolean => {
if (typeof window === 'undefined') return false;
const cap = (window as any).Capacitor;
return Boolean(cap?.isNativePlatform?.() || cap?.getPlatform?.() === 'android' || cap?.getPlatform?.() === 'ios');
};
/**
* Check if we're running on a mobile platform (Android/iOS)
*/
export const isTauriMobile = (): boolean => {
if (!isTauri() || isElectron()) return false;
const ua = navigator.userAgent.toLowerCase();
return ua.includes('android') || ua.includes('iphone') || ua.includes('ipad');
};
/**
* Check if we're running on Android
*/
export const isAndroid = (): boolean => {
const ua = navigator.userAgent.toLowerCase();
return ua.includes('android');
};
/**
* Apply safe area insets for mobile devices
* On Android, CSS env() may not work, so we apply fallback padding
*/
export const applySafeAreaInsets = (): void => {
if (!isTauriMobile()) return;
const root = document.documentElement;
// Check if env() is working by testing if it returns a value
const testValue = getComputedStyle(root).getPropertyValue('--safe-area-inset-top');
const envWorking = testValue && testValue !== '0px' && testValue !== '';
if (!envWorking && isAndroid()) {
// Apply fallback padding for Android status bar and navigation bar
// Status bar is typically 24-48dp, navigation bar is typically 48dp
// We use conservative values that work on most devices
root.style.setProperty('--safe-area-inset-top', '28px');
root.style.setProperty('--safe-area-inset-bottom', '24px');
}
};
/**
* Send a browser notification
*/
const sendBrowserNotification = (options: {
title: string;
body: string;
icon?: string;
onClick?: () => void;
}): Notification | undefined => {
const { title, body, icon, onClick } = options;
if (!('Notification' in window)) return undefined;
if (Notification.permission !== 'granted') return undefined;
const notification = new Notification(title, {
body,
icon,
silent: true,
});
if (onClick) {
notification.onclick = async () => {
// Bring Tauri window to front if in Tauri
await focusWindow();
// Also use standard window.focus for browser
window.focus();
if (!window.closed) onClick();
notification.close();
};
}
return notification;
};
/** Flag to track if notification channel has been created on Android */
let notificationChannelCreated = false;
/**
* Create notification channel for Android
* Required for Android 8.0+ to show notifications
*/
const ensureNotificationChannel = async (): Promise<void> => {
if (notificationChannelCreated || !isAndroid()) return;
try {
const { createChannel, Importance } = await import('@tauri-apps/plugin-notification');
await createChannel({
id: 'messages',
name: 'Messages',
description: 'Message notifications from Matrix',
importance: Importance.High,
vibration: true,
sound: 'default',
});
notificationChannelCreated = true;
} catch (err) {
console.warn('Failed to create notification channel:', err);
}
};
const ensureCapacitorNotificationChannel = async (): Promise<void> => {
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
*/
export const sendNotification = async (options: {
title: string;
body: string;
icon?: string;
path?: string;
onClick?: () => void;
}): Promise<void> => {
const { title, body, icon, path, onClick } = options;
// Use Electron's native notification API if in Electron
if (isElectron()) {
try {
const electron = (window as any).electron;
if (electron?.notification?.show) {
await electron.notification.show({
title,
body,
icon,
path,
});
return;
}
} catch (err) {
console.warn('Electron notification failed:', err);
}
}
// Use Tauri plugin for actual Tauri builds (not Electron)
if (isTauri() && !isElectron()) {
try {
const {
sendNotification: tauriSendNotification,
isPermissionGranted,
requestPermission,
} = await import('@tauri-apps/plugin-notification');
let permissionGranted = await isPermissionGranted();
if (!permissionGranted) {
const permission = await requestPermission();
permissionGranted = permission === 'granted';
}
if (permissionGranted) {
// Ensure notification channel exists on Android
await ensureNotificationChannel();
await tauriSendNotification({
title,
body,
// Use the channel on Android
channelId: isAndroid() ? 'messages' : undefined,
// Store path in extra data for notification tap handling
extra: path ? { path } : undefined,
});
}
return;
} catch (err) {
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 });
};
/**
* Check if we're running on Linux
*/
export const isLinux = (): boolean => {
const ua = navigator.userAgent.toLowerCase();
return ua.includes('linux') && !ua.includes('android');
};
/**
* Read clipboard image on Linux using Tauri command with arboard/Wayland support
* Returns a data URL if an image is found, null otherwise
*/
export const readClipboardImage = async (): Promise<File | null> => {
// Use Electron's clipboard API if in Electron
if (isElectron()) {
try {
const electron = (window as any).electron;
if (electron?.clipboard?.readImage) {
const dataUrl = await electron.clipboard.readImage();
if (!dataUrl) return null;
// Convert data URL to File
const response = await fetch(dataUrl);
const blob = await response.blob();
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
}
} catch (err) {
console.warn('Failed to read Electron clipboard image:', err);
return null;
}
}
// Use Tauri's invoke for actual Tauri builds on Linux
if (isTauri() && !isElectron() && isLinux()) {
try {
const { invoke } = await import('@tauri-apps/api/core');
const dataUrl = await invoke<string | null>('read_clipboard_image');
if (!dataUrl) return null;
// Convert data URL to File
const response = await fetch(dataUrl);
const blob = await response.blob();
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
} catch (err) {
console.warn('Failed to read Tauri clipboard image:', err);
return null;
}
}
return null;
};
/**
* Background Sync API for mobile platforms
* Starts a native Rust-based Matrix sync that runs even when the app is backgrounded
*/
export interface BackgroundSyncCredentials {
homeserverUrl: string;
userId: string;
accessToken: string;
deviceId: string;
}
/**
* Start background Matrix sync on mobile
* This runs the sync in native Rust code, allowing notifications even when the app is backgrounded
*/
export const startBackgroundSync = async (credentials: BackgroundSyncCredentials): Promise<void> => {
if (!isTauriMobile()) {
console.log('[BackgroundSync] Not on mobile, skipping');
return;
}
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('start_background_sync', {
homeserverUrl: credentials.homeserverUrl,
userId: credentials.userId,
accessToken: credentials.accessToken,
deviceId: credentials.deviceId,
});
console.log('[BackgroundSync] Started successfully');
} catch (err) {
console.error('[BackgroundSync] Failed to start:', err);
throw err;
}
};
/**
* Stop background Matrix sync on mobile
*/
export const stopBackgroundSync = async (): Promise<void> => {
if (!isTauriMobile()) return;
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('stop_background_sync');
console.log('[BackgroundSync] Stopped');
} catch (err) {
console.error('[BackgroundSync] Failed to stop:', err);
}
};
/**
* Get the current background sync state
*/
export const getBackgroundSyncState = async (): Promise<string> => {
if (!isTauriMobile()) return 'NotApplicable';
try {
const { invoke } = await import('@tauri-apps/api/core');
return await invoke<string>('get_background_sync_state');
} catch (err) {
console.error('[BackgroundSync] Failed to get state:', err);
return 'Error';
}
};

19
overlay/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"sourceMap": true,
"jsx": "react",
"target": "ES2016",
"module": "ES2020",
"allowJs": true,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "./src",
"skipLibCheck": true,
"lib": ["ES2016", "DOM"]
},
"exclude": ["node_modules", "dist"],
"include": ["src"]
}