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"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-status-bar')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
<data android:mimeType="*/*" />
|
||||
</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>
|
||||
|
||||
<provider
|
||||
|
||||
@@ -2,5 +2,14 @@
|
||||
include ':capacitor-android'
|
||||
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'
|
||||
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": {
|
||||
"@capacitor/app": "8.0.1",
|
||||
"@capacitor/browser": "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;
|
||||
};
|
||||
|
||||
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 => {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
|
||||
)
|
||||
);
|
||||
return Boolean(target.closest(IGNORE_BACK_SELECTOR)) || isInsideHorizontalScroller(target);
|
||||
};
|
||||
|
||||
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
|
||||
)
|
||||
);
|
||||
return Boolean(target.closest(IGNORE_REPLY_SELECTOR)) || isInsideHorizontalScroller(target);
|
||||
};
|
||||
|
||||
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 { downloadMedia } from './matrix';
|
||||
import { getCurrentAccessToken } from './auth';
|
||||
import { registerMediaSaver } from './saveMedia';
|
||||
|
||||
interface AndroidShareHandlerSavePlugin {
|
||||
saveFile(options: {
|
||||
@@ -9,19 +7,10 @@ interface AndroidShareHandlerSavePlugin {
|
||||
mimeType?: string;
|
||||
base64: 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 isAndroidNative = (): boolean =>
|
||||
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||
|
||||
/** Convert a Blob to raw base64 (no data: prefix). */
|
||||
const blobToBase64 = (blob: Blob): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
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
|
||||
* ShareHandler plugin (FileSaver / `<a download>` does not work in WebView).
|
||||
* Elsewhere falls back to FileSaver.
|
||||
* Wire Capacitor Android MediaStore saves into the core saveMedia helpers.
|
||||
* Call once at app startup from the mobile overlay entry.
|
||||
*/
|
||||
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
|
||||
if (isAndroidNative()) {
|
||||
export function registerAndroidMediaSaver(): void {
|
||||
if (!(Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android')) {
|
||||
return;
|
||||
}
|
||||
|
||||
registerMediaSaver(async (blob, filename) => {
|
||||
const base64 = await blobToBase64(blob);
|
||||
const mimeType = blob.type || guessMimeFromName(filename) || 'application/octet-stream';
|
||||
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);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,19 @@ export const openExternalUrl = async (url: string): Promise<void> => {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
if (isTauri() && !isElectron()) {
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,8 @@ import { trimTrailingSlash } from './app/utils/common';
|
||||
import App from './app/pages/App';
|
||||
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
|
||||
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 './app/i18n';
|
||||
@@ -28,6 +30,12 @@ applySafeAreaInsets();
|
||||
// Enable View Transitions API for smooth navigation
|
||||
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
|
||||
if ('serviceWorker' in navigator) {
|
||||
const swUrl =
|
||||
|
||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.121",
|
||||
"version": "4.11.150",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paarrot",
|
||||
"version": "4.11.121",
|
||||
"version": "4.11.150",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.3.0",
|
||||
"@capacitor/app": "8.0.1",
|
||||
"@capacitor/browser": "8.0.1",
|
||||
"@capacitor/cli": "8.3.0",
|
||||
"@capacitor/core": "8.3.0",
|
||||
"@capacitor/local-notifications": "8.0.2",
|
||||
@@ -33,6 +35,24 @@
|
||||
"@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": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz",
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.3.0",
|
||||
"@capacitor/app": "8.0.1",
|
||||
"@capacitor/browser": "8.0.1",
|
||||
"@capacitor/cli": "8.3.0",
|
||||
"@capacitor/core": "8.3.0",
|
||||
"@capacitor/local-notifications": "8.0.2",
|
||||
|
||||
Reference in New Issue
Block a user