add option to download audio/video file (#2253)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Thu, 6 Mar 2025 03:29:23 +0000 (14:29 +1100)
committerGitHub <noreply@github.com>
Thu, 6 Mar 2025 03:29:23 +0000 (14:29 +1100)
* add option to download audio file

* add button to download video

src/app/components/message/FileHeader.tsx
src/app/components/message/MsgTypeRenderers.tsx

index 947be90ed4c4dff4d62b15d29708dd2b4fefc276..0248862df3b44ca938f912eb2448c7d668f5ddb8 100644 (file)
@@ -1,22 +1,81 @@
-import { Badge, Box, Text, as, toRem } from 'folds';
-import React from 'react';
+import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
+import React, { ReactNode, useCallback } from 'react';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import FileSaver from 'file-saver';
 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';
 
 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;
+      const fileContent = encInfo
+        ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
+        : await downloadMedia(mediaUrl);
+
+      const fileURL = URL.createObjectURL(fileContent);
+      FileSaver.saveAs(fileURL, filename);
+      return fileURL;
+    }, [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, ...props }, ref) => (
+export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => (
   <Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
-    <Badge style={badgeStyles} variant="Secondary" radii="Pill">
-      <Text size="O400" truncate>
-        {mimeTypeToExt(mimeType)}
+    <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>
-    </Badge>
-    <Text size="T300" truncate>
-      {body}
-    </Text>
+    </Box>
+    {after}
   </Box>
 ));
index 287a5ca475cecc377618d41191714841b9cf4fd4..cea5220b810911ef3be89733d6ad0deb21653732 100644 (file)
@@ -28,7 +28,7 @@ import {
 import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
 import { parseGeoUri, scaleYDimension } from '../../utils/common';
 import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
-import { FileHeader } from './FileHeader';
+import { FileHeader, FileDownloadButton } from './FileHeader';
 
 export function MBadEncrypted() {
   return (
@@ -243,8 +243,24 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
 
   const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
 
+  const filename = content.filename ?? content.body ?? 'Video';
+
   return (
     <Attachment outlined={outlined}>
+      <AttachmentHeader>
+        <FileHeader
+          body={filename}
+          mimeType={safeMimeType}
+          after={
+            <FileDownloadButton
+              filename={filename}
+              url={mxcUrl}
+              mimeType={safeMimeType}
+              encInfo={content.file}
+            />
+          }
+        />
+      </AttachmentHeader>
       <AttachmentBox
         style={{
           height: toRem(height < 48 ? 48 : height),
@@ -286,10 +302,22 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
     return <BrokenContent />;
   }
 
+  const filename = content.filename ?? content.body ?? 'Audio';
   return (
     <Attachment outlined={outlined}>
       <AttachmentHeader>
-        <FileHeader body={content.filename ?? content.body ?? 'Audio'} mimeType={safeMimeType} />
+        <FileHeader
+          body={filename}
+          mimeType={safeMimeType}
+          after={
+            <FileDownloadButton
+              filename={filename}
+              url={mxcUrl}
+              mimeType={safeMimeType}
+              encInfo={content.file}
+            />
+          }
+        />
       </AttachmentHeader>
       <AttachmentBox>
         <AttachmentContent>