diff --git a/.changeset/sweet-jeans-smile.md b/.changeset/sweet-jeans-smile.md
new file mode 100644
index 00000000..13b2ee08
--- /dev/null
+++ b/.changeset/sweet-jeans-smile.md
@@ -0,0 +1,5 @@
+---
+'@matrix-widget-toolkit/api': minor
+---
+
+Add support for the download_file widget action
diff --git a/example-widget-mui/README.md b/example-widget-mui/README.md
index b347f819..9c4dea09 100644
--- a/example-widget-mui/README.md
+++ b/example-widget-mui/README.md
@@ -17,7 +17,7 @@ The widget demonstrates:
- How to read related events ([`Event Relations`](./src/RelationsPage/RelationsPage.tsx)).
- How to search the User Directory ([`User Directory and Invitations`](./src/InvitationsPage/InvitationsPage.tsx)).
- How to use the UI components to match the style of Element ([`Theme`](./src/ThemePage/ThemePage.tsx)).
-- How to upload files to the media repository ([`Upload File`](./src/UploadImagePage/UploadImagePage.tsx)).
+- How to upload and download files to the media repository ([`Up- and download image`](./src/ImagePage/ImagePage.tsx)).
## Demo
diff --git a/example-widget-mui/package.json b/example-widget-mui/package.json
index 25a7ca34..527c5722 100644
--- a/example-widget-mui/package.json
+++ b/example-widget-mui/package.json
@@ -17,7 +17,7 @@
"i18next-http-backend": "^2.5.2",
"joi": "^17.13.3",
"lodash": "^4.17.21",
- "matrix-widget-api": "^1.7.0",
+ "matrix-widget-api": "^1.9.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^15.0.0",
diff --git a/example-widget-mui/src/App/App.tsx b/example-widget-mui/src/App/App.tsx
index 89fed014..e9ea82d0 100644
--- a/example-widget-mui/src/App/App.tsx
+++ b/example-widget-mui/src/App/App.tsx
@@ -24,6 +24,7 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { AllRoomsPage } from '../AllRoomsPage';
import { DicePage } from '../DicePage';
import { IdentityPage } from '../IdentityPage';
+import { ImagePage } from '../ImagePage';
import { InvitationsPage } from '../InvitationsPage';
import { ModalDialog, ModalPage } from '../ModalPage';
import { NavigationPage } from '../NavigationPage';
@@ -31,7 +32,6 @@ import { PowerLevelsPage } from '../PowerLevelsPage';
import { RelationsPage } from '../RelationsPage';
import { RoomPage } from '../RoomPage';
import { ThemePage } from '../ThemePage';
-import { UploadImagePage } from '../UploadImagePage';
import { WelcomePage } from '../WelcomePage';
export function App({
@@ -65,7 +65,7 @@ export function App({
} />
} />
} />
- } />
+ } />
diff --git a/example-widget-mui/src/ImagePage/Image.tsx b/example-widget-mui/src/ImagePage/Image.tsx
new file mode 100644
index 00000000..a3e5f8ea
--- /dev/null
+++ b/example-widget-mui/src/ImagePage/Image.tsx
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2024 Nordeck IT + Consulting GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useWidgetApi } from '@matrix-widget-toolkit/react';
+import React, { useCallback, useEffect, useState } from 'react';
+
+type ImageProps = {
+ alt?: string;
+ /**
+ * MXC URI of the image that should be shown
+ */
+ contentUrl: string;
+};
+
+/**
+ * Component that loads the image from the content repository and displays it.
+ */
+export const Image: React.FC = function ({
+ contentUrl,
+ ...imageProps
+}) {
+ const [dataUrl, setDataUrl] = useState();
+ const widgetApi = useWidgetApi();
+
+ const handleLoad = useCallback(() => {
+ if (dataUrl) {
+ URL.revokeObjectURL(dataUrl);
+ }
+ }, [dataUrl]);
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const result = await widgetApi.downloadFile(contentUrl);
+
+ if (!(result.file instanceof Blob)) {
+ throw new Error('Got non Blob file response');
+ }
+
+ const downloadedFileDataUrl = URL.createObjectURL(result.file);
+ setDataUrl(downloadedFileDataUrl);
+ } catch (error) {
+ console.log('Error downloading file', error);
+ }
+ })();
+ }, [contentUrl]);
+
+ if (dataUrl === undefined) {
+ return null;
+ }
+
+ return ;
+};
diff --git a/example-widget-mui/src/UploadImagePage/ImageListView.tsx b/example-widget-mui/src/ImagePage/ImageListView.tsx
similarity index 92%
rename from example-widget-mui/src/UploadImagePage/ImageListView.tsx
rename to example-widget-mui/src/ImagePage/ImageListView.tsx
index 0904c154..91a8e318 100644
--- a/example-widget-mui/src/UploadImagePage/ImageListView.tsx
+++ b/example-widget-mui/src/ImagePage/ImageListView.tsx
@@ -33,6 +33,7 @@ import {
UploadedImageEvent,
isValidUploadedImage,
} from '../events';
+import { Image } from './Image';
export const ImageListView = (): ReactElement => {
const widgetApi = useWidgetApi();
@@ -75,13 +76,9 @@ export const ImageListView = (): ReactElement => {
{imageNames.length > 0 &&
imageNames.map((image) => (
-
>;
@@ -37,6 +37,8 @@ afterEach(() => widgetApi.stop());
beforeEach(() => {
widgetApi = mockWidgetApi();
+ global.URL.createObjectURL = jest.fn().mockReturnValue('http://...');
+
wrapper = ({ children }: PropsWithChildren<{}>) => (
@@ -46,9 +48,13 @@ beforeEach(() => {
);
});
-describe('', () => {
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe('', () => {
it('should render without exploding', async () => {
- render(, { wrapper });
+ render(, { wrapper });
expect(
screen.getByRole('link', { name: /back to navigation/i }),
@@ -66,7 +72,7 @@ describe('', () => {
});
it('should have no accessibility violations', async () => {
- const { container } = render(, { wrapper });
+ const { container } = render(, { wrapper });
expect(
screen.getByRole('heading', { name: /upload file/i }),
@@ -83,7 +89,7 @@ describe('', () => {
});
it('should request the capabilities', async () => {
- render(, { wrapper });
+ render(, { wrapper });
expect(widgetApi.requestCapabilities).toHaveBeenCalledWith([
WidgetEventCapability.forRoomEvent(
@@ -95,6 +101,7 @@ describe('', () => {
ROOM_EVENT_UPLOADED_IMAGE,
),
WidgetApiFromWidgetAction.MSC4039UploadFileAction,
+ WidgetApiFromWidgetAction.MSC4039DownloadFileAction,
WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction,
]);
@@ -104,7 +111,7 @@ describe('', () => {
});
it('should say that no images are loaded yet', async () => {
- render(, { wrapper });
+ render(, { wrapper });
await expect(
screen.findByText(/no images uploaded to this room yet/i),
@@ -115,10 +122,10 @@ describe('', () => {
widgetApi.sendRoomEvent(ROOM_EVENT_UPLOADED_IMAGE, {
name: 'image.png',
size: 123,
- url: 'http://example.com/image.png',
+ url: 'mxc://...',
});
- render(, { wrapper });
+ render(, { wrapper });
await expect(
screen.findByRole('img', { name: /image.png/i }),
diff --git a/example-widget-mui/src/UploadImagePage/UploadImagePage.tsx b/example-widget-mui/src/ImagePage/ImagePage.tsx
similarity index 97%
rename from example-widget-mui/src/UploadImagePage/UploadImagePage.tsx
rename to example-widget-mui/src/ImagePage/ImagePage.tsx
index 19a5abad..371b122a 100644
--- a/example-widget-mui/src/UploadImagePage/UploadImagePage.tsx
+++ b/example-widget-mui/src/ImagePage/ImagePage.tsx
@@ -49,7 +49,7 @@ import { ROOM_EVENT_UPLOADED_IMAGE, UploadedImageEvent } from '../events';
/**
* A component that showcases how to upload image files and render them in a widget.
*/
-export const UploadImagePage = (): ReactElement => {
+export const ImagePage = (): ReactElement => {
const widgetApi = useWidgetApi();
const [errorDialogOpen, setErrorDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
@@ -71,6 +71,7 @@ export const UploadImagePage = (): ReactElement => {
const handleFileUpload = useCallback(() => {
const uploadImage = async () => {
if (selectedFile) {
+ setLoading(true);
if (!selectedFile.type.startsWith('image/')) {
setErrorMessage(
'Please select a valid image file. You can upload any image format that is supported by the browser.',
@@ -103,18 +104,17 @@ export const UploadImagePage = (): ReactElement => {
const responseUploadMedia = await widgetApi.uploadFile(selectedFile);
const url = responseUploadMedia.content_uri;
- setLoading(true);
await widgetApi.sendRoomEvent(
ROOM_EVENT_UPLOADED_IMAGE,
{ name: selectedFile.name, size: selectedFile.size, url },
);
- setLoading(false);
- setSelectedFile(null);
- return;
+ setSelectedFile(null);
} catch (error) {
setErrorMessage('An error occurred during file upload: ' + error);
setErrorDialogOpen(true);
+ } finally {
+ setLoading(false);
}
}
};
@@ -137,10 +137,11 @@ export const UploadImagePage = (): ReactElement => {
ROOM_EVENT_UPLOADED_IMAGE,
),
WidgetApiFromWidgetAction.MSC4039UploadFileAction,
+ WidgetApiFromWidgetAction.MSC4039DownloadFileAction,
WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction,
]}
>
- {/*
+ {/*
The StoreProvider is located here to keep the example small. Normal
applications would locate it outside of the router to establish a
single, global store.
diff --git a/example-widget-mui/src/UploadImagePage/index.ts b/example-widget-mui/src/ImagePage/index.ts
similarity index 92%
rename from example-widget-mui/src/UploadImagePage/index.ts
rename to example-widget-mui/src/ImagePage/index.ts
index 6b15fd18..4ce61f26 100644
--- a/example-widget-mui/src/UploadImagePage/index.ts
+++ b/example-widget-mui/src/ImagePage/index.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-export { UploadImagePage } from './UploadImagePage';
+export { ImagePage } from './ImagePage';
diff --git a/example-widget-mui/src/NavigationPage/NavigationPage.tsx b/example-widget-mui/src/NavigationPage/NavigationPage.tsx
index 3bf2875e..944f819a 100644
--- a/example-widget-mui/src/NavigationPage/NavigationPage.tsx
+++ b/example-widget-mui/src/NavigationPage/NavigationPage.tsx
@@ -110,9 +110,9 @@ export const NavigationPage = (): ReactElement => {
design of Element"
/>
diff --git a/packages/api/api-report.api.md b/packages/api/api-report.api.md
index 0c9a95af..164ecc46 100644
--- a/packages/api/api-report.api.md
+++ b/packages/api/api-report.api.md
@@ -5,6 +5,7 @@
```ts
import { Capability } from 'matrix-widget-api';
+import { IDownloadFileActionFromWidgetResponseData } from 'matrix-widget-api';
import { IGetMediaConfigActionFromWidgetResponseData } from 'matrix-widget-api';
import { IModalWidgetCreateData } from 'matrix-widget-api';
import { IModalWidgetOpenRequestDataButton } from 'matrix-widget-api';
@@ -271,6 +272,7 @@ export type WidgetApi = {
}>;
getMediaConfig(): Promise;
uploadFile(file: XMLHttpRequestBodyInit): Promise;
+ downloadFile(contentUrl: string): Promise;
};
// @public
@@ -281,6 +283,7 @@ export class WidgetApiImpl implements WidgetApi {
widgetParameters: WidgetParameters, { capabilities, supportStandalone }?: WidgetApiOptions);
closeModal(data?: T): Promise;
static create({ capabilities, supportStandalone, }?: WidgetApiOptions): Promise;
+ downloadFile(contentUrl: string): Promise;
getMediaConfig(): Promise;
getWidgetConfig(): Readonly | undefined>;
hasCapabilities(capabilities: Array): boolean;
diff --git a/packages/api/package.json b/packages/api/package.json
index 46922b52..b0f7d342 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -28,7 +28,7 @@
"generate-api-report": "tsc && api-extractor run --verbose --local"
},
"dependencies": {
- "matrix-widget-api": "^1.7.0",
+ "matrix-widget-api": "^1.9.0",
"qs": "^6.13.0",
"rxjs": "^7.8.1"
},
diff --git a/packages/api/src/api/WidgetApiImpl.ts b/packages/api/src/api/WidgetApiImpl.ts
index b0eafab0..f12fb2ff 100644
--- a/packages/api/src/api/WidgetApiImpl.ts
+++ b/packages/api/src/api/WidgetApiImpl.ts
@@ -16,6 +16,7 @@
import {
Capability,
+ IDownloadFileActionFromWidgetResponseData,
IGetMediaConfigActionFromWidgetResponseData,
IModalWidgetCreateData,
IModalWidgetOpenRequestDataButton,
@@ -799,4 +800,11 @@ export class WidgetApiImpl implements WidgetApi {
): Promise {
return await this.matrixWidgetApi.uploadFile(file);
}
+
+ /** {@inheritdoc WidgetApi.downloadFile} */
+ async downloadFile(
+ contentUrl: string,
+ ): Promise {
+ return await this.matrixWidgetApi.downloadFile(contentUrl);
+ }
}
diff --git a/packages/api/src/api/types.ts b/packages/api/src/api/types.ts
index 039b7d92..e21c3384 100644
--- a/packages/api/src/api/types.ts
+++ b/packages/api/src/api/types.ts
@@ -16,6 +16,7 @@
import {
Capability,
+ IDownloadFileActionFromWidgetResponseData,
IGetMediaConfigActionFromWidgetResponseData,
IModalWidgetCreateData,
IModalWidgetOpenRequestDataButton,
@@ -561,5 +562,14 @@ export type WidgetApi = {
file: XMLHttpRequestBodyInit,
): Promise;
+ /**
+ * Download a file to the media repository on the homeserver.
+ * @param contentUrl - MXC URI of the file to download
+ * @returns resolves to an object with: file - the file contents
+ */
+ downloadFile(
+ contentUrl: string,
+ ): Promise;
+
// TODO: sendSticker, setAlwaysOnScreen
};
diff --git a/packages/mui/package.json b/packages/mui/package.json
index 0dcb1464..84a470bb 100644
--- a/packages/mui/package.json
+++ b/packages/mui/package.json
@@ -51,7 +51,7 @@
"i18next-browser-languagedetector": "^8.0.0",
"i18next-resources-to-backend": "^1.2.1",
"lodash": "^4.17.21",
- "matrix-widget-api": "^1.7.0",
+ "matrix-widget-api": "^1.9.0",
"react": "^18.2.0",
"react-i18next": "^15.0.0",
"react-use": "^17.5.1"
diff --git a/packages/react/package.json b/packages/react/package.json
index 3f38941c..a31a5177 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -32,7 +32,7 @@
},
"dependencies": {
"@matrix-widget-toolkit/api": "^3.2.2",
- "matrix-widget-api": "^1.7.0",
+ "matrix-widget-api": "^1.9.0",
"react": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-use": "^17.5.1"
diff --git a/packages/testing/package.json b/packages/testing/package.json
index 7c4faf91..8e454ba6 100644
--- a/packages/testing/package.json
+++ b/packages/testing/package.json
@@ -29,7 +29,7 @@
"dependencies": {
"@matrix-widget-toolkit/api": "^3.3.0",
"lodash": "^4.17.21",
- "matrix-widget-api": "^1.7.0",
+ "matrix-widget-api": "^1.9.0",
"rxjs": "^7.8.1"
},
"repository": {
diff --git a/packages/testing/src/api/mockWidgetApi.ts b/packages/testing/src/api/mockWidgetApi.ts
index ef2f4bac..e50dfd69 100644
--- a/packages/testing/src/api/mockWidgetApi.ts
+++ b/packages/testing/src/api/mockWidgetApi.ts
@@ -235,6 +235,9 @@ export function mockWidgetApi(opts?: {
uploadFile: jest.fn().mockResolvedValue({
content_uri: 'mxc://...',
}),
+ downloadFile: jest.fn().mockResolvedValue({
+ file: new Blob(['image content'], { type: 'image/png' }),
+ }),
};
widgetApi.receiveRoomEvents.mockImplementation(async (type, options) => {
diff --git a/yarn.lock b/yarn.lock
index 18e8b375..31d3dd5b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8623,10 +8623,10 @@ matcher-collection@^2.0.0:
"@types/minimatch" "^3.0.3"
minimatch "^3.0.2"
-matrix-widget-api@^1.7.0:
- version "1.7.0"
- resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.7.0.tgz#ae3b44380f11bb03519d0bf0373dfc3341634667"
- integrity sha512-dzSnA5Va6CeIkyWs89xZty/uv38HLyfjOrHGbbEikCa2ZV0HTkUNtrBMKlrn4CRYyDJ6yoO/3ssRwiR0jJvOkQ==
+matrix-widget-api@^1.9.0:
+ version "1.9.0"
+ resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.9.0.tgz#884136b405bd3c56e4ea285095c9e01ec52b6b1f"
+ integrity sha512-au8mqralNDqrEvaVAkU37bXOb8I9SCe+ACdPk11QWw58FKstVq31q2wRz+qWA6J+42KJ6s1DggWbG/S3fEs3jw==
dependencies:
"@types/events" "^3.0.0"
events "^3.2.0"