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/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} >; @@ -46,9 +46,9 @@ beforeEach(() => { ); }); -describe('', () => { +describe('', () => { it('should render without exploding', async () => { - render(, { wrapper }); + render(, { wrapper }); expect( screen.getByRole('link', { name: /back to navigation/i }), @@ -66,7 +66,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 +83,7 @@ describe('', () => { }); it('should request the capabilities', async () => { - render(, { wrapper }); + render(, { wrapper }); expect(widgetApi.requestCapabilities).toHaveBeenCalledWith([ WidgetEventCapability.forRoomEvent( @@ -104,7 +104,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), @@ -118,7 +118,7 @@ describe('', () => { url: 'http://example.com/image.png', }); - 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 98% rename from example-widget-mui/src/UploadImagePage/UploadImagePage.tsx rename to example-widget-mui/src/ImagePage/ImagePage.tsx index 19a5abad..271ae7ec 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); @@ -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/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/testing/src/api/mockWidgetApi.ts b/packages/testing/src/api/mockWidgetApi.ts index ef2f4bac..3b4d766a 100644 --- a/packages/testing/src/api/mockWidgetApi.ts +++ b/packages/testing/src/api/mockWidgetApi.ts @@ -235,6 +235,7 @@ export function mockWidgetApi(opts?: { uploadFile: jest.fn().mockResolvedValue({ content_uri: 'mxc://...', }), + downloadFile: jest.fn(), }; widgetApi.receiveRoomEvents.mockImplementation(async (type, options) => {