Skip to content

Commit

Permalink
Implement the download_file widget action (#759)
Browse files Browse the repository at this point in the history
* Implement the download_file widget action

Signed-off-by: Michael Weimann <michael.weimann@nordeck.net>

* Disable upload button during upload

Signed-off-by: Michael Weimann <michael.weimann@nordeck.net>

* bump matrix-widget-api to 1.9.0

Signed-off-by: Kim Brose <kim.brose@nordeck.net>

* Add mocked results required for file download test

Signed-off-by: Milton Moura <miltonmoura@gmail.com>

---------

Signed-off-by: Michael Weimann <michael.weimann@nordeck.net>
Signed-off-by: Kim Brose <kim.brose@nordeck.net>
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
Co-authored-by: Kim Brose <kim.brose@nordeck.net>
Co-authored-by: Milton Moura <miltonmoura@gmail.com>
  • Loading branch information
3 people authored Sep 4, 2024
1 parent 8958d99 commit 9852f94
Show file tree
Hide file tree
Showing 19 changed files with 136 additions and 36 deletions.
5 changes: 5 additions & 0 deletions .changeset/sweet-jeans-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@matrix-widget-toolkit/api': minor
---

Add support for the download_file widget action
2 changes: 1 addition & 1 deletion example-widget-mui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion example-widget-mui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions example-widget-mui/src/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ 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';
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({
Expand Down Expand Up @@ -65,7 +65,7 @@ export function App({
<Route path="/relations" element={<RelationsPage />} />
<Route path="/invitations" element={<InvitationsPage />} />
<Route path="/theme" element={<ThemePage />} />
<Route path="/uploadImage" element={<UploadImagePage />} />
<Route path="/image" element={<ImagePage />} />
</Routes>
</MuiWidgetApiProvider>
</Suspense>
Expand Down
66 changes: 66 additions & 0 deletions example-widget-mui/src/ImagePage/Image.tsx
Original file line number Diff line number Diff line change
@@ -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<ImageProps> = function ({
contentUrl,
...imageProps
}) {
const [dataUrl, setDataUrl] = useState<string>();
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 <img {...imageProps} src={dataUrl} onLoad={handleLoad} />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
UploadedImageEvent,
isValidUploadedImage,
} from '../events';
import { Image } from './Image';

export const ImageListView = (): ReactElement => {
const widgetApi = useWidgetApi();
Expand Down Expand Up @@ -75,13 +76,9 @@ export const ImageListView = (): ReactElement => {
{imageNames.length > 0 &&
imageNames.map((image) => (
<ImageListItem key={image.event_id}>
<img
src={`${getHttpUriForMxc(
image.content.url,
widgetApi.widgetParameters.baseUrl,
)}?w=164&h=164&fit=crop&auto=format`}
<Image
alt={image.content.name}
loading="lazy"
contentUrl={image.content.url}
/>
<ImageListItemBar
title={image.content.name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { ComponentType, PropsWithChildren, act } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ROOM_EVENT_UPLOADED_IMAGE } from '../events';
import { StoreProvider } from '../store';
import { UploadImagePage } from './UploadImagePage';
import { ImagePage } from './ImagePage';

let widgetApi: MockedWidgetApi;
let wrapper: ComponentType<PropsWithChildren<{}>>;
Expand All @@ -37,6 +37,8 @@ afterEach(() => widgetApi.stop());
beforeEach(() => {
widgetApi = mockWidgetApi();

global.URL.createObjectURL = jest.fn().mockReturnValue('http://...');

wrapper = ({ children }: PropsWithChildren<{}>) => (
<WidgetApiMockProvider value={widgetApi}>
<StoreProvider>
Expand All @@ -46,9 +48,13 @@ beforeEach(() => {
);
});

describe('<UploadImagePage>', () => {
afterEach(() => {
jest.restoreAllMocks();
});

describe('<ImagePage>', () => {
it('should render without exploding', async () => {
render(<UploadImagePage />, { wrapper });
render(<ImagePage />, { wrapper });

expect(
screen.getByRole('link', { name: /back to navigation/i }),
Expand All @@ -66,7 +72,7 @@ describe('<UploadImagePage>', () => {
});

it('should have no accessibility violations', async () => {
const { container } = render(<UploadImagePage />, { wrapper });
const { container } = render(<ImagePage />, { wrapper });

expect(
screen.getByRole('heading', { name: /upload file/i }),
Expand All @@ -83,7 +89,7 @@ describe('<UploadImagePage>', () => {
});

it('should request the capabilities', async () => {
render(<UploadImagePage />, { wrapper });
render(<ImagePage />, { wrapper });

expect(widgetApi.requestCapabilities).toHaveBeenCalledWith([
WidgetEventCapability.forRoomEvent(
Expand All @@ -95,6 +101,7 @@ describe('<UploadImagePage>', () => {
ROOM_EVENT_UPLOADED_IMAGE,
),
WidgetApiFromWidgetAction.MSC4039UploadFileAction,
WidgetApiFromWidgetAction.MSC4039DownloadFileAction,
WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction,
]);

Expand All @@ -104,7 +111,7 @@ describe('<UploadImagePage>', () => {
});

it('should say that no images are loaded yet', async () => {
render(<UploadImagePage />, { wrapper });
render(<ImagePage />, { wrapper });

await expect(
screen.findByText(/no images uploaded to this room yet/i),
Expand All @@ -115,10 +122,10 @@ describe('<UploadImagePage>', () => {
widgetApi.sendRoomEvent(ROOM_EVENT_UPLOADED_IMAGE, {
name: 'image.png',
size: 123,
url: 'http://example.com/image.png',
url: 'mxc://...',
});

render(<UploadImagePage />, { wrapper });
render(<ImagePage />, { wrapper });

await expect(
screen.findByRole('img', { name: /image.png/i }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
Expand All @@ -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.',
Expand Down Expand Up @@ -103,18 +104,17 @@ export const UploadImagePage = (): ReactElement => {
const responseUploadMedia = await widgetApi.uploadFile(selectedFile);
const url = responseUploadMedia.content_uri;

setLoading(true);
await widgetApi.sendRoomEvent<UploadedImageEvent>(
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);
}
}
};
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@
* limitations under the License.
*/

export { UploadImagePage } from './UploadImagePage';
export { ImagePage } from './ImagePage';
6 changes: 3 additions & 3 deletions example-widget-mui/src/NavigationPage/NavigationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@ export const NavigationPage = (): ReactElement => {
design of Element"
/>
<NavigationItem
to="/uploadImage"
title="Upload File"
description="Example for uploading an image file"
to="/image"
title="Up- and download image"
description="Example for up- and downloading an image file"
/>
</List>
</Box>
Expand Down
3 changes: 3 additions & 0 deletions packages/api/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -271,6 +272,7 @@ export type WidgetApi = {
}>;
getMediaConfig(): Promise<IGetMediaConfigActionFromWidgetResponseData>;
uploadFile(file: XMLHttpRequestBodyInit): Promise<IUploadFileActionFromWidgetResponseData>;
downloadFile(contentUrl: string): Promise<IDownloadFileActionFromWidgetResponseData>;
};

// @public
Expand All @@ -281,6 +283,7 @@ export class WidgetApiImpl implements WidgetApi {
widgetParameters: WidgetParameters, { capabilities, supportStandalone }?: WidgetApiOptions);
closeModal<T extends IModalWidgetReturnData>(data?: T): Promise<void>;
static create({ capabilities, supportStandalone, }?: WidgetApiOptions): Promise<WidgetApi>;
downloadFile(contentUrl: string): Promise<IDownloadFileActionFromWidgetResponseData>;
getMediaConfig(): Promise<IGetMediaConfigActionFromWidgetResponseData>;
getWidgetConfig<T extends IWidgetApiRequestData>(): Readonly<WidgetConfig<T> | undefined>;
hasCapabilities(capabilities: Array<WidgetEventCapability | Capability>): boolean;
Expand Down
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
8 changes: 8 additions & 0 deletions packages/api/src/api/WidgetApiImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import {
Capability,
IDownloadFileActionFromWidgetResponseData,
IGetMediaConfigActionFromWidgetResponseData,
IModalWidgetCreateData,
IModalWidgetOpenRequestDataButton,
Expand Down Expand Up @@ -799,4 +800,11 @@ export class WidgetApiImpl implements WidgetApi {
): Promise<IUploadFileActionFromWidgetResponseData> {
return await this.matrixWidgetApi.uploadFile(file);
}

/** {@inheritdoc WidgetApi.downloadFile} */
async downloadFile(
contentUrl: string,
): Promise<IDownloadFileActionFromWidgetResponseData> {
return await this.matrixWidgetApi.downloadFile(contentUrl);
}
}
10 changes: 10 additions & 0 deletions packages/api/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import {
Capability,
IDownloadFileActionFromWidgetResponseData,
IGetMediaConfigActionFromWidgetResponseData,
IModalWidgetCreateData,
IModalWidgetOpenRequestDataButton,
Expand Down Expand Up @@ -561,5 +562,14 @@ export type WidgetApi = {
file: XMLHttpRequestBodyInit,
): Promise<IUploadFileActionFromWidgetResponseData>;

/**
* 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<IDownloadFileActionFromWidgetResponseData>;

// TODO: sendSticker, setAlwaysOnScreen
};
2 changes: 1 addition & 1 deletion packages/mui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/testing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading

0 comments on commit 9852f94

Please sign in to comment.