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) => ( - {image.content.name} >; @@ -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"