Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Add option to control user's experimental features
  • Loading branch information
beastafk committed Oct 30, 2024
commit 974757c819c12c64b8ed3a2ce8aec9015323ff50
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
43 changes: 37 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, FormControlLabel, Box, Stack } from "@mui/material";
import {
ArrayInput,
ArrayField,
Expand Down Expand Up @@ -58,8 +59,11 @@ import {
ImageInput,
ImageField,
FunctionField,
useDataProvider,
SingleFieldList,
WithListContext,
} from "react-admin";
import { Link } from "react-router-dom";
import { Form, Link } from "react-router-dom";

import AvatarField from "../components/AvatarField";
import DeleteUserButton from "../components/DeleteUserButton";
Expand Down Expand Up @@ -126,8 +130,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 +240,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 +316,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,11 +454,36 @@ export const UserEdit = (props: EditProps) => {
</Datagrid>
</ReferenceManyField>
</FormTab>

<FormTab label="Experimental" icon={<ScienceIcon />} path="experimental">
<ReferenceManyField reference="features" target="id" label={false}>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<FeatureBooleanInput />
</Datagrid>
</ReferenceManyField>
</FormTab>
</TabbedForm>
</Edit>
);
};

const FeatureBooleanInput = () => {
const record = useRecordContext();
if (!record) {
return null;
}
return (
<Stack direction="column" spacing={2}>
<TextField source="featureLabel" />
<BooleanInput
source="featureValue"
name={`features.${record.featureName}`}
label={record.featureName}
/>
</Stack>
);
};

const resource: ResourceProps = {
name: "users",
icon: UserIcon,
Expand Down
46 changes: 46 additions & 0 deletions src/synapse/dataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ interface Pusher {
pushkey: string;
}

interface ExperimentalFeature {
name: string;
value: boolean;
}

interface UserMedia {
created_ts: number;
last_access_ts?: number;
Expand Down Expand Up @@ -248,9 +253,16 @@ export interface UploadMediaResult {
content_uri: string;
}

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

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

const resourceMap = {
Expand Down Expand Up @@ -357,6 +369,17 @@ const resourceMap = {
data: "pushers",
total: json => json.total,
},
features: {
map: (f: ExperimentalFeature) => ({
...f,
id: f.name,
}),
total: json => json.features.length,
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/experimental_features/${id}`,
}),
data: "features",
},
joined_rooms: {
map: (jr: string) => ({
id: jr,
Expand Down Expand Up @@ -523,6 +546,11 @@ function getSearchOrder(order: "ASC" | "DESC") {
}
}

const featureLabels = {
msc3881: "enable remotely toggling push notifications for another client",
msc3575: "enable experimental sliding sync support",
};

const baseDataProvider: SynapseDataProvider = {
getList: async (resource, params) => {
console.log("getList " + resource);
Expand Down Expand Up @@ -624,6 +652,14 @@ const baseDataProvider: SynapseDataProvider = {
const endpoint_url = `${homeserver}${ref.endpoint}?${new URLSearchParams(filterUndefined(query)).toString()}`;

const { json } = await jsonClient(endpoint_url);
if (resource === "features") {
json.features = Object.entries(json.features).map(([feature, enabled]) => ({
featureName: feature,
featureValue: enabled,
featureLabel: featureLabels[feature],
}));
console.log("JSON", json[res.data]);
}
return {
data: json[res.data].map(res.map),
total: res.total(json, from, perPage),
Expand Down Expand Up @@ -798,15 +834,25 @@ const baseDataProvider: SynapseDataProvider = {
});
return json as UploadMediaResult;
},
updateFeatures: async (id: Identifier, features: ExperimentalFeatures) => {
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, [
{
resource: "users",
beforeUpdate: async (params: UpdateParams<any>, dataProvider: DataProvider) => {
console.log("beforeUpdate", params);
const avatarFile = params.data.avatar_file?.rawFile;
const avatarErase = params.data.avatar_erase;

if (params.data.features) {
await dataProvider.updateFeatures(params.id, params.data.features);
}

if (avatarErase) {
params.data.avatar_url = "";
return params;
Expand Down