Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement the download_file widget action #759

Merged
merged 4 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading