Skip to content
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 .watchmanconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"ignore_dirs": [
"testdata"
]
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ The following changes are already implemented:
* [Restrict actions on specific users](https://github.com/etkecc/synapse-admin/pull/42)
* [Add `Contact support` menu item](https://github.com/etkecc/synapse-admin/pull/45)
* [Provide options to delete media and redact events on user erase](https://github.com/etkecc/synapse-admin/pull/49)
* [Authenticated Media support](https://github.com/etkecc/synapse-admin/pull/51)

_the list will be updated as new changes are added_

Expand Down
45 changes: 35 additions & 10 deletions src/components/AvatarField.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { RecordContextProvider } from "react-admin";

import { act } from "react";
import AvatarField from "./AvatarField";

describe("AvatarField", () => {
it("shows image", () => {
beforeEach(() => {
// Mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({
blob: () => Promise.resolve(new Blob(["mock image data"], { type: 'image/jpeg' })),
})
) as jest.Mock;

// Mock URL.createObjectURL
global.URL.createObjectURL = jest.fn(() => "mock-object-url");
});

afterEach(() => {
jest.restoreAllMocks();
});

it.only("shows image", async () => {
const value = {
avatar: "foo",
avatar: "mxc://serverName/mediaId",
};
render(
<RecordContextProvider value={value}>
<AvatarField source="avatar" />
</RecordContextProvider>
);
expect(screen.getByRole("img").getAttribute("src")).toBe("foo");

await act(async () => {
render(
<RecordContextProvider value={value}>
<AvatarField source="avatar" />
</RecordContextProvider>
);
});

await waitFor(() => {
const img = screen.getByRole("img");
expect(img.getAttribute("src")).toBe("mock-object-url");
});

expect(global.fetch).toHaveBeenCalled();
});
});
35 changes: 30 additions & 5 deletions src/components/AvatarField.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,37 @@
import { get } from "lodash";

import { Avatar } from "@mui/material";
import { Avatar, AvatarProps } from "@mui/material";
import { useRecordContext } from "react-admin";
import { useState, useEffect, useCallback } from "react";
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";

const AvatarField = ({ source, ...rest }) => {
const record = useRecordContext(rest);
const src = get(record, source)?.toString();
const AvatarField = ({ source, ...rest }: AvatarProps & { source: string, label?: string }) => {
const { alt, classes, sizes, sx, variant } = rest;

const record = useRecordContext(rest);
const mxcURL = get(record, source)?.toString();

const [src, setSrc] = useState<string>("");

const fetchAvatar = useCallback(async (mxcURL: string) => {
const response = await fetchAuthenticatedMedia(mxcURL, "thumbnail");
const blob = await response.blob();
const blobURL = URL.createObjectURL(blob);
setSrc(blobURL);
}, []);

useEffect(() => {
if (mxcURL) {
fetchAvatar(mxcURL);
}

// Cleanup function to revoke the object URL
return () => {
if (src) {
URL.revokeObjectURL(src);
}
};
}, [mxcURL, fetchAvatar]);

return <Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant} />;
};

Expand Down
140 changes: 110 additions & 30 deletions src/components/media.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { get } from "lodash";
import { useState } from "react";

import Typography from "@mui/material/Typography";
import BlockIcon from "@mui/icons-material/Block";
import IconCancel from "@mui/icons-material/Cancel";
import ClearIcon from "@mui/icons-material/Clear";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import FileOpenIcon from "@mui/icons-material/FileOpen";
import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import { Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip } from "@mui/material";
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import { Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip, Link } from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import {
BooleanInput,
Expand All @@ -29,12 +33,11 @@ import {
useTranslate,
} from "react-admin";
import { useMutation } from "@tanstack/react-query";
import { Link } from "react-router-dom";

import { dateParser } from "./date";
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
import { getMediaUrl } from "../synapse/synapse";
import storage from "../storage";
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";

const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
const translate = useTranslate();
Expand Down Expand Up @@ -311,48 +314,125 @@ export const QuarantineMediaButton = (props: ButtonProps) => {
);
};

export const ViewMediaButton = ({ media_id, label }) => {
export const ViewMediaButton = ({ mxcURL, uploadName, label }) => {
const translate = useTranslate();
const url = getMediaUrl(media_id);

const [open, setOpen] = useState(false);
const [blobURL, setBlobURL] = useState("");

const handleOpen = () => setOpen(true);
const handleClose = () => {
setOpen(false);
if (blobURL) {
URL.revokeObjectURL(blobURL);
}
};

const forceDownload = (url: string, filename: string) => {
const anchorElement = document.createElement("a");
anchorElement.href = url;
anchorElement.download = filename;
document.body.appendChild(anchorElement);
anchorElement.click();
document.body.removeChild(anchorElement);
URL.revokeObjectURL(blobURL);
};

const handleFile = async () => {
const response = await fetchAuthenticatedMedia(mxcURL, "original");
const blob = await response.blob();
const blobURL = URL.createObjectURL(blob);
setBlobURL(blobURL);

const mimeType = blob.type;
if (!mimeType.startsWith("image/")) {
forceDownload(blobURL, uploadName);
} else {
handleOpen();
}
};

return (
<Box style={{ whiteSpace: "pre" }}>
<Tooltip title={translate("resources.users_media.action.open")}>
<span>
<>
<Box style={{ whiteSpace: "pre" }}>
<Tooltip title={translate("resources.users_media.action.open")}>
<span>
<Button
component={Link}
to={url}
target="_blank"
rel="noopener"
style={{ minWidth: 0, paddingLeft: 0, paddingRight: 0 }}
onClick={() => handleFile()}
style={{ minWidth: 0, paddingLeft: 0, paddingRight: 0 }}
>
<FileOpenIcon />
</Button>
</span>
</Tooltip>
{label}
</Box>
<Dialog
open={open}
onClose={handleClose}
aria-labelledby="image-modal-title"
aria-describedby="image-modal-description"
style={{ maxWidth: "100%", maxHeight: "100%" }}
>
<DialogTitle id="image-modal-title">
<Typography>{uploadName}</Typography>
<IconButton
aria-label="close"
onClick={handleClose}
sx={(theme) => ({
position: 'absolute',
right: 8,
top: 8,
color: theme.palette.grey[500],
})}
>
<FileOpenIcon />
</Button>
</span>
</Tooltip>
{label}
</Box>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<Link href={blobURL} target="_blank">
<img src={blobURL} alt={uploadName}
style={{ maxWidth: "100%", maxHeight: "/calc(100vh - 64px)", objectFit: "contain" }}
/>
<br />
<ZoomInIcon />
</Link>
</DialogContent>
</Dialog>
</>
);
};

export const MediaIDField = ({ source }) => {
const homeserver = storage.getItem("home_server");
const record = useRecordContext();
if (!record) return null;
if (!record) {
return null;
}
const homeserver = storage.getItem("home_server");

const src = get(record, source)?.toString();
if (!src) return null;
const mediaID = get(record, source)?.toString();
if (!mediaID) {
return null;
}

return <ViewMediaButton media_id={`${homeserver}/${src}`} label={src} />;
const mxcURL = `mxc://${homeserver}/${mediaID}`;
const uploadName = decodeURIComponent(get(record, "upload_name")?.toString());

return <ViewMediaButton mxcURL={mxcURL} uploadName={uploadName} label={mediaID} />;
};

export const MXCField = ({ source }) => {
export const ReportMediaContent = ({ source }) => {
const record = useRecordContext();
if (!record) return null;
if (!record) {
return null;
}

const src = get(record, source)?.toString();
if (!src) return null;
const mxcURL = get(record, source)?.toString();
if (!mxcURL) {
return null;
}

const media_id = src.replace("mxc://", "");
const uploadName = decodeURIComponent(record.event_json.content.body);

return <ViewMediaButton media_id={media_id} label={src} />;
return <ViewMediaButton mxcURL={mxcURL} uploadName={uploadName} label={mxcURL} />;
};
4 changes: 2 additions & 2 deletions src/resources/reports.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
} from "react-admin";

import { DATE_FORMAT } from "../components/date";
import { MXCField } from "../components/media";
import { ReportMediaContent } from "../components/media";

const ReportPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;

Expand Down Expand Up @@ -62,7 +62,7 @@ export const ReportShow = (props: ShowProps) => {
<TextField source="event_json.content.msgtype" />
<TextField source="event_json.content.body" />
<TextField source="event_json.content.info.mimetype" />
<MXCField source="event_json.content.url" />
<ReportMediaContent source="event_json.content.url" />
<TextField source="event_json.content.format" />
<TextField source="event_json.content.formatted_body" />
<TextField source="event_json.content.algorithm" />
Expand Down
3 changes: 1 addition & 2 deletions src/resources/room_directory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ import {
useUnselectAll,
} from "react-admin";
import { useMutation } from "@tanstack/react-query";

import AvatarField from "../components/AvatarField";


const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />;

export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
Expand Down Expand Up @@ -144,7 +144,6 @@ export const RoomDirectoryList = () => (
>
<AvatarField
source="avatar_src"
sortable={false}
sx={{ height: "40px", width: "40px" }}
label="resources.rooms.fields.avatar"
/>
Expand Down
6 changes: 6 additions & 0 deletions src/resources/rooms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from "./room_directory";
import { DATE_FORMAT } from "../components/date";
import DeleteRoomButton from "../components/DeleteRoomButton";
import AvatarField from "../components/AvatarField";

const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;

Expand Down Expand Up @@ -90,6 +91,11 @@ export const RoomShow = (props: ShowProps) => {
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
<TabbedShowLayout>
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
<AvatarField
source="avatar"
sx={{ height: "120px", width: "120px" }}
label="resources.rooms.fields.avatar"
/>
<TextField source="room_id" />
<TextField source="name" />
<TextField source="topic" />
Expand Down
4 changes: 2 additions & 2 deletions src/resources/user_media_statistics.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import EqualizerIcon from "@mui/icons-material/Equalizer";
import PermMediaIcon from "@mui/icons-material/PermMedia";
import {
Datagrid,
ExportButton,
Expand Down Expand Up @@ -48,7 +48,7 @@ export const UserMediaStatsList = (props: ListProps) => (

const resource: ResourceProps = {
name: "user_media_statistics",
icon: EqualizerIcon,
icon: PermMediaIcon,
list: UserMediaStatsList,
};

Expand Down
Loading