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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ with a proper manifest.json generation on build)
* [Fix room state events display](https://github.com/etkecc/synapse-admin/pull/100)
* [Sanitize CSV on import](https://github.com/etkecc/synapse-admin/pull/101)
* Allow setting version using `SYNAPSE_ADMIN_VERSION` environment variable on build (if git is not available)
* [Add option to control user's experimental features](https://github.com/etkecc/synapse-admin/pull/111)

_the list will be updated as new changes are added_

Expand Down
95 changes: 95 additions & 0 deletions src/components/ExperimentalFeatures.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useRecordContext } from "react-admin";
import { useNotify } from "react-admin";
import { useDataProvider } from "react-admin";
import { useState, useEffect } from "react";
import { Stack, Switch, Typography } from "@mui/material";
import { ExperimentalFeaturesModel, SynapseDataProvider } from "../synapse/dataProvider";

const experimentalFeaturesMap = {
msc3881: "enable remotely toggling push notifications for another client",
msc3575: "enable experimental sliding sync support",
};
const ExperimentalFeatureRow = (props: { featureKey: string, featureValue: boolean, updateFeature: (feature_name: string, feature_value: boolean) => void}) => {
const featureKey = props.featureKey;
const featureValue = props.featureValue;
const featureDescription = experimentalFeaturesMap[featureKey] ?? "";
const [checked, setChecked] = useState(featureValue);

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setChecked(event.target.checked);
props.updateFeature(featureKey, event.target.checked);
};

return <Stack
direction="row"
spacing={2}
alignItems="start"
sx={{
padding: 2,
}}
>
<Switch checked={checked} onChange={handleChange} />
<Stack>
<Typography
variant="subtitle1"
sx={{
fontWeight: "medium",
color: "text.primary"
}}
>
{featureKey}
</Typography>
<Typography
variant="body2"
color="text.secondary"
>
{featureDescription}
</Typography>
</Stack>
</Stack>
}

export const ExperimentalFeaturesList = () => {
const record = useRecordContext();
const notify = useNotify();
const dataProvider = useDataProvider() as SynapseDataProvider;
const [features, setFeatures] = useState({});
if (!record) {
return null;
}

useEffect(() => {
const fetchFeatures = async () => {
const features = await dataProvider.getFeatures(record.id);
setFeatures(features);
}

fetchFeatures();
}, []);

const updateFeature = async (feature_name: string, feature_value: boolean) => {
const updatedFeatures = {...features, [feature_name]: feature_value} as ExperimentalFeaturesModel;
setFeatures(updatedFeatures);
const reponse = await dataProvider.updateFeatures(record.id, updatedFeatures);
notify("ra.notification.updated", {
messageArgs: { smart_count: 1 },
type: "success",
});
};

return <>
<Stack
direction="column"
spacing={1}
>
{Object.keys(features).map((featureKey: string) =>
<ExperimentalFeatureRow
key={featureKey}
featureKey={featureKey}
featureValue={features[featureKey]}
updateFeature={updateFeature}
/>
)}
</Stack>
</>
}
2 changes: 1 addition & 1 deletion src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const Footer = () => {
borderColor: '#ddd',
p: 1,
}}>
<Typography variant="body2">
<Typography variant="body2" component="div">
<Avatar src="./images/logo.webp" sx={{ width: "1rem", height: "1rem", display: "inline-block", verticalAlign: "sub" }} />
<Link sx={{ color: "#888", textDecoration: 'none' }} href="https://github.com/etkecc/synapse-admin" target="_blank">
Synapse Admin
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ const en: SynapseTranslationMessages = {
erase_avatar: "Erase avatar",
delete_media: "Delete all media uploaded by the user(-s)",
redact_events: "Redact all events sent by the user(-s)",
},
}
},
rooms: {
name: "Room |||| Rooms",
Expand Down
20 changes: 14 additions & 6 deletions src/resources/users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import NotificationsIcon from "@mui/icons-material/Notifications";
import PermMediaIcon from "@mui/icons-material/PermMedia";
import PersonPinIcon from "@mui/icons-material/PersonPin";
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
import ScienceIcon from "@mui/icons-material/Science";
import ViewListIcon from "@mui/icons-material/ViewList";
import { useEffect, useState } from "react";
import { Alert, ownerDocument } from "@mui/material";
import { Alert, Switch, Stack, Typography } from "@mui/material";
import {
ArrayInput,
ArrayField,
Expand Down Expand Up @@ -54,10 +55,10 @@ import {
useNotify,
Identifier,
ToolbarClasses,
RaRecord,
ImageInput,
ImageField,
FunctionField,
useDataProvider,
} from "react-admin";
import { Link } from "react-router-dom";

Expand All @@ -68,6 +69,7 @@ import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/Server
import { DATE_FORMAT } from "../components/date";
import { DeviceRemoveButton } from "../components/devices";
import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../components/media";
import { ExperimentalFeaturesList } from "../components/ExperimentalFeatures";

const choices_medium = [
{ id: "email", name: "resources.users.email" },
Expand Down Expand Up @@ -126,8 +128,6 @@ const UserBulkActionButtons = () => {
const [asManagedUserIsSelected, setAsManagedUserIsSelected] = useState(false);
const selectedIds = record.selectedIds;
const ownUserId = localStorage.getItem("user_id");
const notify = useNotify();
const translate = useTranslate();

useEffect(() => {
setOwnUserIsSelected(selectedIds.includes(ownUserId));
Expand Down Expand Up @@ -238,11 +238,11 @@ export const UserCreate = (props: CreateProps) => (

const UserTitle = () => {
const record = useRecordContext();
const translate = useTranslate();
if (!record) {
return null;
}

const translate = useTranslate();
let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""
if (isASManaged(record?.id)) {
username += " 🤖";
Expand Down Expand Up @@ -314,7 +314,11 @@ export const UserEdit = (props: EditProps) => {
const translate = useTranslate();

return (
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic">
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic" queryOptions={{
meta: {
include: ["features"] // Tell your dataProvider to include features
}
}}>
<TabbedForm toolbar={<UserEditToolbar />}>
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
<AvatarField source="avatar_src" sx={{ height: "120px", width: "120px" }} />
Expand Down Expand Up @@ -448,6 +452,10 @@ export const UserEdit = (props: EditProps) => {
</Datagrid>
</ReferenceManyField>
</FormTab>

<FormTab label="Experimental" icon={<ScienceIcon />} path="experimental">
<ExperimentalFeaturesList />
</FormTab>
</TabbedForm>
</Edit>
);
Expand Down
18 changes: 18 additions & 0 deletions src/synapse/dataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,16 @@ export interface UploadMediaResult {
content_uri: string;
}

export interface ExperimentalFeaturesModel {
features: {
[key: string]: boolean;
};
}

export interface SynapseDataProvider extends DataProvider {
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
updateFeatures: (id: Identifier, features: ExperimentalFeaturesModel) => Promise<void>;
}

const resourceMap = {
Expand Down Expand Up @@ -798,6 +805,17 @@ const baseDataProvider: SynapseDataProvider = {
});
return json as UploadMediaResult;
},
getFeatures: async (id: Identifier) => {
const base_url = storage.getItem("base_url");
const endpoint_url = `${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}`;
const { json } = await jsonClient(endpoint_url);
return json.features as ExperimentalFeaturesModel;
},
updateFeatures: async (id: Identifier, features: ExperimentalFeaturesModel) => {
const base_url = storage.getItem("base_url");
const endpoint_url = `${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}`;
await jsonClient(endpoint_url, { method: "PUT", body: JSON.stringify({ features }) });
},
};

const dataProvider = withLifecycleCallbacks(baseDataProvider, [
Expand Down