Wire Android SSO Custom Tabs, carousel-safe swipes, and core saveMedia.
Open SSO in Capacitor Browser with paarrot://android return; stop swipe-to-reply stealing carousel scrolls; bump cinny for shared save helpers and register MediaStore from the overlay.
This commit is contained in:
@@ -9,7 +9,10 @@ android {
|
|||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-browser')
|
||||||
implementation project(':capacitor-local-notifications')
|
implementation project(':capacitor-local-notifications')
|
||||||
|
implementation project(':capacitor-status-bar')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,14 @@
|
|||||||
<data android:mimeType="*/*" />
|
<data android:mimeType="*/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- SSO / deep link return: paarrot://android/login/...?loginToken=... -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="paarrot" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|||||||
@@ -2,5 +2,14 @@
|
|||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-app'
|
||||||
|
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capacitor-browser'
|
||||||
|
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
||||||
|
|
||||||
include ':capacitor-local-notifications'
|
include ':capacitor-local-notifications'
|
||||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||||
|
|
||||||
|
include ':capacitor-status-bar'
|
||||||
|
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||||
|
|||||||
2
cinny
2
cinny
Submodule cinny updated: 898d217451...9887903f49
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor/app": "8.0.1",
|
||||||
|
"@capacitor/browser": "8.0.1",
|
||||||
"@capacitor/status-bar": "8.0.1"
|
"@capacitor/status-bar": "8.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
/* eslint-disable no-param-reassign */
|
|
||||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
|
||||||
import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
|
|
||||||
import { Icon, Icons } from '../icons';
|
|
||||||
import FocusTrap from 'focus-trap-react';
|
|
||||||
import { saveMediaBlob } from '../../utils/saveMedia';
|
|
||||||
import * as css from './PdfViewer.css';
|
|
||||||
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
|
||||||
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
|
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
|
||||||
|
|
||||||
export type PdfViewerProps = {
|
|
||||||
name: string;
|
|
||||||
src: string;
|
|
||||||
requestClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PdfViewer = as<'div', PdfViewerProps>(
|
|
||||||
({ className, name, src, requestClose, ...props }, ref) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
|
||||||
|
|
||||||
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
|
|
||||||
const [docState, loadPdfDocument] = usePdfDocumentLoader(
|
|
||||||
pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined,
|
|
||||||
src
|
|
||||||
);
|
|
||||||
const isLoading =
|
|
||||||
pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading;
|
|
||||||
const isError =
|
|
||||||
pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
|
|
||||||
const [pageNo, setPageNo] = useState(1);
|
|
||||||
const [jumpAnchor, setJumpAnchor] = useState<RectCords>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadPdfJS();
|
|
||||||
}, [loadPdfJS]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (pdfJSState.status === AsyncStatus.Success) {
|
|
||||||
loadPdfDocument();
|
|
||||||
}
|
|
||||||
}, [pdfJSState, loadPdfDocument]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (docState.status === AsyncStatus.Success) {
|
|
||||||
const doc = docState.data;
|
|
||||||
if (pageNo < 0 || pageNo > doc.numPages) return;
|
|
||||||
createPage(doc, pageNo, { scale: zoom }).then((canvas) => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
container.textContent = '';
|
|
||||||
container.append(canvas);
|
|
||||||
scrollRef.current?.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [docState, pageNo, zoom]);
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(src);
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch PDF');
|
|
||||||
await saveMediaBlob(await res.blob(), name);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[PdfViewer] Failed to download:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
if (docState.status !== AsyncStatus.Success) return;
|
|
||||||
const jumpInput = evt.currentTarget.jumpInput as HTMLInputElement;
|
|
||||||
if (!jumpInput) return;
|
|
||||||
const jumpTo = parseInt(jumpInput.value, 10);
|
|
||||||
setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
|
|
||||||
setJumpAnchor(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
|
||||||
setPageNo((n) => Math.max(n - 1, 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextPage = () => {
|
|
||||||
if (docState.status !== AsyncStatus.Success) return;
|
|
||||||
setPageNo((n) => Math.min(n + 1, docState.data.numPages));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenJump: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
||||||
setJumpAnchor(evt.currentTarget.getBoundingClientRect());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
|
|
||||||
<Header className={css.PdfViewerHeader} size="400">
|
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
|
||||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
|
||||||
</IconButton>
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box shrink="No" alignItems="Center" gap="200">
|
|
||||||
<IconButton
|
|
||||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
|
||||||
outlined={zoom < 1}
|
|
||||||
size="300"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={zoomOut}
|
|
||||||
aria-label="Zoom Out"
|
|
||||||
>
|
|
||||||
<Icon size="50" src={Icons.Minus} />
|
|
||||||
</IconButton>
|
|
||||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
|
||||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
|
||||||
</Chip>
|
|
||||||
<IconButton
|
|
||||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
|
||||||
outlined={zoom > 1}
|
|
||||||
size="300"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={zoomIn}
|
|
||||||
aria-label="Zoom In"
|
|
||||||
>
|
|
||||||
<Icon size="50" src={Icons.Plus} />
|
|
||||||
</IconButton>
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
onClick={handleDownload}
|
|
||||||
radii="300"
|
|
||||||
before={<Icon size="50" src={Icons.Download} />}
|
|
||||||
>
|
|
||||||
<Text size="B300">Download</Text>
|
|
||||||
</Chip>
|
|
||||||
</Box>
|
|
||||||
</Header>
|
|
||||||
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
|
||||||
{isLoading && <Spinner variant="Secondary" size="600" />}
|
|
||||||
{isError && (
|
|
||||||
<>
|
|
||||||
<Text>Failed to load PDF</Text>
|
|
||||||
<Button
|
|
||||||
variant="Critical"
|
|
||||||
fill="Soft"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
before={<Icon src={Icons.Warning} size="50" />}
|
|
||||||
onClick={loadPdfJS}
|
|
||||||
>
|
|
||||||
<Text size="B300">Retry</Text>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{docState.status === AsyncStatus.Success && (
|
|
||||||
<Scroll
|
|
||||||
ref={scrollRef}
|
|
||||||
size="300"
|
|
||||||
direction="Both"
|
|
||||||
variant="Surface"
|
|
||||||
visibility="Hover"
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<div className={css.PdfViewerContent} ref={containerRef} />
|
|
||||||
</Box>
|
|
||||||
</Scroll>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{docState.status === AsyncStatus.Success && (
|
|
||||||
<Header as="footer" className={css.PdfViewerFooter} size="400">
|
|
||||||
<Chip
|
|
||||||
variant="Secondary"
|
|
||||||
radii="300"
|
|
||||||
before={<Icon size="50" src={Icons.ChevronLeft} />}
|
|
||||||
onClick={handlePrevPage}
|
|
||||||
aria-disabled={pageNo <= 1}
|
|
||||||
>
|
|
||||||
<Text size="B300">Previous</Text>
|
|
||||||
</Chip>
|
|
||||||
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
|
|
||||||
<PopOut
|
|
||||||
anchor={jumpAnchor}
|
|
||||||
align="Center"
|
|
||||||
position="Top"
|
|
||||||
content={
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setJumpAnchor(undefined),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu variant="Surface">
|
|
||||||
<Box
|
|
||||||
as="form"
|
|
||||||
onSubmit={handleJumpSubmit}
|
|
||||||
style={{ padding: config.space.S200 }}
|
|
||||||
direction="Column"
|
|
||||||
gap="200"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name="jumpInput"
|
|
||||||
size="300"
|
|
||||||
variant="Background"
|
|
||||||
defaultValue={pageNo}
|
|
||||||
min={1}
|
|
||||||
max={docState.data.numPages}
|
|
||||||
step={1}
|
|
||||||
outlined
|
|
||||||
type="number"
|
|
||||||
radii="300"
|
|
||||||
aria-label="Page Number"
|
|
||||||
/>
|
|
||||||
<Button type="submit" size="300" variant="Primary" radii="300">
|
|
||||||
<Text size="B300">Jump To Page</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
</FocusTrap>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Chip
|
|
||||||
onClick={handleOpenJump}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
radii="300"
|
|
||||||
aria-pressed={jumpAnchor !== undefined}
|
|
||||||
>
|
|
||||||
<Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
|
|
||||||
</Chip>
|
|
||||||
</PopOut>
|
|
||||||
</Box>
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
radii="300"
|
|
||||||
after={<Icon size="50" src={Icons.ChevronRight} />}
|
|
||||||
onClick={handleNextPage}
|
|
||||||
aria-disabled={pageNo >= docState.data.numPages}
|
|
||||||
>
|
|
||||||
<Text size="B300">Next</Text>
|
|
||||||
</Chip>
|
|
||||||
</Header>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds';
|
|
||||||
import { Icon, Icons } from '../icons';
|
|
||||||
import React, { ReactNode, useCallback } from 'react';
|
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
|
||||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|
||||||
import {
|
|
||||||
decryptFile,
|
|
||||||
downloadEncryptedMedia,
|
|
||||||
downloadMedia,
|
|
||||||
mxcUrlToHttp,
|
|
||||||
} from '../../utils/matrix';
|
|
||||||
import { getCurrentAccessToken } from '../../utils/auth';
|
|
||||||
import { saveMediaBlob } from '../../utils/saveMedia';
|
|
||||||
|
|
||||||
const badgeStyles = { maxWidth: toRem(100) };
|
|
||||||
|
|
||||||
type FileDownloadButtonProps = {
|
|
||||||
filename: string;
|
|
||||||
url: string;
|
|
||||||
mimeType: string;
|
|
||||||
encInfo?: EncryptedAttachmentInfo;
|
|
||||||
};
|
|
||||||
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
|
||||||
useCallback(async () => {
|
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const fileContent = encInfo
|
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
|
||||||
: await downloadMedia(mediaUrl, accessToken);
|
|
||||||
|
|
||||||
await saveMediaBlob(fileContent, filename);
|
|
||||||
return true;
|
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
|
|
||||||
);
|
|
||||||
|
|
||||||
const downloading = downloadState.status === AsyncStatus.Loading;
|
|
||||||
const hasError = downloadState.status === AsyncStatus.Error;
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
disabled={downloading}
|
|
||||||
onClick={download}
|
|
||||||
variant={hasError ? 'Critical' : 'SurfaceVariant'}
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
>
|
|
||||||
{downloading ? (
|
|
||||||
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
|
||||||
) : (
|
|
||||||
<Icon size="100" src={Icons.Download} />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FileHeaderProps = {
|
|
||||||
body: string;
|
|
||||||
mimeType: string;
|
|
||||||
after?: ReactNode;
|
|
||||||
};
|
|
||||||
export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => (
|
|
||||||
<Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
|
|
||||||
<Box shrink="No">
|
|
||||||
<Badge style={badgeStyles} variant="Secondary" radii="Pill">
|
|
||||||
<Text size="O400" truncate>
|
|
||||||
{mimeTypeToExt(mimeType)}
|
|
||||||
</Text>
|
|
||||||
</Badge>
|
|
||||||
</Box>
|
|
||||||
<Box grow="Yes">
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{body}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
{after}
|
|
||||||
</Box>
|
|
||||||
));
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
import React, { ReactNode, useCallback, useState } from 'react';
|
|
||||||
import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
|
||||||
import { Icon, Icons } from '../../icons';
|
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
|
||||||
import { saveMediaBlob } from '../../../utils/saveMedia';
|
|
||||||
import FocusTrap from 'focus-trap-react';
|
|
||||||
import { IFileInfo } from '../../../../types/matrix/common';
|
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
||||||
import { bytesToSize } from '../../../utils/common';
|
|
||||||
import {
|
|
||||||
READABLE_EXT_TO_MIME_TYPE,
|
|
||||||
READABLE_TEXT_MIME_TYPES,
|
|
||||||
getFileNameExt,
|
|
||||||
mimeTypeToExt,
|
|
||||||
} from '../../../utils/mimeTypes';
|
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
|
||||||
import {
|
|
||||||
decryptFile,
|
|
||||||
downloadEncryptedMedia,
|
|
||||||
downloadMedia,
|
|
||||||
mxcUrlToHttp,
|
|
||||||
} from '../../../utils/matrix';
|
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|
||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
|
||||||
|
|
||||||
const renderErrorButton = (retry: () => void, text: string) => (
|
|
||||||
<TooltipProvider
|
|
||||||
tooltip={
|
|
||||||
<Tooltip variant="Critical">
|
|
||||||
<Text>Failed to load file!</Text>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
position="Top"
|
|
||||||
align="Center"
|
|
||||||
>
|
|
||||||
{(triggerRef) => (
|
|
||||||
<Button
|
|
||||||
ref={triggerRef}
|
|
||||||
size="400"
|
|
||||||
variant="Critical"
|
|
||||||
fill="Soft"
|
|
||||||
outlined
|
|
||||||
radii="300"
|
|
||||||
onClick={retry}
|
|
||||||
before={<Icon size="100" src={Icons.Warning} filled />}
|
|
||||||
>
|
|
||||||
<Text size="B400" truncate>
|
|
||||||
{text}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
type RenderTextViewerProps = {
|
|
||||||
name: string;
|
|
||||||
text: string;
|
|
||||||
langName: string;
|
|
||||||
requestClose: () => void;
|
|
||||||
};
|
|
||||||
type ReadTextFileProps = {
|
|
||||||
body: string;
|
|
||||||
mimeType: string;
|
|
||||||
url: string;
|
|
||||||
encInfo?: EncryptedAttachmentInfo;
|
|
||||||
renderViewer: (props: RenderTextViewerProps) => ReactNode;
|
|
||||||
};
|
|
||||||
export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const [textViewer, setTextViewer] = useState(false);
|
|
||||||
|
|
||||||
const [textState, loadText] = useAsyncCallback(
|
|
||||||
useCallback(async () => {
|
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const fileContent = encInfo
|
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
|
||||||
: await downloadMedia(mediaUrl, accessToken);
|
|
||||||
|
|
||||||
const text = fileContent.text();
|
|
||||||
setTextViewer(true);
|
|
||||||
return text;
|
|
||||||
}, [mx, useAuthentication, mimeType, encInfo, url])
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{textState.status === AsyncStatus.Success && (
|
|
||||||
<Overlay open={textViewer} backdrop={<OverlayBackdrop />}>
|
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setTextViewer(false),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Modal
|
|
||||||
className={ModalWide}
|
|
||||||
size="500"
|
|
||||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
|
||||||
>
|
|
||||||
{renderViewer({
|
|
||||||
name: body,
|
|
||||||
text: textState.data,
|
|
||||||
langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
|
|
||||||
? mimeTypeToExt(mimeType)
|
|
||||||
: mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
|
|
||||||
requestClose: () => setTextViewer(false),
|
|
||||||
})}
|
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
)}
|
|
||||||
{textState.status === AsyncStatus.Error ? (
|
|
||||||
renderErrorButton(loadText, 'Open File')
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="Secondary"
|
|
||||||
fill="Solid"
|
|
||||||
radii="300"
|
|
||||||
size="400"
|
|
||||||
onClick={() =>
|
|
||||||
textState.status === AsyncStatus.Success ? setTextViewer(true) : loadText()
|
|
||||||
}
|
|
||||||
disabled={textState.status === AsyncStatus.Loading}
|
|
||||||
before={
|
|
||||||
textState.status === AsyncStatus.Loading ? (
|
|
||||||
<Spinner fill="Solid" size="100" variant="Secondary" />
|
|
||||||
) : (
|
|
||||||
<Icon size="100" src={Icons.ArrowRight} filled />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text size="B400" truncate>
|
|
||||||
Open File
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type RenderPdfViewerProps = {
|
|
||||||
name: string;
|
|
||||||
src: string;
|
|
||||||
requestClose: () => void;
|
|
||||||
};
|
|
||||||
export type ReadPdfFileProps = {
|
|
||||||
body: string;
|
|
||||||
mimeType: string;
|
|
||||||
url: string;
|
|
||||||
encInfo?: EncryptedAttachmentInfo;
|
|
||||||
renderViewer: (props: RenderPdfViewerProps) => ReactNode;
|
|
||||||
};
|
|
||||||
export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const [pdfViewer, setPdfViewer] = useState(false);
|
|
||||||
|
|
||||||
const [pdfState, loadPdf] = useAsyncCallback(
|
|
||||||
useCallback(async () => {
|
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const fileContent = encInfo
|
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
|
||||||
: await downloadMedia(mediaUrl, accessToken);
|
|
||||||
setPdfViewer(true);
|
|
||||||
return URL.createObjectURL(fileContent);
|
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{pdfState.status === AsyncStatus.Success && (
|
|
||||||
<Overlay open={pdfViewer} backdrop={<OverlayBackdrop />}>
|
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setPdfViewer(false),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Modal
|
|
||||||
className={ModalWide}
|
|
||||||
size="500"
|
|
||||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
|
||||||
>
|
|
||||||
{renderViewer({
|
|
||||||
name: body,
|
|
||||||
src: pdfState.data,
|
|
||||||
requestClose: () => setPdfViewer(false),
|
|
||||||
})}
|
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
)}
|
|
||||||
{pdfState.status === AsyncStatus.Error ? (
|
|
||||||
renderErrorButton(loadPdf, 'Open PDF')
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="Secondary"
|
|
||||||
fill="Solid"
|
|
||||||
radii="300"
|
|
||||||
size="400"
|
|
||||||
onClick={() => (pdfState.status === AsyncStatus.Success ? setPdfViewer(true) : loadPdf())}
|
|
||||||
disabled={pdfState.status === AsyncStatus.Loading}
|
|
||||||
before={
|
|
||||||
pdfState.status === AsyncStatus.Loading ? (
|
|
||||||
<Spinner fill="Solid" size="100" variant="Secondary" />
|
|
||||||
) : (
|
|
||||||
<Icon size="100" src={Icons.ArrowRight} filled />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text size="B400" truncate>
|
|
||||||
Open PDF
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DownloadFileProps = {
|
|
||||||
body: string;
|
|
||||||
mimeType: string;
|
|
||||||
url: string;
|
|
||||||
info: IFileInfo;
|
|
||||||
encInfo?: EncryptedAttachmentInfo;
|
|
||||||
};
|
|
||||||
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
|
||||||
useCallback(async () => {
|
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const fileContent = encInfo
|
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
|
||||||
: await downloadMedia(mediaUrl, accessToken);
|
|
||||||
|
|
||||||
await saveMediaBlob(fileContent, body);
|
|
||||||
return fileContent;
|
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, body])
|
|
||||||
);
|
|
||||||
|
|
||||||
return downloadState.status === AsyncStatus.Error ? (
|
|
||||||
renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="Secondary"
|
|
||||||
fill="Soft"
|
|
||||||
radii="300"
|
|
||||||
size="400"
|
|
||||||
onClick={() =>
|
|
||||||
downloadState.status === AsyncStatus.Success
|
|
||||||
? saveMediaBlob(downloadState.data, body)
|
|
||||||
: download()
|
|
||||||
}
|
|
||||||
disabled={downloadState.status === AsyncStatus.Loading}
|
|
||||||
before={
|
|
||||||
downloadState.status === AsyncStatus.Loading ? (
|
|
||||||
<Spinner fill="Soft" size="100" variant="Secondary" />
|
|
||||||
) : (
|
|
||||||
<Icon size="100" src={Icons.Download} filled />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text size="B400" truncate>{`Download (${bytesToSize(info.size ?? 0)})`}</Text>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileContentProps = {
|
|
||||||
body: string;
|
|
||||||
mimeType: string;
|
|
||||||
renderAsTextFile: () => ReactNode;
|
|
||||||
renderAsPdfFile: () => ReactNode;
|
|
||||||
};
|
|
||||||
export const FileContent = as<'div', FileContentProps>(
|
|
||||||
({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => (
|
|
||||||
<Box direction="Column" gap="300" {...props} ref={ref}>
|
|
||||||
{(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
|
|
||||||
READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) &&
|
|
||||||
renderAsTextFile()}
|
|
||||||
{mimeType === 'application/pdf' && renderAsPdfFile()}
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
@@ -45,22 +45,42 @@ type MobileSwipeGestureHostProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const IGNORE_BACK_SELECTOR =
|
||||||
|
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]';
|
||||||
|
|
||||||
|
const IGNORE_REPLY_SELECTOR =
|
||||||
|
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]';
|
||||||
|
|
||||||
|
/** True when the touch starts on (or inside) a horizontally scrollable region like an image carousel. */
|
||||||
|
const isInsideHorizontalScroller = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
|
||||||
|
let el: Element | null = target;
|
||||||
|
while (el && el !== document.documentElement) {
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
const overflowX = style.overflowX;
|
||||||
|
if (
|
||||||
|
(overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'overlay') &&
|
||||||
|
el.scrollWidth > el.clientWidth + 1
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => {
|
const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => {
|
||||||
if (!(target instanceof Element)) return true;
|
if (!(target instanceof Element)) return true;
|
||||||
return Boolean(
|
return Boolean(target.closest(IGNORE_BACK_SELECTOR)) || isInsideHorizontalScroller(target);
|
||||||
target.closest(
|
|
||||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
|
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
|
||||||
if (!(target instanceof Element)) return true;
|
if (!(target instanceof Element)) return true;
|
||||||
return Boolean(
|
return Boolean(target.closest(IGNORE_REPLY_SELECTOR)) || isInsideHorizontalScroller(target);
|
||||||
target.closest(
|
|
||||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
|
||||||
import { Icon, Icons } from '../icons';
|
|
||||||
import * as css from './VideoViewer.css';
|
|
||||||
import { downloadAndSaveMedia } from '../../utils/saveMedia';
|
|
||||||
import { getCurrentAccessToken } from '../../utils/auth';
|
|
||||||
|
|
||||||
export type VideoViewerProps = {
|
|
||||||
alt: string;
|
|
||||||
src: string;
|
|
||||||
requestClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VideoViewer = as<'div', VideoViewerProps>(
|
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
|
||||||
const handleDownload = async () => {
|
|
||||||
try {
|
|
||||||
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[VideoViewer] Failed to download media:', error);
|
|
||||||
try {
|
|
||||||
window.open(src, '_blank');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
className={classNames(css.VideoViewer, className)}
|
|
||||||
direction="Column"
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<Header className={css.VideoViewerHeader} size="400">
|
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
|
||||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
|
||||||
</IconButton>
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{alt}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box shrink="No" alignItems="Center" gap="200">
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
onClick={handleDownload}
|
|
||||||
radii="300"
|
|
||||||
before={<Icon size="50" src={Icons.Download} />}
|
|
||||||
>
|
|
||||||
<Text size="B300">Download</Text>
|
|
||||||
</Chip>
|
|
||||||
</Box>
|
|
||||||
</Header>
|
|
||||||
<Box
|
|
||||||
grow="Yes"
|
|
||||||
className={css.VideoViewerContent}
|
|
||||||
justifyContent="Center"
|
|
||||||
alignItems="Center"
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
className={css.VideoViewerVideo}
|
|
||||||
src={src}
|
|
||||||
title={alt}
|
|
||||||
controls
|
|
||||||
autoPlay
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
49
overlay/src/app/hooks/usePathWithOrigin.ts
Normal file
49
overlay/src/app/hooks/usePathWithOrigin.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useClientConfig } from './useClientConfig';
|
||||||
|
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
|
||||||
|
import { isCapacitorNative, isElectron } from '../utils/tauri';
|
||||||
|
|
||||||
|
/** Authority shown on Matrix SSO consent ("Continue to paarrot://desktop"). */
|
||||||
|
export const ELECTRON_PROTOCOL_HOST = 'desktop';
|
||||||
|
|
||||||
|
/** Authority shown on Matrix SSO consent for the Android Capacitor app. */
|
||||||
|
export const ANDROID_PROTOCOL_HOST = 'android';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an absolute URL for the given in-app path.
|
||||||
|
*
|
||||||
|
* In Electron / Capacitor, SSO (and other browser handoffs) must return via
|
||||||
|
* paarrot://{host}/... — a named host so consent UI identifies the app, and a
|
||||||
|
* real pathname (not a hash) because browsers strip fragments when launching
|
||||||
|
* custom protocols.
|
||||||
|
*/
|
||||||
|
export const usePathWithOrigin = (path: string): string => {
|
||||||
|
const { hashRouter } = useClientConfig();
|
||||||
|
const { origin } = window.location;
|
||||||
|
|
||||||
|
const pathWithOrigin = useMemo(() => {
|
||||||
|
if (isElectron() || isCapacitorNative()) {
|
||||||
|
const host = isElectron() ? ELECTRON_PROTOCOL_HOST : ANDROID_PROTOCOL_HOST;
|
||||||
|
const basename = trimSlash(hashRouter?.basename ?? '');
|
||||||
|
const routeParts = [basename, trimLeadingSlash(path)].filter(Boolean);
|
||||||
|
const route = `/${routeParts.join('/')}`.replace(/\/{2,}/g, '/');
|
||||||
|
return `paarrot://${host}${route}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: string = trimSlash(origin);
|
||||||
|
|
||||||
|
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
|
||||||
|
url = trimTrailingSlash(url);
|
||||||
|
|
||||||
|
if (hashRouter?.enabled) {
|
||||||
|
url += `/#/${trimSlash(hashRouter.basename ?? '')}`;
|
||||||
|
url = trimTrailingSlash(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
url += `/${trimLeadingSlash(path)}`;
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}, [path, hashRouter, origin]);
|
||||||
|
|
||||||
|
return pathWithOrigin;
|
||||||
|
};
|
||||||
127
overlay/src/app/utils/androidDeepLink.ts
Normal file
127
overlay/src/app/utils/androidDeepLink.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { App } from '@capacitor/app';
|
||||||
|
import { Browser } from '@capacitor/browser';
|
||||||
|
import { isCapacitorNative } from './tauri';
|
||||||
|
|
||||||
|
const PROTOCOL_SCHEME = 'paarrot';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a paarrot:// deep link into an in-app hash route.
|
||||||
|
* Supports:
|
||||||
|
* - Hash routes: paarrot://anything/#/login/server/
|
||||||
|
* - Path routes (SSO-safe): paarrot://android/login/server/?loginToken=...
|
||||||
|
* Preserves SSO loginToken from the URL query (Synapse inserts it before `#`).
|
||||||
|
*/
|
||||||
|
export function deepLinkToHashRoute(deepLink: string): string | null {
|
||||||
|
if (!deepLink) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deepLink.toLowerCase().startsWith(`${PROTOCOL_SCHEME}://`)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hashRoute: string | null = null;
|
||||||
|
let loginToken: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(deepLink);
|
||||||
|
loginToken = parsed.searchParams.get('loginToken');
|
||||||
|
|
||||||
|
if (parsed.hash && parsed.hash.length > 1) {
|
||||||
|
const hashValue = parsed.hash.slice(1);
|
||||||
|
hashRoute = hashValue.startsWith('/') ? hashValue : `/${hashValue}`;
|
||||||
|
} else if (parsed.pathname && parsed.pathname !== '/') {
|
||||||
|
// paarrot://android/login/... → /login/...
|
||||||
|
hashRoute = parsed.pathname;
|
||||||
|
if (parsed.search && parsed.search.length > 1) {
|
||||||
|
const params = new URLSearchParams(parsed.search);
|
||||||
|
params.delete('loginToken');
|
||||||
|
const rest = params.toString();
|
||||||
|
if (rest) {
|
||||||
|
hashRoute += `?${rest}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const hashIndex = deepLink.indexOf('#');
|
||||||
|
if (hashIndex !== -1) {
|
||||||
|
const hashValue = deepLink.slice(hashIndex + 1);
|
||||||
|
if (hashValue) {
|
||||||
|
hashRoute = hashValue.startsWith('/') ? hashValue : `/${hashValue}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = deepLink.match(/[?&]loginToken=([^&#]+)/i);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
loginToken = decodeURIComponent(match[1]);
|
||||||
|
} catch {
|
||||||
|
loginToken = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginToken) {
|
||||||
|
if (!hashRoute) {
|
||||||
|
hashRoute = '/';
|
||||||
|
}
|
||||||
|
const queryIndex = hashRoute.indexOf('?');
|
||||||
|
const pathPart = queryIndex === -1 ? hashRoute : hashRoute.slice(0, queryIndex);
|
||||||
|
const params = new URLSearchParams(queryIndex === -1 ? '' : hashRoute.slice(queryIndex + 1));
|
||||||
|
if (!params.has('loginToken')) {
|
||||||
|
params.set('loginToken', loginToken);
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
hashRoute = query ? `${pathPart}?${query}` : pathPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDeepLink(deepLink: string): Promise<void> {
|
||||||
|
const hashRoute = deepLinkToHashRoute(deepLink);
|
||||||
|
if (!hashRoute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Browser.close();
|
||||||
|
} catch {
|
||||||
|
// Browser may already be closed or never opened.
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextHash = hashRoute.startsWith('/') ? `#${hashRoute}` : `#/${hashRoute}`;
|
||||||
|
if (window.location.hash === nextHash) {
|
||||||
|
// Force TokenLogin to re-run if we land on the same route again.
|
||||||
|
window.location.hash = '#/';
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.hash = nextHash;
|
||||||
|
}, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.hash = nextHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for paarrot:// SSO returns (and cold-start launch URLs) on Capacitor Android.
|
||||||
|
*/
|
||||||
|
export async function initAndroidDeepLinks(): Promise<void> {
|
||||||
|
if (!isCapacitorNative()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const launch = await App.getLaunchUrl();
|
||||||
|
if (launch?.url) {
|
||||||
|
await applyDeepLink(launch.url);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[androidDeepLink] Failed to read launch URL:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await App.addListener('appUrlOpen', ({ url }) => {
|
||||||
|
if (!url) return;
|
||||||
|
void applyDeepLink(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import FileSaver from 'file-saver';
|
|
||||||
import { Capacitor, registerPlugin } from '@capacitor/core';
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
import { downloadMedia } from './matrix';
|
import { registerMediaSaver } from './saveMedia';
|
||||||
import { getCurrentAccessToken } from './auth';
|
|
||||||
|
|
||||||
interface AndroidShareHandlerSavePlugin {
|
interface AndroidShareHandlerSavePlugin {
|
||||||
saveFile(options: {
|
saveFile(options: {
|
||||||
@@ -9,19 +7,10 @@ interface AndroidShareHandlerSavePlugin {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
base64: string;
|
base64: string;
|
||||||
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
|
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
|
||||||
shareFile(options: {
|
|
||||||
filename: string;
|
|
||||||
mimeType?: string;
|
|
||||||
base64: string;
|
|
||||||
}): Promise<{ shared: boolean }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AndroidShareHandler = registerPlugin<AndroidShareHandlerSavePlugin>('AndroidShareHandler');
|
const AndroidShareHandler = registerPlugin<AndroidShareHandlerSavePlugin>('AndroidShareHandler');
|
||||||
|
|
||||||
const isAndroidNative = (): boolean =>
|
|
||||||
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
|
||||||
|
|
||||||
/** Convert a Blob to raw base64 (no data: prefix). */
|
|
||||||
const blobToBase64 = (blob: Blob): Promise<string> =>
|
const blobToBase64 = (blob: Blob): Promise<string> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -62,42 +51,17 @@ const guessMimeFromName = (filename: string): string | undefined => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist a Blob to disk. On Capacitor Android, uses MediaStore via the native
|
* Wire Capacitor Android MediaStore saves into the core saveMedia helpers.
|
||||||
* ShareHandler plugin (FileSaver / `<a download>` does not work in WebView).
|
* Call once at app startup from the mobile overlay entry.
|
||||||
* Elsewhere falls back to FileSaver.
|
|
||||||
*/
|
*/
|
||||||
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
|
export function registerAndroidMediaSaver(): void {
|
||||||
if (isAndroidNative()) {
|
if (!(Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerMediaSaver(async (blob, filename) => {
|
||||||
const base64 = await blobToBase64(blob);
|
const base64 = await blobToBase64(blob);
|
||||||
const mimeType = blob.type || guessMimeFromName(filename) || 'application/octet-stream';
|
const mimeType = blob.type || guessMimeFromName(filename) || 'application/octet-stream';
|
||||||
await AndroidShareHandler.saveFile({ filename, mimeType, base64 });
|
await AndroidShareHandler.saveFile({ filename, mimeType, base64 });
|
||||||
return;
|
});
|
||||||
}
|
}
|
||||||
FileSaver.saveAs(blob, filename);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch media (auth download or blob:/http URL) and save it via [saveMediaBlob].
|
|
||||||
*/
|
|
||||||
export const downloadAndSaveMedia = async (
|
|
||||||
src: string,
|
|
||||||
filename: string,
|
|
||||||
accessToken?: string | null
|
|
||||||
): Promise<void> => {
|
|
||||||
let blob: Blob;
|
|
||||||
try {
|
|
||||||
if (src.startsWith('blob:') || src.startsWith('data:')) {
|
|
||||||
const res = await fetch(src);
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch ${src}`);
|
|
||||||
blob = await res.blob();
|
|
||||||
} else {
|
|
||||||
blob = await downloadMedia(src, accessToken ?? getCurrentAccessToken());
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[saveMedia] downloadMedia failed, trying fetch fallback:', error);
|
|
||||||
const res = await fetch(src);
|
|
||||||
if (!res.ok) throw error;
|
|
||||||
blob = await res.blob();
|
|
||||||
}
|
|
||||||
await saveMediaBlob(blob, filename);
|
|
||||||
};
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
export const openExternalUrl = async (url: string): Promise<void> => {
|
export const openExternalUrl = async (url: string): Promise<void> => {
|
||||||
console.log('[openExternalUrl] called with:', url);
|
console.log('[openExternalUrl] called with:', url);
|
||||||
|
|
||||||
// Use Electron's shell.openExternal if in Electron
|
// Use Electron's shell.openExternal if in Electron
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +18,20 @@ export const openExternalUrl = async (url: string): Promise<void> => {
|
|||||||
console.warn('[openExternalUrl] Electron shell.openExternal failed:', err);
|
console.warn('[openExternalUrl] Electron shell.openExternal failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capacitor: SSO (and other handoffs) need Chrome Custom Tabs / in-app Browser
|
||||||
|
// so paarrot:// redirects return into the app instead of a blank external tab.
|
||||||
|
if (isCapacitorNative()) {
|
||||||
|
try {
|
||||||
|
const { Browser } = await import('@capacitor/browser');
|
||||||
|
await Browser.open({ url, presentationStyle: 'popover' });
|
||||||
|
console.log('[openExternalUrl] Capacitor Browser.open succeeded');
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[openExternalUrl] Capacitor Browser.open failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use Tauri for actual Tauri builds
|
// Use Tauri for actual Tauri builds
|
||||||
if (isTauri() && !isElectron()) {
|
if (isTauri() && !isElectron()) {
|
||||||
try {
|
try {
|
||||||
@@ -30,7 +43,7 @@ export const openExternalUrl = async (url: string): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
} catch (pluginErr) {
|
} catch (pluginErr) {
|
||||||
console.warn('[openExternalUrl] Tauri opener plugin failed:', pluginErr);
|
console.warn('[openExternalUrl] Tauri opener plugin failed:', pluginErr);
|
||||||
|
|
||||||
// Fallback: try the custom command (useful if plugin fails due to ACL)
|
// Fallback: try the custom command (useful if plugin fails due to ACL)
|
||||||
try {
|
try {
|
||||||
console.log('[openExternalUrl] trying Tauri invoke command fallback...');
|
console.log('[openExternalUrl] trying Tauri invoke command fallback...');
|
||||||
@@ -43,7 +56,7 @@ export const openExternalUrl = async (url: string): Promise<void> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[openExternalUrl] falling back to window.open');
|
console.log('[openExternalUrl] falling back to window.open');
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { trimTrailingSlash } from './app/utils/common';
|
|||||||
import App from './app/pages/App';
|
import App from './app/pages/App';
|
||||||
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
|
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
|
||||||
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
|
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
|
||||||
|
import { initAndroidDeepLinks } from './app/utils/androidDeepLink';
|
||||||
|
import { registerAndroidMediaSaver } from './app/utils/registerAndroidMediaSaver';
|
||||||
|
|
||||||
// import i18n (needs to be bundled ;))
|
// import i18n (needs to be bundled ;))
|
||||||
import './app/i18n';
|
import './app/i18n';
|
||||||
@@ -28,6 +30,12 @@ applySafeAreaInsets();
|
|||||||
// Enable View Transitions API for smooth navigation
|
// Enable View Transitions API for smooth navigation
|
||||||
enableViewTransitionsForNavigation();
|
enableViewTransitionsForNavigation();
|
||||||
|
|
||||||
|
// Capacitor: paarrot:// SSO return closes Custom Tabs and lands on loginToken
|
||||||
|
void initAndroidDeepLinks();
|
||||||
|
|
||||||
|
// Capacitor Android: MediaStore saves for images / videos / files
|
||||||
|
registerAndroidMediaSaver();
|
||||||
|
|
||||||
// Register Service Worker
|
// Register Service Worker
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
const swUrl =
|
const swUrl =
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.121",
|
"version": "4.11.150",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.121",
|
"version": "4.11.150",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.3.0",
|
"@capacitor/android": "8.3.0",
|
||||||
|
"@capacitor/app": "8.0.1",
|
||||||
|
"@capacitor/browser": "8.0.1",
|
||||||
"@capacitor/cli": "8.3.0",
|
"@capacitor/cli": "8.3.0",
|
||||||
"@capacitor/core": "8.3.0",
|
"@capacitor/core": "8.3.0",
|
||||||
"@capacitor/local-notifications": "8.0.2",
|
"@capacitor/local-notifications": "8.0.2",
|
||||||
@@ -33,6 +35,24 @@
|
|||||||
"@capacitor/core": "^8.3.0"
|
"@capacitor/core": "^8.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/app": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-yeG3yyA0ETKqvgqexwHMBlmVOF13A1hRXzv/km0Ptv5TrNIZvZJK4MTI3uiqvnbHrzoJGP5DwWAjEXEfi90v3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@capacitor/browser": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-cCEYK9DGJvamfCKOh95QP+VafApblySDR/eodOcl1Qt+d/Y+NNPzc8NYm/dztQlRf3aMLJ7ne1HrRCfu2UOMnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@capacitor/cli": {
|
"node_modules/@capacitor/cli": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.3.0",
|
"@capacitor/android": "8.3.0",
|
||||||
|
"@capacitor/app": "8.0.1",
|
||||||
|
"@capacitor/browser": "8.0.1",
|
||||||
"@capacitor/cli": "8.3.0",
|
"@capacitor/cli": "8.3.0",
|
||||||
"@capacitor/core": "8.3.0",
|
"@capacitor/core": "8.3.0",
|
||||||
"@capacitor/local-notifications": "8.0.2",
|
"@capacitor/local-notifications": "8.0.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user