Skip to content

Commit

Permalink
Implement the download_file widget action
Browse files Browse the repository at this point in the history
Signed-off-by: Michael Weimann <michael.weimann@nordeck.net>
  • Loading branch information
weeman1337 committed Aug 26, 2024
1 parent 8518d2f commit 61ee791
Show file tree
Hide file tree
Showing 13 changed files with 113 additions and 22 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
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 @@ -46,9 +46,9 @@ beforeEach(() => {
);
});

describe('<UploadImagePage>', () => {
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 +66,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 +83,7 @@ describe('<UploadImagePage>', () => {
});

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

expect(widgetApi.requestCapabilities).toHaveBeenCalledWith([
WidgetEventCapability.forRoomEvent(
Expand All @@ -104,7 +104,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 @@ -118,7 +118,7 @@ describe('<UploadImagePage>', () => {
url: 'http://example.com/image.png',
});

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 Down Expand Up @@ -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
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
};
1 change: 1 addition & 0 deletions packages/testing/src/api/mockWidgetApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export function mockWidgetApi(opts?: {
uploadFile: jest.fn().mockResolvedValue({
content_uri: 'mxc://...',
}),
downloadFile: jest.fn(),
};

widgetApi.receiveRoomEvents.mockImplementation(async (type, options) => {
Expand Down

0 comments on commit 61ee791

Please sign in to comment.