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:
421
overlay/src/app/utils/matrix.ts
Normal file
421
overlay/src/app/utils/matrix.ts
Normal 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);
|
||||
};
|
||||
625
overlay/src/app/utils/tauri.ts
Normal file
625
overlay/src/app/utils/tauri.ts
Normal 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';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user