Fix Android media downloads and dismiss notifs when read elsewhere.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 5m5s

FileSaver fails in Capacitor WebView, so save via MediaStore; clear tray notifications when sync reports rooms as read on another device, and improve collapsed notification avatars with conversation shortcuts.
This commit is contained in:
2026-07-24 15:26:31 +10:00
parent a4d56e095e
commit 8cf31b929d
10 changed files with 1210 additions and 25 deletions

View File

@@ -0,0 +1,251 @@
/* 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

@@ -0,0 +1,107 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
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 './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadAndSaveMedia } from '../../utils/saveMedia';
import { getCurrentAccessToken } from '../../utils/auth';
export type ImageViewerProps = {
alt: string;
src: string;
requestClose: () => void;
};
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = async () => {
try {
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
} catch (error) {
console.warn('[ImageViewer] Failed to download media:', error);
try {
window.open(src, '_blank');
} catch {
// ignore
}
}
};
return (
<Box
className={classNames(css.ImageViewer, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.ImageViewerHeader} 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">
<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
grow="Yes"
className={css.ImageViewerContent}
justifyContent="Center"
alignItems="Center"
>
<img
className={css.ImageViewerImg}
style={{
cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
}}
src={src}
alt={alt}
draggable={false}
onMouseDown={onMouseDown}
/>
</Box>
</Box>
);
}
);

View File

@@ -0,0 +1,84 @@
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

@@ -0,0 +1,303 @@
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

@@ -0,0 +1,74 @@
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

@@ -425,16 +425,30 @@ function MessageNotifications() {
const roomToUnread = useAtomValue(roomToUnreadAtom);
const previousUnreadRoomsRef = useRef<Set<string>>(new Set());
const previousUnreadTotalsRef = useRef<Map<string, number>>(new Map());
// Dismiss OS notifications when a room's unread is cleared (local read or receipt).
// Dismiss OS notifications when a room's unread is cleared
// (local mark-as-read, or our receipt syncing from another device).
useEffect(() => {
const currentRooms = new Set(roomToUnread.keys());
const nextTotals = new Map<string, number>();
roomToUnread.forEach((unread, roomId) => {
nextTotals.set(roomId, unread.total);
const prevTotal = previousUnreadTotalsRef.current.get(roomId);
if (prevTotal !== undefined && prevTotal > 0 && unread.total === 0) {
void clearNotificationsForRoom(roomId);
}
});
for (const roomId of previousUnreadRoomsRef.current) {
if (!currentRooms.has(roomId)) {
void clearNotificationsForRoom(roomId);
}
}
previousUnreadRoomsRef.current = currentRooms;
previousUnreadTotalsRef.current = nextTotals;
}, [roomToUnread]);
const notify = useCallback(

View File

@@ -21,6 +21,18 @@ interface AndroidShareHandlerPlugin {
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
/** Clears the last pending share payload after it has been consumed. */
clearPendingShare(): Promise<void>;
/** Save a base64 file to gallery / Downloads (MediaStore), or share-sheet fallback. */
saveFile(options: {
filename: string;
mimeType?: string;
base64: string;
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
/** Open the system share sheet for a base64 file. */
shareFile(options: {
filename: string;
mimeType?: string;
base64: string;
}): Promise<{ shared: boolean }>;
addListener(
eventName: 'shareReceived',
listenerFunc: (payload: AndroidSharePayload) => void

View File

@@ -0,0 +1,103 @@
import FileSaver from 'file-saver';
import { Capacitor, registerPlugin } from '@capacitor/core';
import { downloadMedia } from './matrix';
import { getCurrentAccessToken } from './auth';
interface AndroidShareHandlerSavePlugin {
saveFile(options: {
filename: string;
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();
reader.onload = () => {
const result = reader.result;
if (typeof result !== 'string') {
reject(new Error('Failed to read blob as base64'));
return;
}
resolve(result.includes(',') ? result.split(',')[1] : result);
};
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
reader.readAsDataURL(blob);
});
const guessMimeFromName = (filename: string): string | undefined => {
const ext = filename.split('.').pop()?.toLowerCase();
if (!ext) return undefined;
const map: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
svg: 'image/svg+xml',
mp4: 'video/mp4',
webm: 'video/webm',
mkv: 'video/x-matroska',
mov: 'video/quicktime',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
wav: 'audio/wav',
pdf: 'application/pdf',
txt: 'text/plain',
};
return map[ext];
};
/**
* 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.
*/
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
if (isAndroidNative()) {
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);
};