diff --git a/README.md b/README.md index b43d42a..17ecce0 100644 --- a/README.md +++ b/README.md @@ -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_ diff --git a/src/components/ExperimentalFeatures.tsx b/src/components/ExperimentalFeatures.tsx new file mode 100644 index 0000000..49cd384 --- /dev/null +++ b/src/components/ExperimentalFeatures.tsx @@ -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) => { + setChecked(event.target.checked); + props.updateFeature(featureKey, event.target.checked); + }; + + return + + + + {featureKey} + + + {featureDescription} + + + +} + +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 <> + + {Object.keys(features).map((featureKey: string) => + + )} + + +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index c152e10..4f85a5a 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -22,7 +22,7 @@ const Footer = () => { borderColor: '#ddd', p: 1, }}> - + Synapse Admin diff --git a/src/i18n/en.ts b/src/i18n/en.ts index acc1146..e84b572 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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", diff --git a/src/resources/users.tsx b/src/resources/users.tsx index cd7bdbc..f649ec7 100644 --- a/src/resources/users.tsx +++ b/src/resources/users.tsx @@ -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, @@ -54,10 +55,10 @@ import { useNotify, Identifier, ToolbarClasses, - RaRecord, ImageInput, ImageField, FunctionField, + useDataProvider, } from "react-admin"; import { Link } from "react-router-dom"; @@ -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" }, @@ -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)); @@ -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 += " 🤖"; @@ -314,7 +314,11 @@ export const UserEdit = (props: EditProps) => { const translate = useTranslate(); return ( - } actions={} mutationMode="pessimistic"> + } actions={} mutationMode="pessimistic" queryOptions={{ + meta: { + include: ["features"] // Tell your dataProvider to include features + } + }}> }> }> @@ -448,6 +452,10 @@ export const UserEdit = (props: EditProps) => { + + } path="experimental"> + + ); diff --git a/src/synapse/dataProvider.ts b/src/synapse/dataProvider.ts index 9203d8a..7bff99f 100644 --- a/src/synapse/dataProvider.ts +++ b/src/synapse/dataProvider.ts @@ -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; uploadMedia: (params: UploadMediaParams) => Promise; + updateFeatures: (id: Identifier, features: ExperimentalFeaturesModel) => Promise; } const resourceMap = { @@ -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, [