Refactor all of Devtools and tidy it up (#8097)

This commit is contained in:
Michael Telatynski 2022-03-23 20:17:57 +00:00 committed by GitHub
parent 64871c057b
commit 306ddd51e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1516 additions and 1562 deletions

View file

@ -359,13 +359,16 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
}
}
const viewSourceButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconSource"
label={_t("View source")}
onClick={this.onViewSourceClick}
/>
);
let viewSourceButton: JSX.Element;
if (SettingsStore.getValue("developerMode")) {
viewSourceButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconSource"
label={_t("View source")}
onClick={this.onViewSourceClick}
/>
);
}
if (this.props.eventTileOps) {
if (this.props.eventTileOps.isWidgetHidden()) {

View file

@ -48,6 +48,8 @@ import PosthogTrackers from "../../../PosthogTrackers";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import SettingsStore from "../../../settings/SettingsStore";
import DevtoolsDialog from "../dialogs/DevtoolsDialog";
interface IProps extends IContextMenuProps {
room: Room;
@ -353,6 +355,20 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => {
iconClassName="mx_RoomTile_iconExport"
/>
{ SettingsStore.getValue("developerMode") && <IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createDialog(DevtoolsDialog, {
roomId: RoomViewStore.getRoomId(),
}, "mx_DevtoolsDialog_wrapper");
onFinished();
}}
label={_t("Developer tools")}
iconClassName="mx_RoomTile_iconDeveloperTools"
/> }
{ leaveOption }
</IconizedContextMenuOptionList>
</IconizedContextMenu>;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,117 @@
/*
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext, useMemo, useState } from "react";
import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { EventEditor, EventViewer, eventTypeField, IEditorProps, stringify } from "./Event";
import FilteredList from "./FilteredList";
import { _t } from "../../../../languageHandler";
export const AccountDataEventEditor = ({ mxEvent, onBack }: IEditorProps) => {
const cli = useContext(MatrixClientContext);
const fields = useMemo(() => [
eventTypeField(mxEvent?.getType()),
], [mxEvent]);
const onSend = ([eventType]: string[], content?: IContent) => {
return cli.setAccountData(eventType, content);
};
const defaultContent = mxEvent ? stringify(mxEvent.getContent()) : undefined;
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;
};
export const RoomAccountDataEventEditor = ({ mxEvent, onBack }: IEditorProps) => {
const context = useContext(DevtoolsContext);
const cli = useContext(MatrixClientContext);
const fields = useMemo(() => [
eventTypeField(mxEvent?.getType()),
], [mxEvent]);
const onSend = ([eventType]: string[], content?: IContent) => {
return cli.setRoomAccountData(context.room.roomId, eventType, content);
};
const defaultContent = mxEvent ? stringify(mxEvent.getContent()) : undefined;
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;
};
interface IProps extends IDevtoolsProps {
events: Record<string, MatrixEvent>;
Editor: React.FC<IEditorProps>;
actionLabel: string;
}
const BaseAccountDataExplorer = ({ events, Editor, actionLabel, onBack, setTool }: IProps) => {
const [query, setQuery] = useState("");
const [event, setEvent] = useState<MatrixEvent>(null);
if (event) {
const onBack = () => {
setEvent(null);
};
return <EventViewer mxEvent={event} onBack={onBack} Editor={Editor} />;
}
const onAction = async () => {
setTool(actionLabel, Editor);
};
return <BaseTool onBack={onBack} actionLabel={actionLabel} onAction={onAction}>
<FilteredList query={query} onChange={setQuery}>
{
Object.entries(events).map(([eventType, ev]) => {
const onClick = () => {
setEvent(ev);
};
return <button className="mx_DevTools_button" key={eventType} onClick={onClick}>
{ eventType }
</button>;
})
}
</FilteredList>
</BaseTool>;
};
export const AccountDataExplorer = ({ onBack, setTool }: IDevtoolsProps) => {
const cli = useContext(MatrixClientContext);
return <BaseAccountDataExplorer
events={cli.store.accountData}
Editor={AccountDataEventEditor}
actionLabel={_t("Send custom account data event")}
onBack={onBack}
setTool={setTool}
/>;
};
export const RoomAccountDataExplorer = ({ onBack, setTool }: IDevtoolsProps) => {
const context = useContext(DevtoolsContext);
return <BaseAccountDataExplorer
events={context.room.accountData}
Editor={RoomAccountDataEventEditor}
actionLabel={_t("Send custom room account data event")}
onBack={onBack}
setTool={setTool}
/>;
};

View file

@ -0,0 +1,88 @@
/*
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createContext, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import classNames from "classnames";
import { _t } from "../../../../languageHandler";
import { XOR } from "../../../../@types/common";
import { Tool } from "../DevtoolsDialog";
export interface IDevtoolsProps {
onBack(): void;
setTool(label: string, tool: Tool): void;
}
interface IMinProps extends Pick<IDevtoolsProps, "onBack"> {
className?: string;
}
interface IProps extends IMinProps {
actionLabel: string;
onAction(): Promise<string | void>;
}
const BaseTool: React.FC<XOR<IMinProps, IProps>> = ({ className, actionLabel, onBack, onAction, children }) => {
const [message, setMessage] = useState<string>(null);
const onBackClick = () => {
if (message) {
setMessage(null);
} else {
onBack();
}
};
let actionButton: JSX.Element;
if (message) {
children = message;
} else if (onAction) {
const onActionClick = () => {
onAction().then((msg) => {
if (typeof msg === "string") {
setMessage(msg);
}
});
};
actionButton = (
<button onClick={onActionClick}>
{ actionLabel }
</button>
);
}
return <>
<div className={classNames("mx_DevTools_content", className)}>
{ children }
</div>
<div className="mx_Dialog_buttons">
<button onClick={onBackClick}>
{ _t("Back") }
</button>
{ actionButton }
</div>
</>;
};
export default BaseTool;
interface IContext {
room: Room;
}
export const DevtoolsContext = createContext<IContext>({} as IContext);

View file

@ -0,0 +1,209 @@
/*
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext, useMemo, useRef, useState } from "react";
import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t, _td } from "../../../../languageHandler";
import Field from "../../elements/Field";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import withValidation from "../../elements/Validation";
import SyntaxHighlight from "../../elements/SyntaxHighlight";
export const stringify = (object: object): string => {
return JSON.stringify(object, null, 2);
};
interface IEventEditorProps extends Pick<IDevtoolsProps, "onBack"> {
fieldDefs: IFieldDef[]; // immutable
defaultContent?: string;
onSend(fields: string[], content?: IContent): Promise<unknown>;
}
interface IFieldDef {
id: string;
label: string; // _td
default?: string;
}
export const eventTypeField = (defaultValue?: string): IFieldDef => ({
id: "eventType",
label: _td("Event Type"),
default: defaultValue,
});
export const stateKeyField = (defaultValue?: string): IFieldDef => ({
id: "stateKey",
label: _td("State Key"),
default: defaultValue,
});
const validateEventContent = withValidation<any, Error | undefined>({
deriveData({ value }) {
try {
JSON.parse(value);
} catch (e) {
return e;
}
},
rules: [{
key: "validJson",
test: ({ value }, error) => {
if (!value) return true;
return !error;
},
invalid: (error) => _t("Doesn't look like valid JSON.") + " " + error,
}],
});
export const EventEditor = ({ fieldDefs, defaultContent = "{\n\n}", onSend, onBack }: IEventEditorProps) => {
const [fieldData, setFieldData] = useState<string[]>(fieldDefs.map(def => def.default ?? ""));
const [content, setContent] = useState<string>(defaultContent);
const contentField = useRef<Field>();
const fields = fieldDefs.map((def, i) => (
<Field
key={def.id}
id={def.id}
label={_t(def.label)}
size={42}
autoFocus={defaultContent === undefined && i === 0}
type="text"
autoComplete="on"
value={fieldData[i]}
onChange={ev => setFieldData(data => {
data[i] = ev.target.value;
return [...data];
})}
/>
));
const onAction = async () => {
const valid = await contentField.current.validate({});
if (!valid) {
contentField.current.focus();
contentField.current.validate({ focused: true });
return;
}
try {
const json = JSON.parse(content);
await onSend(fieldData, json);
} catch (e) {
return _t("Failed to send event!") + ` (${e.toString()})`;
}
return _t("Event sent!");
};
return <BaseTool
actionLabel={_t("Send")}
onAction={onAction}
onBack={onBack}
>
<div className="mx_DevTools_eventTypeStateKeyGroup">
{ fields }
</div>
<Field
id="evContent"
label={_t("Event Content")}
type="text"
className="mx_DevTools_textarea"
autoComplete="off"
value={content}
onChange={ev => setContent(ev.target.value)}
element="textarea"
onValidate={validateEventContent}
ref={contentField}
autoFocus={!!defaultContent}
/>
</BaseTool>;
};
export interface IEditorProps extends Pick<IDevtoolsProps, "onBack"> {
mxEvent?: MatrixEvent;
}
interface IViewerProps extends Required<IEditorProps> {
Editor: React.FC<Required<IEditorProps>>;
}
export const EventViewer = ({ mxEvent, onBack, Editor }: IViewerProps) => {
const [editing, setEditing] = useState(false);
if (editing) {
const onBack = () => {
setEditing(false);
};
return <Editor mxEvent={mxEvent} onBack={onBack} />;
}
const onAction = async () => {
setEditing(true);
};
return <BaseTool onBack={onBack} actionLabel={_t("Edit")} onAction={onAction}>
<SyntaxHighlight language="json">
{ stringify(mxEvent.event) }
</SyntaxHighlight>
</BaseTool>;
};
// returns the id of the initial message, not the id of the previous edit
const getBaseEventId = (baseEvent: MatrixEvent): string => {
// show the replacing event, not the original, if it is an edit
const mxEvent = baseEvent.replacingEvent() ?? baseEvent;
return mxEvent.getWireContent()["m.relates_to"]?.event_id ?? baseEvent.getId();
};
export const TimelineEventEditor = ({ mxEvent, onBack }: IEditorProps) => {
const context = useContext(DevtoolsContext);
const cli = useContext(MatrixClientContext);
const fields = useMemo(() => [
eventTypeField(mxEvent?.getType()),
], [mxEvent]);
const onSend = ([eventType]: string[], content?: IContent) => {
return cli.sendEvent(context.room.roomId, eventType, content);
};
let defaultContent: string;
if (mxEvent) {
const originalContent = mxEvent.getContent();
// prefill an edit-message event, keep only the `body` and `msgtype` fields of originalContent
const bodyToStartFrom = originalContent["m.new_content"]?.body ?? originalContent.body; // prefill the last edit body, to start editing from there
const newContent = {
"body": ` * ${bodyToStartFrom}`,
"msgtype": originalContent.msgtype,
"m.new_content": {
body: bodyToStartFrom,
msgtype: originalContent.msgtype,
},
"m.relates_to": {
rel_type: "m.replace",
event_id: getBaseEventId(mxEvent),
},
};
defaultContent = stringify(newContent);
}
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;
};

View file

@ -0,0 +1,90 @@
/*
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useState } from "react";
import { _t } from "../../../../languageHandler";
import Field from "../../elements/Field";
import TruncatedList from "../../elements/TruncatedList";
const INITIAL_LOAD_TILES = 20;
const LOAD_TILES_STEP_SIZE = 50;
interface IProps {
children: React.ReactElement[];
query: string;
onChange(value: string): void;
}
const FilteredList = ({ children, query, onChange }: IProps) => {
const [truncateAt, setTruncateAt] = useState<number>(INITIAL_LOAD_TILES);
const [filteredChildren, setFilteredChildren] = useState<React.ReactElement[]>(children);
useEffect(() => {
let filteredChildren = children;
if (query) {
const lcQuery = query.toLowerCase();
filteredChildren = children.filter((child) => child.key.toString().toLowerCase().includes(lcQuery));
}
setFilteredChildren(filteredChildren);
setTruncateAt(INITIAL_LOAD_TILES);
}, [children, query]);
const getChildren = (start: number, end: number): React.ReactElement[] => {
return filteredChildren.slice(start, end);
};
const getChildCount = (): number => {
return filteredChildren.length;
};
const createOverflowElement = (overflowCount: number, totalCount: number) => {
const showMore = () => {
setTruncateAt(num => num + LOAD_TILES_STEP_SIZE);
};
return <button className="mx_DevTools_button" onClick={showMore}>
{ _t("and %(count)s others...", { count: overflowCount }) }
</button>;
};
return <>
<Field
label={_t('Filter results')}
autoFocus={true}
size={64}
type="text"
autoComplete="off"
value={query}
onChange={ev => onChange(ev.target.value)}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used
key={children?.[0]?.key ?? ''}
/>
{ filteredChildren.length < 1
? _t("No results found")
: <TruncatedList
getChildren={getChildren}
getChildCount={getChildCount}
truncateAt={truncateAt}
createOverflowElement={createOverflowElement}
/>
}
</>;
};
export default FilteredList;

View file

@ -0,0 +1,132 @@
/*
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext, useEffect, useMemo, useState } from "react";
import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event";
import classNames from "classnames";
import { _t } from "../../../../languageHandler";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { EventEditor, EventViewer, eventTypeField, stateKeyField, IEditorProps, stringify } from "./Event";
import FilteredList from "./FilteredList";
export const StateEventEditor = ({ mxEvent, onBack }: IEditorProps) => {
const context = useContext(DevtoolsContext);
const cli = useContext(MatrixClientContext);
const fields = useMemo(() => [
eventTypeField(mxEvent?.getType()),
stateKeyField(mxEvent?.getStateKey()),
], [mxEvent]);
const onSend = ([eventType, stateKey]: string[], content?: IContent) => {
return cli.sendStateEvent(context.room.roomId, eventType, content, stateKey);
};
const defaultContent = mxEvent ? stringify(mxEvent.getContent()) : undefined;
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;
};
interface IEventTypeProps extends Pick<IDevtoolsProps, "onBack"> {
eventType: string;
}
const RoomStateExplorerEventType = ({ eventType, onBack }: IEventTypeProps) => {
const context = useContext(DevtoolsContext);
const [query, setQuery] = useState("");
const [event, setEvent] = useState<MatrixEvent>(null);
const events = context.room.currentState.events.get(eventType);
useEffect(() => {
if (events.size === 1 && events.has("")) {
setEvent(events.get(""));
} else {
setEvent(null);
}
}, [events]);
if (event) {
const onBack = () => {
setEvent(null);
};
return <EventViewer mxEvent={event} onBack={onBack} Editor={StateEventEditor} />;
}
return <BaseTool onBack={onBack}>
<FilteredList query={query} onChange={setQuery}>
{
Array.from(events.entries()).map(([stateKey, ev]) => {
const trimmed = stateKey.trim();
const onClick = () => {
setEvent(ev);
};
return <button
className={classNames("mx_DevTools_button", {
mx_DevTools_RoomStateExplorer_button_hasSpaces: trimmed.length !== stateKey.length,
mx_DevTools_RoomStateExplorer_button_emptyString: !trimmed,
})}
key={stateKey}
onClick={onClick}
>
{ trimmed ? stateKey : _t("<%(count)s spaces>", { count: stateKey.length }) }
</button>;
})
}
</FilteredList>
</BaseTool>;
};
export const RoomStateExplorer = ({ onBack, setTool }: IDevtoolsProps) => {
const context = useContext(DevtoolsContext);
const [query, setQuery] = useState("");
const [eventType, setEventType] = useState<string>(null);
const events = context.room.currentState.events;
if (eventType) {
const onBack = () => {
setEventType(null);
};
return <RoomStateExplorerEventType eventType={eventType} onBack={onBack} />;
}
const onAction = async () => {
setTool(_t("Send custom state event"), StateEventEditor);
};
return <BaseTool onBack={onBack} actionLabel={_t("Send custom state event")} onAction={onAction}>
<FilteredList query={query} onChange={setQuery}>
{
Array.from(events.keys()).map((eventType) => {
const onClick = () => {
setEventType(eventType);
};
return <button
className="mx_DevTools_button"
key={eventType}
onClick={onClick}
>
{ eventType }
</button>;
})
}
</FilteredList>
</BaseTool>;
};

View file

@ -0,0 +1,95 @@
/*
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext } from "react";
import BaseTool, { IDevtoolsProps } from "./BaseTool";
import { _t } from "../../../../languageHandler";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import Spinner from "../../elements/Spinner";
import SyntaxHighlight from "../../elements/SyntaxHighlight";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
const FAILED_TO_LOAD = Symbol("failed-to-load");
interface IServerWellKnown {
server: {
name: string;
version: string;
};
}
const ServerInfo = ({ onBack }: IDevtoolsProps) => {
const cli = useContext(MatrixClientContext);
const capabilities = useAsyncMemo(() => cli.getCapabilities(true).catch(() => FAILED_TO_LOAD), [cli]);
const clientVersions = useAsyncMemo(() => cli.getVersions().catch(() => FAILED_TO_LOAD), [cli]);
const serverVersions = useAsyncMemo<IServerWellKnown | symbol>(async () => {
let baseUrl = cli.getHomeserverUrl();
try {
const hsName = MatrixClientPeg.getHomeserverName();
// We don't use the js-sdk Autodiscovery module here as it only support client well-known, not server ones.
const response = await fetch(`https://${hsName}/.well-known/matrix/server`);
const json = await response.json();
if (json["m.server"]) {
baseUrl = `https://${json["m.server"]}`;
}
} catch (e) {
console.warn(e);
}
try {
const response = await fetch(`${baseUrl}/_matrix/federation/v1/version`);
return response.json();
} catch (e) {
console.warn(e);
}
return FAILED_TO_LOAD;
}, [cli]);
let body: JSX.Element;
if (!capabilities || !clientVersions || !serverVersions) {
body = <Spinner />;
} else {
body = <>
<h4>{ _t("Capabilities") }</h4>
{ capabilities !== FAILED_TO_LOAD
? <SyntaxHighlight language="json" children={JSON.stringify(capabilities, null, 4)} />
: <div>{ _t("Failed to load.") }</div>
}
<h4>{ _t("Client Versions") }</h4>
{ capabilities !== FAILED_TO_LOAD
? <SyntaxHighlight language="json" children={JSON.stringify(clientVersions, null, 4)} />
: <div>{ _t("Failed to load.") }</div>
}
<h4>{ _t("Server Versions") }</h4>
{ capabilities !== FAILED_TO_LOAD
? <SyntaxHighlight language="json" children={JSON.stringify(serverVersions, null, 4)} />
: <div>{ _t("Failed to load.") }</div>
}
</>;
}
return <BaseTool onBack={onBack}>
{ body }
</BaseTool>;
};
export default ServerInfo;

View file

@ -0,0 +1,56 @@
/*
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext, useMemo } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import { _t } from "../../../../languageHandler";
const ServersInRoom = ({ onBack }: IDevtoolsProps) => {
const context = useContext(DevtoolsContext);
const servers = useMemo<Record<string, number>>(() => {
const servers: Record<string, number> = {};
context.room.currentState.getStateEvents(EventType.RoomMember).forEach(ev => {
if (ev.getContent().membership !== "join") return; // only count joined users
const server = ev.getSender().split(":")[1];
servers[server] = (servers[server] ?? 0) + 1;
});
return servers;
}, [context.room]);
return <BaseTool onBack={onBack}>
<table>
<thead>
<tr>
<th>{ _t("Server") }</th>
<th>{ _t("Number of users") }</th>
</tr>
</thead>
<tbody>
{ Object.entries(servers).map(([server, numUsers]) => (
<tr key={server}>
<td>{ server }</td>
<td>{ numUsers }</td>
</tr>
)) }
</tbody>
</table>
</BaseTool>;
};
export default ServersInRoom;

View file

@ -0,0 +1,305 @@
/*
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext, useMemo, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../languageHandler";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import AccessibleButton from "../../elements/AccessibleButton";
import SettingsStore, { LEVEL_ORDER } from "../../../../settings/SettingsStore";
import { SettingLevel } from "../../../../settings/SettingLevel";
import { SETTINGS } from "../../../../settings/Settings";
import Field from "../../elements/Field";
const SettingExplorer = ({ onBack }: IDevtoolsProps) => {
const [setting, setSetting] = useState<string>(null);
const [editing, setEditing] = useState(false);
if (setting && editing) {
const onBack = () => {
setEditing(false);
};
return <EditSetting setting={setting} onBack={onBack} />;
} else if (setting) {
const onBack = () => {
setSetting(null);
};
const onEdit = async () => {
setEditing(true);
};
return <ViewSetting setting={setting} onBack={onBack} onEdit={onEdit} />;
} else {
const onView = (setting: string) => {
setSetting(setting);
};
const onEdit = (setting: string) => {
setSetting(setting);
setEditing(true);
};
return <SettingsList onBack={onBack} onView={onView} onEdit={onEdit} />;
}
};
export default SettingExplorer;
interface ICanEditLevelFieldProps {
setting: string;
level: SettingLevel;
roomId?: string;
}
const CanEditLevelField = ({ setting, roomId, level }: ICanEditLevelFieldProps) => {
const canEdit = SettingsStore.canSetValue(setting, roomId, level);
const className = canEdit ? "mx_DevTools_SettingsExplorer_mutable" : "mx_DevTools_SettingsExplorer_immutable";
return <td className={className}><code>{ canEdit.toString() }</code></td>;
};
function renderExplicitSettingValues(setting: string, roomId: string): string {
const vals = {};
for (const level of LEVEL_ORDER) {
try {
vals[level] = SettingsStore.getValueAt(level, setting, roomId, true, true);
if (vals[level] === undefined) {
vals[level] = null;
}
} catch (e) {
logger.warn(e);
}
}
return JSON.stringify(vals, null, 4);
}
interface IEditSettingProps extends Pick<IDevtoolsProps, "onBack"> {
setting: string;
}
const EditSetting = ({ setting, onBack }: IEditSettingProps) => {
const context = useContext(DevtoolsContext);
const [explicitValue, setExplicitValue] = useState(renderExplicitSettingValues(setting, null));
const [explicitRoomValue, setExplicitRoomValue] =
useState(renderExplicitSettingValues(setting, context.room.roomId));
const onSave = async () => {
try {
const parsedExplicit = JSON.parse(explicitValue);
const parsedExplicitRoom = JSON.parse(explicitRoomValue);
for (const level of Object.keys(parsedExplicit)) {
logger.log(`[Devtools] Setting value of ${setting} at ${level} from user input`);
try {
const val = parsedExplicit[level];
await SettingsStore.setValue(setting, null, level as SettingLevel, val);
} catch (e) {
logger.warn(e);
}
}
const roomId = context.room.roomId;
for (const level of Object.keys(parsedExplicit)) {
logger.log(`[Devtools] Setting value of ${setting} at ${level} in ${roomId} from user input`);
try {
const val = parsedExplicitRoom[level];
await SettingsStore.setValue(setting, roomId, level as SettingLevel, val);
} catch (e) {
logger.warn(e);
}
}
onBack();
} catch (e) {
return _t("Failed to save settings.") + ` (${e.message})`;
}
};
return <BaseTool onBack={onBack} actionLabel={_t("Save setting values")} onAction={onSave}>
<h3>{ _t("Setting:") } <code>{ setting }</code></h3>
<div className="mx_DevTools_SettingsExplorer_warning">
<b>{ _t("Caution:") }</b> { _t("This UI does NOT check the types of the values. Use at your own risk.") }
</div>
<div>
{ _t("Setting definition:") }
<pre><code>{ JSON.stringify(SETTINGS[setting], null, 4) }</code></pre>
</div>
<div>
<table>
<thead>
<tr>
<th>{ _t("Level") }</th>
<th>{ _t("Settable at global") }</th>
<th>{ _t("Settable at room") }</th>
</tr>
</thead>
<tbody>
{ LEVEL_ORDER.map(lvl => (
<tr key={lvl}>
<td><code>{ lvl }</code></td>
<CanEditLevelField setting={setting} level={lvl} />
<CanEditLevelField setting={setting} roomId={context.room.roomId} level={lvl} />
</tr>
)) }
</tbody>
</table>
</div>
<div>
<Field
id="valExpl"
label={_t("Values at explicit levels")}
type="text"
className="mx_DevTools_textarea"
element="textarea"
autoComplete="off"
value={explicitValue}
onChange={e => setExplicitValue(e.target.value)}
/>
</div>
<div>
<Field
id="valExpl"
label={_t("Values at explicit levels in this room")}
type="text"
className="mx_DevTools_textarea"
element="textarea"
autoComplete="off"
value={explicitRoomValue}
onChange={e => setExplicitRoomValue(e.target.value)}
/>
</div>
</BaseTool>;
};
interface IViewSettingProps extends Pick<IDevtoolsProps, "onBack"> {
setting: string;
onEdit(): Promise<void>;
}
const ViewSetting = ({ setting, onEdit, onBack }: IViewSettingProps) => {
const context = useContext(DevtoolsContext);
return <BaseTool onBack={onBack} actionLabel={_t("Edit values")} onAction={onEdit}>
<h3>{ _t("Setting:") } <code>{ setting }</code></h3>
<div>
{ _t("Setting definition:") }
<pre><code>{ JSON.stringify(SETTINGS[setting], null, 4) }</code></pre>
</div>
<div>
{ _t("Value:") }&nbsp;
<code>{ renderSettingValue(SettingsStore.getValue(setting)) }</code>
</div>
<div>
{ _t("Value in this room:") }&nbsp;
<code>{ renderSettingValue(SettingsStore.getValue(setting, context.room.roomId)) }</code>
</div>
<div>
{ _t("Values at explicit levels:") }
<pre><code>{ renderExplicitSettingValues(setting, null) }</code></pre>
</div>
<div>
{ _t("Values at explicit levels in this room:") }
<pre><code>{ renderExplicitSettingValues(setting, context.room.roomId) }</code></pre>
</div>
</BaseTool>;
};
function renderSettingValue(val: any): string {
// Note: we don't .toString() a string because we want JSON.stringify to inject quotes for us
const toStringTypes = ["boolean", "number"];
if (toStringTypes.includes(typeof(val))) {
return val.toString();
} else {
return JSON.stringify(val);
}
}
interface ISettingsListProps extends Pick<IDevtoolsProps, "onBack"> {
onView(setting: string): void;
onEdit(setting: string): void;
}
const SettingsList = ({ onBack, onView, onEdit }: ISettingsListProps) => {
const context = useContext(DevtoolsContext);
const [query, setQuery] = useState("");
const allSettings = useMemo(() => {
let allSettings = Object.keys(SETTINGS);
if (query) {
const lcQuery = query.toLowerCase();
allSettings = allSettings.filter(setting => setting.toLowerCase().includes(lcQuery));
}
return allSettings;
}, [query]);
return <BaseTool onBack={onBack} className="mx_DevTools_SettingsExplorer">
<Field
label={_t("Filter results")}
autoFocus={true}
size={64}
type="text"
autoComplete="off"
value={query}
onChange={ev => setQuery(ev.target.value)}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
/>
<table>
<thead>
<tr>
<th>{ _t("Setting ID") }</th>
<th>{ _t("Value") }</th>
<th>{ _t("Value in this room") }</th>
</tr>
</thead>
<tbody>
{ allSettings.map(i => (
<tr key={i}>
<td>
<AccessibleButton
kind="link_inline"
className="mx_DevTools_SettingsExplorer_setting"
onClick={() => onView(i)}
>
<code>{ i }</code>
</AccessibleButton>
<AccessibleButton
alt={_t("Edit setting")}
onClick={() => onEdit(i)}
className="mx_DevTools_SettingsExplorer_edit"
>
</AccessibleButton>
</td>
<td>
<code>{ renderSettingValue(SettingsStore.getValue(i)) }</code>
</td>
<td>
<code>
{ renderSettingValue(SettingsStore.getValue(i, context.room.roomId)) }
</code>
</td>
</tr>
)) }
</tbody>
</table>
</BaseTool>;
};

View file

@ -0,0 +1,101 @@
/*
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext, useEffect, useState } from "react";
import {
PHASE_CANCELLED,
PHASE_DONE,
PHASE_READY,
PHASE_REQUESTED,
PHASE_STARTED,
PHASE_UNSENT,
VerificationRequest,
VerificationRequestEvent,
} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { useTypedEventEmitter, useTypedEventEmitterState } from "../../../../hooks/useEventEmitter";
import { _t, _td } from "../../../../languageHandler";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
const PHASE_MAP = {
[PHASE_UNSENT]: _td("Unsent"),
[PHASE_REQUESTED]: _td("Requested"),
[PHASE_READY]: _td("Ready"),
[PHASE_DONE]: _td("Done"),
[PHASE_STARTED]: _td("Started"),
[PHASE_CANCELLED]: _td("Cancelled"),
};
const VerificationRequestExplorer: React.FC<{
txnId: string;
request: VerificationRequest;
}> = ({ txnId, request }) => {
const [, updateState] = useState();
const [timeout, setRequestTimeout] = useState(request.timeout);
/* Re-render if something changes state */
useTypedEventEmitter(request, VerificationRequestEvent.Change, updateState);
/* Keep re-rendering if there's a timeout */
useEffect(() => {
if (request.timeout == 0) return;
/* Note that request.timeout is a getter, so its value changes */
const id = setInterval(() => {
setRequestTimeout(request.timeout);
}, 500);
return () => { clearInterval(id); };
}, [request]);
return (<div className="mx_DevTools_VerificationRequest">
<dl>
<dt>{ _t("Transaction") }</dt>
<dd>{ txnId }</dd>
<dt>{ _t("Phase") }</dt>
<dd>{ PHASE_MAP[request.phase] || request.phase }</dd> // TODO
<dt>{ _t("Timeout") }</dt>
<dd>{ Math.floor(timeout / 1000) }</dd>
<dt>{ _t("Methods") }</dt>
<dd>{ request.methods && request.methods.join(", ") }</dd>
<dt>{ _t("Requester") }</dt>
<dd>{ request.requestingUserId }</dd>
<dt>{ _t("Observe only") }</dt>
<dd>{ JSON.stringify(request.observeOnly) }</dd>
</dl>
</div>);
};
const VerificationExplorer = ({ onBack }: IDevtoolsProps) => {
const cli = useContext(MatrixClientContext);
const context = useContext(DevtoolsContext);
const requests = useTypedEventEmitterState(cli, CryptoEvent.VerificationRequest, () => {
return cli.crypto.inRoomVerificationRequests["requestsByRoomId"]?.get(context.room.roomId)
?? new Map<string, VerificationRequest>();
});
return <BaseTool onBack={onBack}>
{ Array.from(requests.entries()).reverse().map(([txnId, request]) =>
<VerificationRequestExplorer txnId={txnId} request={request} key={txnId} />,
) }
{ requests.size < 1 && _t("No verification requests found") }
</BaseTool>;
};
export default VerificationExplorer;

View file

@ -0,0 +1,68 @@
/*
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext, useState } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { useEventEmitterState } from "../../../../hooks/useEventEmitter";
import { _t } from "../../../../languageHandler";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import WidgetStore, { IApp } from "../../../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../../../stores/AsyncStore";
import FilteredList from "./FilteredList";
import { StateEventEditor } from "./RoomState";
const WidgetExplorer = ({ onBack }: IDevtoolsProps) => {
const context = useContext(DevtoolsContext);
const [query, setQuery] = useState("");
const [widget, setWidget] = useState<IApp>(null);
const widgets = useEventEmitterState(WidgetStore.instance, UPDATE_EVENT, () => {
return WidgetStore.instance.getApps(context.room.roomId);
});
if (widget && widgets.includes(widget)) {
const onBack = () => {
setWidget(null);
};
const allState = Array.from(
Array.from(context.room.currentState.events.values()).map((e: Map<string, MatrixEvent>) => {
return e.values();
}),
).reduce((p, c) => { p.push(...c); return p; }, []);
const event = allState.find(ev => ev.getId() === widget.eventId);
if (!event) { // "should never happen"
return <BaseTool onBack={onBack}>
{ _t("There was an error finding this widget.") }
</BaseTool>;
}
return <StateEventEditor mxEvent={event} onBack={onBack} />;
}
return <BaseTool onBack={onBack}>
<FilteredList query={query} onChange={setQuery}>
{ widgets.map(w => (
<button className="mx_DevTools_button" key={w.url + w.eventId} onClick={() => setWidget(w)}>
{ w.url }
</button>
)) }
</FilteredList>
</BaseTool>;
};
export default WidgetExplorer;

View file

@ -30,6 +30,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleButton from "../elements/AccessibleButton";
import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
import ViewSource from "../../structures/ViewSource";
import SettingsStore from "../../../settings/SettingsStore";
function getReplacedContent(event) {
const originalContent = event.getOriginalContent();
@ -112,7 +113,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
private renderActionBar(): JSX.Element {
// hide the button when already redacted
let redactButton;
let redactButton: JSX.Element;
if (!this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact) {
redactButton = (
<AccessibleButton onClick={this.onRedactClick}>
@ -120,11 +121,16 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
</AccessibleButton>
);
}
const viewSourceButton = (
<AccessibleButton onClick={this.onViewSourceClick}>
{ _t("View Source") }
</AccessibleButton>
);
let viewSourceButton: JSX.Element;
if (SettingsStore.getValue("developerMode")) {
viewSourceButton = (
<AccessibleButton onClick={this.onViewSourceClick}>
{ _t("View Source") }
</AccessibleButton>
);
}
// disabled remove button when not allowed
return (
<div className="mx_MessageActionBar">

View file

@ -21,7 +21,6 @@ import { _t } from "../../../../../languageHandler";
import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton";
import RoomUpgradeDialog from "../../../dialogs/RoomUpgradeDialog";
import DevtoolsDialog from "../../../dialogs/DevtoolsDialog";
import Modal from "../../../../../Modal";
import dis from "../../../../../dispatcher/dispatcher";
import { Action } from '../../../../../dispatcher/actions';
@ -83,10 +82,6 @@ export default class AdvancedRoomSettingsTab extends React.Component<IProps, ISt
Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, { room });
};
private openDevtools = (e) => {
Modal.createDialog(DevtoolsDialog, { roomId: this.props.roomId });
};
private onOldRoomClicked = (e) => {
e.preventDefault();
e.stopPropagation();
@ -169,12 +164,6 @@ export default class AdvancedRoomSettingsTab extends React.Component<IProps, ISt
{ oldRoomLink }
{ roomUpgradeButton }
</div>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<span className='mx_SettingsTab_subheading'>{ _t("Developer options") }</span>
<AccessibleButton onClick={this.openDevtools} kind='primary'>
{ _t("Open Devtools") }
</AccessibleButton>
</div>
</div>
);
}

View file

@ -110,19 +110,6 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> {
/>,
);
groups.getOrCreate(LabGroup.Developer, []).push(
<SettingsFlag
key="developerMode"
name="developerMode"
level={SettingLevel.ACCOUNT}
/>,
<SettingsFlag
key="showHiddenEventsInTimeline"
name="showHiddenEventsInTimeline"
level={SettingLevel.DEVICE}
/>,
);
groups.getOrCreate(LabGroup.Analytics, []).push(
<SettingsFlag
key="automaticErrorReporting"

View file

@ -33,6 +33,10 @@ import { Icon as PinUprightIcon } from '../../../../res/img/element-icons/room/p
import { Icon as EllipsisIcon } from '../../../../res/img/element-icons/room/ellipsis.svg';
import { Icon as MembersIcon } from '../../../../res/img/element-icons/room/members.svg';
import { Icon as FavoriteIcon } from '../../../../res/img/element-icons/roomlist/favorite.svg';
import SettingsStore from "../../../settings/SettingsStore";
import Modal from "../../../Modal";
import DevtoolsDialog from "../dialogs/DevtoolsDialog";
import RoomViewStore from "../../../stores/RoomViewStore";
const QuickSettingsButton = ({ isPanelCollapsed = false }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
@ -63,6 +67,20 @@ const QuickSettingsButton = ({ isPanelCollapsed = false }) => {
{ _t("All settings") }
</AccessibleButton>
{ SettingsStore.getValue("developerMode") && (
<AccessibleButton
onClick={() => {
closeMenu();
Modal.createDialog(DevtoolsDialog, {
roomId: RoomViewStore.getRoomId(),
}, "mx_DevtoolsDialog_wrapper");
}}
kind="danger_outline"
>
{ _t("Developer tools") }
</AccessibleButton>
) }
<h4 className="mx_QuickSettingsButton_pinToSidebarHeading">
<PinUprightIcon className="mx_QuickSettingsButton_icon" />
{ _t("Pin to sidebar") }