Wire Android SSO Custom Tabs, carousel-safe swipes, and core saveMedia.
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m41s

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:
2026-08-09 20:49:05 +10:00
parent a1c6015772
commit bb768911ef
17 changed files with 289 additions and 776 deletions

View File

@@ -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')
} }

View File

@@ -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

View File

@@ -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

Submodule cinny updated: 898d217451...9887903f49

View File

@@ -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"
} }
} }

View File

@@ -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>
);
}
);

View File

@@ -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>
));

View File

@@ -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>
)
);

View File

@@ -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 => {

View File

@@ -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>
);
}
);

View 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;
};

View 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);
});
}

View File

@@ -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);
};

View File

@@ -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');
}; };

View File

@@ -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
View File

@@ -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",

View File

@@ -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",