Add option to control user's experimental features (#111)

* Add option to control user's experimental features

* Don't use ReferenceManyField, load experimental features manually

* cleanup

* Move experimental features to their own components, improve UI

* remove background from Stack

* update readme
This commit is contained in:
Borislav Pantaleev 2024-11-06 11:25:47 +02:00 committed by GitHub
parent 86b4987b7f
commit cd1ca7c039
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 130 additions and 8 deletions

View file

@ -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) * [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) * [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) * 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_ _the list will be updated as new changes are added_

View file

@ -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>
</>
}

View file

@ -22,7 +22,7 @@ const Footer = () => {
borderColor: '#ddd', borderColor: '#ddd',
p: 1, 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" }} /> <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"> <Link sx={{ color: "#888", textDecoration: 'none' }} href="https://github.com/etkecc/synapse-admin" target="_blank">
Synapse Admin Synapse Admin

View file

@ -159,7 +159,7 @@ const en: SynapseTranslationMessages = {
erase_avatar: "Erase avatar", erase_avatar: "Erase avatar",
delete_media: "Delete all media uploaded by the user(-s)", delete_media: "Delete all media uploaded by the user(-s)",
redact_events: "Redact all events sent by the user(-s)", redact_events: "Redact all events sent by the user(-s)",
}, }
}, },
rooms: { rooms: {
name: "Room |||| Rooms", name: "Room |||| Rooms",

View file

@ -7,9 +7,10 @@ import NotificationsIcon from "@mui/icons-material/Notifications";
import PermMediaIcon from "@mui/icons-material/PermMedia"; import PermMediaIcon from "@mui/icons-material/PermMedia";
import PersonPinIcon from "@mui/icons-material/PersonPin"; import PersonPinIcon from "@mui/icons-material/PersonPin";
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent"; import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
import ScienceIcon from "@mui/icons-material/Science";
import ViewListIcon from "@mui/icons-material/ViewList"; import ViewListIcon from "@mui/icons-material/ViewList";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Alert, ownerDocument } from "@mui/material"; import { Alert, Switch, Stack, Typography } from "@mui/material";
import { import {
ArrayInput, ArrayInput,
ArrayField, ArrayField,
@ -54,10 +55,10 @@ import {
useNotify, useNotify,
Identifier, Identifier,
ToolbarClasses, ToolbarClasses,
RaRecord,
ImageInput, ImageInput,
ImageField, ImageField,
FunctionField, FunctionField,
useDataProvider,
} from "react-admin"; } from "react-admin";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@ -68,6 +69,7 @@ import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/Server
import { DATE_FORMAT } from "../components/date"; import { DATE_FORMAT } from "../components/date";
import { DeviceRemoveButton } from "../components/devices"; import { DeviceRemoveButton } from "../components/devices";
import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../components/media"; import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../components/media";
import { ExperimentalFeaturesList } from "../components/ExperimentalFeatures";
const choices_medium = [ const choices_medium = [
{ id: "email", name: "resources.users.email" }, { id: "email", name: "resources.users.email" },
@ -126,8 +128,6 @@ const UserBulkActionButtons = () => {
const [asManagedUserIsSelected, setAsManagedUserIsSelected] = useState(false); const [asManagedUserIsSelected, setAsManagedUserIsSelected] = useState(false);
const selectedIds = record.selectedIds; const selectedIds = record.selectedIds;
const ownUserId = localStorage.getItem("user_id"); const ownUserId = localStorage.getItem("user_id");
const notify = useNotify();
const translate = useTranslate();
useEffect(() => { useEffect(() => {
setOwnUserIsSelected(selectedIds.includes(ownUserId)); setOwnUserIsSelected(selectedIds.includes(ownUserId));
@ -238,11 +238,11 @@ export const UserCreate = (props: CreateProps) => (
const UserTitle = () => { const UserTitle = () => {
const record = useRecordContext(); const record = useRecordContext();
const translate = useTranslate();
if (!record) { if (!record) {
return null; return null;
} }
const translate = useTranslate();
let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : "" let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""
if (isASManaged(record?.id)) { if (isASManaged(record?.id)) {
username += " 🤖"; username += " 🤖";
@ -314,7 +314,11 @@ export const UserEdit = (props: EditProps) => {
const translate = useTranslate(); const translate = useTranslate();
return ( 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 />}> <TabbedForm toolbar={<UserEditToolbar />}>
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}> <FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
<AvatarField source="avatar_src" sx={{ height: "120px", width: "120px" }} /> <AvatarField source="avatar_src" sx={{ height: "120px", width: "120px" }} />
@ -448,6 +452,10 @@ export const UserEdit = (props: EditProps) => {
</Datagrid> </Datagrid>
</ReferenceManyField> </ReferenceManyField>
</FormTab> </FormTab>
<FormTab label="Experimental" icon={<ScienceIcon />} path="experimental">
<ExperimentalFeaturesList />
</FormTab>
</TabbedForm> </TabbedForm>
</Edit> </Edit>
); );

View file

@ -248,9 +248,16 @@ export interface UploadMediaResult {
content_uri: string; content_uri: string;
} }
export interface ExperimentalFeaturesModel {
features: {
[key: string]: boolean;
};
}
export interface SynapseDataProvider extends DataProvider { export interface SynapseDataProvider extends DataProvider {
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>; deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>; uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
updateFeatures: (id: Identifier, features: ExperimentalFeaturesModel) => Promise<void>;
} }
const resourceMap = { const resourceMap = {
@ -798,6 +805,17 @@ const baseDataProvider: SynapseDataProvider = {
}); });
return json as UploadMediaResult; 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, [ const dataProvider = withLifecycleCallbacks(baseDataProvider, [