diff --git a/res/css/structures/_QuickSettingsButton.scss b/res/css/structures/_QuickSettingsButton.scss index 17417fa36f..153dedf6b7 100644 --- a/res/css/structures/_QuickSettingsButton.scss +++ b/res/css/structures/_QuickSettingsButton.scss @@ -66,8 +66,9 @@ limitations under the License. margin: 0 0 16px; } - .mx_AccessibleButton_kind_primary_outline { + .mx_AccessibleButton_hasKind { display: block; + margin-top: 4px; } > div > h4 { diff --git a/res/css/views/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss index 37974d1358..ca82b82ee1 100644 --- a/res/css/views/dialogs/_DevtoolsDialog.scss +++ b/res/css/views/dialogs/_DevtoolsDialog.scss @@ -14,36 +14,40 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_DevTools_content { - margin: 10px 0; +.mx_DevtoolsDialog_wrapper { + .mx_Dialog { + height: 100%; + } + + .mx_Dialog_fixedWidth { + overflow-y: hidden; + height: 100%; + } } -.mx_DevTools_ServersInRoomList_button { - /* Set the cursor back to default as `.mx_Dialog button` sets it to pointer */ - cursor: default !important; +.mx_DevTools_content { + margin: 10px 0; + overflow-y: auto; + height: calc(100% - 124px); // 58px for buttons + 50px for header + 8px margin around } .mx_DevTools_RoomStateExplorer_query { margin-bottom: 10px; } -.mx_DevTools_RoomStateExplorer_button { - font-family: monospace; +.mx_DevTools_button { + font-family: monospace !important; + margin-bottom: 8px !important; } .mx_DevTools_RoomStateExplorer_button_hasSpaces { text-decoration: underline; } -.mx_DevTools_RoomStateExplorer_button.mx_DevTools_RoomStateExplorer_button_emptyString { +.mx_DevTools_button.mx_DevTools_RoomStateExplorer_button_emptyString { font-style: italic; } -.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_ServersInRoomList_button { - margin-bottom: 10px; - width: 100%; -} - .mx_DevTools_label_left { float: left; } @@ -83,108 +87,6 @@ limitations under the License. margin-right: 42px; } -.mx_DevTools_tgl { - display: none; - - // add default box-sizing for this scope - &, - &::after, - &::before, - & *, - & *::after, - & *::before, - & + .mx_DevTools_tgl-btn { - box-sizing: border-box; - &::selection { - background: none; - } - } - - + .mx_DevTools_tgl-btn { - outline: 0; - display: block; - width: 7em; - height: 2em; - position: relative; - cursor: pointer; - user-select: none; - &::after, - &::before { - position: relative; - display: block; - content: ""; - width: 50%; - height: 100%; - } - - &::after { - left: 0; - } - - &::before { - display: none; - } - } - - &:checked + .mx_DevTools_tgl-btn::after { - left: 50%; - } -} - -.mx_DevTools_tgl-flip { - + .mx_DevTools_tgl-btn { - padding: 2px; - transition: all .2s ease; - perspective: 100px; - &::after, - &::before { - display: inline-block; - transition: all .4s ease; - width: 100%; - text-align: center; - position: absolute; - line-height: 2em; - font-weight: bold; - color: #fff; - top: 0; - left: 0; - backface-visibility: hidden; - border-radius: 4px; - } - - &::after { - content: attr(data-tg-on); - background: #02c66f; - transform: rotateY(-180deg); - } - - &::before { - background: #ff3a19; - content: attr(data-tg-off); - } - - &:active::before { - transform: rotateY(-20deg); - } - } - - &:checked + .mx_DevTools_tgl-btn { - &::before { - transform: rotateY(180deg); - } - - &::after { - transform: rotateY(0); - left: 0; - background: #7fc6a6; - } - - &:active::after { - transform: rotateY(20deg); - } - } -} - .mx_DevTools_VerificationRequest { border: 1px solid #cccccc; border-radius: 3px; diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index ec6e5fe00a..bf19e0632f 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -292,6 +292,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/export.svg'); } + .mx_RoomTile_iconDeveloperTools::before { + mask-image: url('$(res)/img/element-icons/settings/flask.svg'); + } + .mx_RoomTile_iconCopyLink::before { mask-image: url('$(res)/img/element-icons/link.svg'); } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 2fb3702133..59a25cdef2 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -922,7 +922,7 @@ export const Commands = [ command: 'devtools', description: _td('Opens the Developer Tools dialog'), runFn: function(roomId) { - Modal.createDialog(DevtoolsDialog, { roomId }); + Modal.createDialog(DevtoolsDialog, { roomId }, "mx_DevtoolsDialog_wrapper"); return success(); }, category: CommandCategories.advanced, diff --git a/src/components/structures/ViewSource.tsx b/src/components/structures/ViewSource.tsx index 3b8250991c..bd2f3cdb29 100644 --- a/src/components/structures/ViewSource.tsx +++ b/src/components/structures/ViewSource.tsx @@ -22,12 +22,14 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import SyntaxHighlight from "../views/elements/SyntaxHighlight"; import { _t } from "../../languageHandler"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog"; import { canEditContent } from "../../utils/EventUtils"; import { MatrixClientPeg } from '../../MatrixClientPeg'; import { replaceableComponent } from "../../utils/replaceableComponent"; import { IDialogProps } from "../views/dialogs/IDialogProps"; import BaseDialog from "../views/dialogs/BaseDialog"; +import { DevtoolsContext } from "../views/dialogs/devtools/BaseTool"; +import { StateEventEditor } from "../views/dialogs/devtools/RoomState"; +import { stringify, TimelineEventEditor } from "../views/dialogs/devtools/Event"; interface IProps extends IDialogProps { mxEvent: MatrixEvent; // the MatrixEvent associated with the context menu @@ -47,10 +49,10 @@ export default class ViewSource extends React.Component { }; } - private onBack(): void { + private onBack = (): void => { // TODO: refresh the "Event ID:" modal header this.setState({ isEditing: false }); - } + }; private onEdit(): void { this.setState({ isEditing: true }); @@ -71,13 +73,13 @@ export default class ViewSource extends React.Component { { _t("Decrypted event source") } - { JSON.stringify(decryptedEventSource, null, 2) } + { stringify(decryptedEventSource) }
{ _t("Original event source") } - { JSON.stringify(originalEventSource, null, 2) } + { stringify(originalEventSource) }
); @@ -85,84 +87,40 @@ export default class ViewSource extends React.Component { return ( <>
{ _t("Original event source") }
- { JSON.stringify(originalEventSource, null, 2) } + { stringify(originalEventSource) } ); } } - // returns the id of the initial message, not the id of the previous edit - private getBaseEventId(): string { - const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit - const isEncrypted = mxEvent.isEncrypted(); - const baseMxEvent = this.props.mxEvent; - - if (isEncrypted) { - // `relates_to` field is inside the encrypted event - return mxEvent.event.content["m.relates_to"]?.event_id ?? baseMxEvent.getId(); - } else { - return mxEvent.getContent()["m.relates_to"]?.event_id ?? baseMxEvent.getId(); - } - } - // returns the SendCustomEvent component prefilled with the correct details private editSourceContent(): JSX.Element { const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit const isStateEvent = mxEvent.isState(); const roomId = mxEvent.getRoomId(); - const originalContent = mxEvent.getContent(); if (isStateEvent) { return ( { (cli) => ( - this.onBack()} - inputs={{ - eventType: mxEvent.getType(), - evContent: JSON.stringify(originalContent, null, "\t"), - stateKey: mxEvent.getStateKey(), - }} - /> - ) } - - ); - } else { - // 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: this.getBaseEventId(), - }, - }; - return ( - - { (cli) => ( - this.onBack()} - inputs={{ - eventType: mxEvent.getType(), - evContent: JSON.stringify(newContent, null, "\t"), - }} - /> + + + ) } ); } + + return ( + + { (cli) => ( + + + + ) } + + ); } private canSendStateEvent(mxEvent: MatrixEvent): boolean { @@ -181,8 +139,8 @@ export default class ViewSource extends React.Component { return (
-
Room ID: { roomId }
-
Event ID: { eventId }
+
{ _t("Room ID: %(roomId)s", { roomId }) }
+
{ _t("Event ID: %(eventId)s", { eventId }) }
{ isEditing ? this.editSourceContent() : this.viewSourceContent() }
diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 300cf42554..4f08139840 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -359,13 +359,16 @@ export default class MessageContextMenu extends React.Component } } - const viewSourceButton = ( - - ); + let viewSourceButton: JSX.Element; + if (SettingsStore.getValue("developerMode")) { + viewSourceButton = ( + + ); + } if (this.props.eventTileOps) { if (this.props.eventTileOps.isWidgetHidden()) { diff --git a/src/components/views/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx index d0bc235a9d..1d2ca8f171 100644 --- a/src/components/views/context_menus/RoomContextMenu.tsx +++ b/src/components/views/context_menus/RoomContextMenu.tsx @@ -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") && { + ev.preventDefault(); + ev.stopPropagation(); + + Modal.createDialog(DevtoolsDialog, { + roomId: RoomViewStore.getRoomId(), + }, "mx_DevtoolsDialog_wrapper"); + onFinished(); + }} + label={_t("Developer tools")} + iconClassName="mx_RoomTile_iconDeveloperTools" + /> } + { leaveOption } ; diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index f69041336c..14fb69b156 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> +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"); @@ -15,1319 +15,111 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useState, useEffect, ChangeEvent } from 'react'; -import { - PHASE_UNSENT, - PHASE_REQUESTED, - PHASE_READY, - PHASE_DONE, - PHASE_STARTED, - PHASE_CANCELLED, - VerificationRequest, - VerificationRequestEvent, -} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import { Room } from "matrix-js-sdk/src/models/room"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { logger } from "matrix-js-sdk/src/logger"; -import classNames from 'classnames'; +import React, { useState } from 'react'; -import SyntaxHighlight from '../elements/SyntaxHighlight'; -import { _t } from '../../../languageHandler'; -import Field from "../elements/Field"; +import { _t, _td } from '../../../languageHandler'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; -import WidgetStore, { IApp } from "../../../stores/WidgetStore"; -import { UPDATE_EVENT } from "../../../stores/AsyncStore"; -import { SETTINGS } from "../../../settings/Settings"; -import SettingsStore, { LEVEL_ORDER } from "../../../settings/SettingsStore"; -import Modal from "../../../Modal"; -import ErrorDialog from "./ErrorDialog"; -import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { SettingLevel } from '../../../settings/SettingLevel'; import BaseDialog from "./BaseDialog"; -import TruncatedList from "../elements/TruncatedList"; -import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; +import { TimelineEventEditor } from "./devtools/Event"; +import ServersInRoom from "./devtools/ServersInRoom"; +import VerificationExplorer from "./devtools/VerificationExplorer"; +import SettingExplorer from "./devtools/SettingExplorer"; +import { RoomStateExplorer } from "./devtools/RoomState"; +import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./devtools/BaseTool"; +import WidgetExplorer from './devtools/WidgetExplorer'; +import { AccountDataExplorer, RoomAccountDataExplorer } from "./devtools/AccountData"; +import SettingsFlag from "../elements/SettingsFlag"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import ServerInfo from './devtools/ServerInfo'; -interface IGenericEditorProps { - onBack: () => void; +enum Category { + Room, + Other, } -interface IGenericEditorState { - message?: string; - [inputId: string]: boolean | string; -} - -abstract class GenericEditor< - P extends IGenericEditorProps = IGenericEditorProps, - S extends IGenericEditorState = IGenericEditorState, -> extends React.PureComponent { - protected onBack = () => { - if (this.state.message) { - this.setState({ message: null }); - } else { - this.props.onBack(); - } - }; - - protected onChange = (e: ChangeEvent) => { - // @ts-ignore: Unsure how to convince TS this is okay when the state - // type can be extended. - this.setState({ [e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }); - }; - - protected abstract send(); - - protected buttons(): React.ReactNode { - return
- - { !this.state.message && } -
; - } - - protected textInput(id: string, label: string): React.ReactNode { - return ; - } -} - -interface ISendCustomEventProps extends IGenericEditorProps { - room: Room; - forceStateEvent?: boolean; - forceGeneralEvent?: boolean; - inputs?: { - eventType?: string; - stateKey?: string; - evContent?: string; - }; -} - -interface ISendCustomEventState extends IGenericEditorState { - isStateEvent: boolean; - eventType: string; - stateKey: string; - evContent: string; -} - -export class SendCustomEvent extends GenericEditor { - static getLabel() { return _t('Send Custom Event'); } - - static contextType = MatrixClientContext; - - constructor(props) { - super(props); - - const { eventType, stateKey, evContent } = Object.assign({ - eventType: '', - stateKey: '', - evContent: '{\n\n}', - }, this.props.inputs); - - this.state = { - isStateEvent: Boolean(this.props.forceStateEvent), - - eventType, - stateKey, - evContent, - }; - } - - private doSend(content: object): Promise { - const cli = this.context; - if (this.state.isStateEvent) { - return cli.sendStateEvent(this.props.room.roomId, this.state.eventType, content, this.state.stateKey); - } else { - return cli.sendEvent(this.props.room.roomId, this.state.eventType, content); - } - } - - protected send = async () => { - if (this.state.eventType === '') { - this.setState({ message: _t('You must specify an event type!') }); - return; - } - - let message; - try { - const content = JSON.parse(this.state.evContent); - await this.doSend(content); - message = _t('Event sent!'); - } catch (e) { - message = _t('Failed to send custom event.') + ' (' + e.toString() + ')'; - } - this.setState({ message }); - }; - - render() { - if (this.state.message) { - return
-
- { this.state.message } -
- { this.buttons() } -
; - } - - const showTglFlip = !this.state.message && !this.props.forceStateEvent && !this.props.forceGeneralEvent; - - return
-
-
- { this.textInput('eventType', _t('Event Type')) } - { this.state.isStateEvent && this.textInput('stateKey', _t('State Key')) } -
- -
- - -
-
- - { !this.state.message && } - { showTglFlip &&
- -
} -
-
; - } -} - -interface ISendAccountDataProps extends IGenericEditorProps { - room: Room; - isRoomAccountData: boolean; - forceMode: boolean; - inputs?: { - eventType?: string; - evContent?: string; - }; -} - -interface ISendAccountDataState extends IGenericEditorState { - isRoomAccountData: boolean; - eventType: string; - evContent: string; -} - -class SendAccountData extends GenericEditor { - static getLabel() { return _t('Send Account Data'); } - - static contextType = MatrixClientContext; - - constructor(props) { - super(props); - - const { eventType, evContent } = Object.assign({ - eventType: '', - evContent: '{\n\n}', - }, this.props.inputs); - - this.state = { - isRoomAccountData: Boolean(this.props.isRoomAccountData), - - eventType, - evContent, - }; - } - - private doSend(content: object): Promise { - const cli = this.context; - if (this.state.isRoomAccountData) { - return cli.setRoomAccountData(this.props.room.roomId, this.state.eventType, content); - } - return cli.setAccountData(this.state.eventType, content); - } - - protected send = async () => { - if (this.state.eventType === '') { - this.setState({ message: _t('You must specify an event type!') }); - return; - } - - let message; - try { - const content = JSON.parse(this.state.evContent); - await this.doSend(content); - message = _t('Event sent!'); - } catch (e) { - message = _t('Failed to send custom event.') + ' (' + e.toString() + ')'; - } - this.setState({ message }); - }; - - render() { - if (this.state.message) { - return
-
- { this.state.message } -
- { this.buttons() } -
; - } - - return
-
- { this.textInput('eventType', _t('Event Type')) } -
- - -
-
- - { !this.state.message && } - { !this.state.message &&
- -
} -
-
; - } -} - -const INITIAL_LOAD_TILES = 20; -const LOAD_TILES_STEP_SIZE = 50; - -interface IFilteredListProps { - children: React.ReactElement[]; - query: string; - onChange: (value: string) => void; -} - -interface IFilteredListState { - filteredChildren: React.ReactElement[]; - truncateAt: number; -} - -class FilteredList extends React.PureComponent { - static filterChildren(children: React.ReactElement[], query: string): React.ReactElement[] { - if (!query) return children; - const lcQuery = query.toLowerCase(); - return children.filter((child) => child.key.toString().toLowerCase().includes(lcQuery)); - } - - constructor(props) { - super(props); - - this.state = { - filteredChildren: FilteredList.filterChildren(this.props.children, this.props.query), - truncateAt: INITIAL_LOAD_TILES, - }; - } - - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line - if (this.props.children === nextProps.children && this.props.query === nextProps.query) return; - this.setState({ - filteredChildren: FilteredList.filterChildren(nextProps.children, nextProps.query), - truncateAt: INITIAL_LOAD_TILES, - }); - } - - private showAll = () => { - this.setState({ - truncateAt: this.state.truncateAt + LOAD_TILES_STEP_SIZE, - }); - }; - - private createOverflowElement = (overflowCount: number, totalCount: number) => { - return ; - }; - - private onQuery = (ev: ChangeEvent) => { - if (this.props.onChange) this.props.onChange(ev.target.value); - }; - - private getChildren = (start: number, end: number): React.ReactElement[] => { - return this.state.filteredChildren.slice(start, end); - }; - - private getChildCount = (): number => { - return this.state.filteredChildren.length; - }; - - render() { - return
- - - -
; - } -} - -interface IExplorerProps { - room: Room; - onBack: () => void; -} - -interface IRoomStateExplorerState { - eventType?: string; - event?: MatrixEvent; - editing: boolean; - queryEventType: string; - queryStateKey: string; -} - -class RoomStateExplorer extends React.PureComponent { - static getLabel() { return _t('Explore Room State'); } - - static contextType = MatrixClientContext; - - private roomStateEvents: Map>; - - constructor(props) { - super(props); - - this.roomStateEvents = this.props.room.currentState.events; - - this.state = { - eventType: null, - event: null, - editing: false, - - queryEventType: '', - queryStateKey: '', - }; - } - - private browseEventType(eventType: string) { - return () => { - this.setState({ eventType }); - }; - } - - private onViewSourceClick(event: MatrixEvent) { - return () => { - this.setState({ event }); - }; - } - - private onBack = () => { - if (this.state.editing) { - this.setState({ editing: false }); - } else if (this.state.event) { - this.setState({ event: null }); - } else if (this.state.eventType) { - this.setState({ eventType: null }); - } else { - this.props.onBack(); - } - }; - - private editEv = () => { - this.setState({ editing: true }); - }; - - private onQueryEventType = (filterEventType: string) => { - this.setState({ queryEventType: filterEventType }); - }; - - private onQueryStateKey = (filterStateKey: string) => { - this.setState({ queryStateKey: filterStateKey }); - }; - - render() { - if (this.state.event) { - if (this.state.editing) { - return ; - } - - return
-
- - { JSON.stringify(this.state.event.event, null, 2) } - -
-
- - -
-
; - } - - let list = null; - - const classes = 'mx_DevTools_RoomStateExplorer_button'; - if (this.state.eventType === null) { - list = - { - Array.from(this.roomStateEvents.entries()).map(([eventType, allStateKeys]) => { - let onClickFn; - if (allStateKeys.size === 1 && allStateKeys.has("")) { - onClickFn = this.onViewSourceClick(allStateKeys.get("")); - } else { - onClickFn = this.browseEventType(eventType); - } - - return ; - }) - } - ; - } else { - const stateGroup = this.roomStateEvents.get(this.state.eventType); - - list = - { - Array.from(stateGroup.entries()).map(([stateKey, ev]) => { - const trimmed = stateKey.trim(); - - return ; - }) - } - ; - } - - return
-
- { list } -
-
- -
-
; - } -} - -interface IAccountDataExplorerState { - [inputId: string]: boolean | string | any; - isRoomAccountData: boolean; - event?: MatrixEvent; - editing: boolean; - queryEventType: string; -} - -class AccountDataExplorer extends React.PureComponent { - static getLabel() { return _t('Explore Account Data'); } - - static contextType = MatrixClientContext; - - constructor(props) { - super(props); - - this.state = { - isRoomAccountData: false, - event: null, - editing: false, - - queryEventType: '', - }; - } - - private getData(): Record { - if (this.state.isRoomAccountData) { - return this.props.room.accountData; - } - return this.context.store.accountData; - } - - private onViewSourceClick(event: MatrixEvent) { - return () => { - this.setState({ event }); - }; - } - - private onBack = () => { - if (this.state.editing) { - this.setState({ editing: false }); - } else if (this.state.event) { - this.setState({ event: null }); - } else { - this.props.onBack(); - } - }; - - private onChange = (e: ChangeEvent) => { - this.setState({ [e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }); - }; - - private editEv = () => { - this.setState({ editing: true }); - }; - - private onQueryEventType = (queryEventType: string) => { - this.setState({ queryEventType }); - }; - - render() { - if (this.state.event) { - if (this.state.editing) { - return ; - } - - return
-
- - { JSON.stringify(this.state.event.event, null, 2) } - -
-
- - -
-
; - } - - const rows = []; - - const classes = 'mx_DevTools_RoomStateExplorer_button'; - - const data = this.getData(); - Object.keys(data).forEach((evType) => { - const ev = data[evType]; - rows.push(); - }); - - return
-
- - { rows } - -
-
- -
- -
-
-
; - } -} - -interface IServersInRoomListState { - query: string; -} - -class ServersInRoomList extends React.PureComponent { - static getLabel() { return _t('View Servers in Room'); } - - static contextType = MatrixClientContext; - - private servers: React.ReactElement[]; - - constructor(props) { - super(props); - - const room = this.props.room; - const servers = new Set(); - room.currentState.getStateEvents("m.room.member").forEach(ev => servers.add(ev.getSender().split(":")[1])); - this.servers = Array.from(servers).map(s => - ); - - this.state = { - query: '', - }; - } - - private onQuery = (query: string) => { - this.setState({ query }); - }; - - render() { - return
-
- - { this.servers } - -
-
- -
-
; - } -} - -const PHASE_MAP = { - [PHASE_UNSENT]: "unsent", - [PHASE_REQUESTED]: "requested", - [PHASE_READY]: "ready", - [PHASE_DONE]: "done", - [PHASE_STARTED]: "started", - [PHASE_CANCELLED]: "cancelled", +const categoryLabels: Record = { + [Category.Room]: _td("Room"), + [Category.Other]: _td("Other"), }; -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 (
-
-
Transaction
-
{ txnId }
-
Phase
-
{ PHASE_MAP[request.phase] || request.phase }
-
Timeout
-
{ Math.floor(timeout / 1000) }
-
Methods
-
{ request.methods && request.methods.join(", ") }
-
requestingUserId
-
{ request.requestingUserId }
-
observeOnly
-
{ JSON.stringify(request.observeOnly) }
-
-
); +export type Tool = React.FC; +const Tools: Record = { + [Category.Room]: [ + [_td("Send custom timeline event"), TimelineEventEditor], + [_td("Explore room state"), RoomStateExplorer], + [_td("Explore room account data"), RoomAccountDataExplorer], + [_td("View servers in room"), ServersInRoom], + [_td("Verification explorer"), VerificationExplorer], + [_td("Active Widgets"), WidgetExplorer], + ], + [Category.Other]: [ + [_td("Explore account data"), AccountDataExplorer], + [_td("Settings explorer"), SettingExplorer], + [_td("Server info"), ServerInfo], + ], }; -class VerificationExplorer extends React.PureComponent { - static getLabel() { - return _t("Verification Requests"); - } - - /* Ensure this.context is the cli */ - static contextType = MatrixClientContext; - - private onNewRequest = () => { - this.forceUpdate(); - }; - - componentDidMount() { - const cli = this.context; - cli.on("crypto.verification.request", this.onNewRequest); - } - - componentWillUnmount() { - const cli = this.context; - cli.off("crypto.verification.request", this.onNewRequest); - } - - render() { - const cli = this.context; - const room = this.props.room; - const inRoomChannel = cli.crypto.inRoomVerificationRequests; - const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map(); - - return (
-
- { Array.from(inRoomRequests.entries()).reverse().map(([txnId, request]) => - , - ) } -
-
- -
-
); - } -} - -interface IWidgetExplorerState { - query: string; - editWidget?: IApp; -} - -class WidgetExplorer extends React.Component { - static getLabel() { - return _t("Active Widgets"); - } - - constructor(props) { - super(props); - - this.state = { - query: '', - editWidget: null, // set to an IApp when editing - }; - } - - private onWidgetStoreUpdate = () => { - this.forceUpdate(); - }; - - private onQueryChange = (query: string) => { - this.setState({ query }); - }; - - private onEditWidget = (widget: IApp) => { - this.setState({ editWidget: widget }); - }; - - private onBack = () => { - const widgets = WidgetStore.instance.getApps(this.props.room.roomId); - if (this.state.editWidget && widgets.includes(this.state.editWidget)) { - this.setState({ editWidget: null }); - } else { - this.props.onBack(); - } - }; - - componentDidMount() { - WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); - } - - componentWillUnmount() { - WidgetStore.instance.off(UPDATE_EVENT, this.onWidgetStoreUpdate); - } - - render() { - const room = this.props.room; - - const editWidget = this.state.editWidget; - const widgets = WidgetStore.instance.getApps(room.roomId); - if (editWidget && widgets.includes(editWidget)) { - const allState = Array.from( - Array.from(room.currentState.events.values()).map((e: Map) => { - return e.values(); - }), - ).reduce((p, c) => { p.push(...c); return p; }, []); - const stateEv = allState.find(ev => ev.getId() === editWidget.eventId); - if (!stateEv) { // "should never happen" - return
- { _t("There was an error finding this widget.") } -
- -
-
; - } - return ; - } - - return (
-
- - { widgets.map(w => { - return ; - }) } - -
-
- -
-
); - } -} - -interface ISettingsExplorerState { - query: string; - editSetting?: string; - viewSetting?: string; - explicitValues?: string; - explicitRoomValues?: string; - } - -class SettingsExplorer extends React.PureComponent { - static getLabel() { - return _t("Settings Explorer"); - } - - constructor(props) { - super(props); - - this.state = { - query: '', - editSetting: null, // set to a setting ID when editing - viewSetting: null, // set to a setting ID when exploring in detail - - explicitValues: null, // stringified JSON for edit view - explicitRoomValues: null, // stringified JSON for edit view - }; - } - - private onQueryChange = (ev: ChangeEvent) => { - this.setState({ query: ev.target.value }); - }; - - private onExplValuesEdit = (ev: ChangeEvent) => { - this.setState({ explicitValues: ev.target.value }); - }; - - private onExplRoomValuesEdit = (ev: ChangeEvent) => { - this.setState({ explicitRoomValues: ev.target.value }); - }; - - private onBack = () => { - if (this.state.editSetting) { - this.setState({ editSetting: null }); - } else if (this.state.viewSetting) { - this.setState({ viewSetting: null }); - } else { - this.props.onBack(); - } - }; - - private onViewClick = (ev: ButtonEvent, settingId: string) => { - ev.preventDefault(); - this.setState({ viewSetting: settingId }); - }; - - private onEditClick = (ev: ButtonEvent, settingId: string) => { - ev.preventDefault(); - this.setState({ - editSetting: settingId, - explicitValues: this.renderExplicitSettingValues(settingId, null), - explicitRoomValues: this.renderExplicitSettingValues(settingId, this.props.room.roomId), - }); - }; - - private onSaveClick = async () => { - try { - const settingId = this.state.editSetting; - const parsedExplicit = JSON.parse(this.state.explicitValues); - const parsedExplicitRoom = JSON.parse(this.state.explicitRoomValues); - for (const level of Object.keys(parsedExplicit)) { - logger.log(`[Devtools] Setting value of ${settingId} at ${level} from user input`); - try { - const val = parsedExplicit[level]; - await SettingsStore.setValue(settingId, null, level as SettingLevel, val); - } catch (e) { - logger.warn(e); - } - } - const roomId = this.props.room.roomId; - for (const level of Object.keys(parsedExplicit)) { - logger.log(`[Devtools] Setting value of ${settingId} at ${level} in ${roomId} from user input`); - try { - const val = parsedExplicitRoom[level]; - await SettingsStore.setValue(settingId, roomId, level as SettingLevel, val); - } catch (e) { - logger.warn(e); - } - } - this.setState({ - viewSetting: settingId, - editSetting: null, - }); - } catch (e) { - Modal.createTrackedDialog('Devtools - Failed to save settings', '', ErrorDialog, { - title: _t("Failed to save settings"), - description: e.message, - }); - } - }; - - private 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); - } - } - - private 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); - } - - private renderCanEditLevel(roomId: string, level: SettingLevel): React.ReactNode { - const canEdit = SettingsStore.canSetValue(this.state.editSetting, roomId, level); - const className = canEdit ? 'mx_DevTools_SettingsExplorer_mutable' : 'mx_DevTools_SettingsExplorer_immutable'; - return { canEdit.toString() }; - } - - render() { - const room = this.props.room; - - if (!this.state.viewSetting && !this.state.editSetting) { - // view all settings - const allSettings = Object.keys(SETTINGS) - .filter(n => this.state.query ? n.toLowerCase().includes(this.state.query.toLowerCase()) : true); - return ( -
-
- - - - - - - - - - - { allSettings.map(i => ( - - - - - - )) } - -
{ _t("Setting ID") }{ _t("Value") }{ _t("Value in this room") }
- this.onViewClick(e, i)}> - { i } - - this.onEditClick(e, i)} - className='mx_DevTools_SettingsExplorer_edit' - > - ✏ - - - { this.renderSettingValue(SettingsStore.getValue(i)) } - - - { this.renderSettingValue(SettingsStore.getValue(i, room.roomId)) } - -
-
-
- -
-
- ); - } else if (this.state.editSetting) { - return ( -
-
-

{ _t("Setting:") } { this.state.editSetting }

- -
- { _t("Caution:") } { _t( - "This UI does NOT check the types of the values. Use at your own risk.", - ) } -
- -
- { _t("Setting definition:") } -
{ JSON.stringify(SETTINGS[this.state.editSetting], null, 4) }
-
- -
- - - - - - - - - - { LEVEL_ORDER.map(lvl => ( - - - { this.renderCanEditLevel(null, lvl) } - { this.renderCanEditLevel(room.roomId, lvl) } - - )) } - -
{ _t("Level") }{ _t("Settable at global") }{ _t("Settable at room") }
{ lvl }
-
- -
- -
- -
- -
- -
-
- - -
-
- ); - } else if (this.state.viewSetting) { - return ( -
-
-

{ _t("Setting:") } { this.state.viewSetting }

- -
- { _t("Setting definition:") } -
{ JSON.stringify(SETTINGS[this.state.viewSetting], null, 4) }
-
- -
- { _t("Value:") }  - { this.renderSettingValue( - SettingsStore.getValue(this.state.viewSetting), - ) } -
- -
- { _t("Value in this room:") }  - { this.renderSettingValue( - SettingsStore.getValue(this.state.viewSetting, room.roomId), - ) } -
- -
- { _t("Values at explicit levels:") } -
{ this.renderExplicitSettingValues(
-                                this.state.viewSetting, null,
-                            ) }
-
- -
- { _t("Values at explicit levels in this room:") } -
{ this.renderExplicitSettingValues(
-                                this.state.viewSetting, room.roomId,
-                            ) }
-
- -
-
- - -
-
- ); - } - } -} - -type DevtoolsDialogEntry = React.JSXElementConstructor & { - getLabel: () => string; -}; - -const Entries: DevtoolsDialogEntry[] = [ - SendCustomEvent, - RoomStateExplorer, - SendAccountData, - AccountDataExplorer, - ServersInRoomList, - VerificationExplorer, - WidgetExplorer, - SettingsExplorer, -]; - interface IProps { roomId: string; - onFinished: (finished: boolean) => void; + onFinished(finished: boolean): void; } -interface IState { - mode?: DevtoolsDialogEntry; -} +type ToolInfo = [label: string, tool: Tool]; -@replaceableComponent("views.dialogs.DevtoolsDialog") -export default class DevtoolsDialog extends React.PureComponent { - constructor(props) { - super(props); +const DevtoolsDialog: React.FC = ({ roomId, onFinished }) => { + const [tool, setTool] = useState(null); - this.state = { - mode: null, + let body: JSX.Element; + let onBack: () => void; + + if (tool) { + onBack = () => { + setTool(null); }; - } - private setMode(mode: DevtoolsDialogEntry) { - return () => { - this.setState({ mode }); + const Tool = tool[1]; + body = setTool([label, tool])} />; + } else { + const onBack = () => { + onFinished(false); }; + body = + { Object.entries(Tools).map(([category, tools]) => ( +
+

{ _t(categoryLabels[category]) }

+ { tools.map(([label, tool]) => { + const onClick = () => { + setTool([label, tool]); + }; + return ; + }) } +
+ )) } +
+

{ _t("Options") }

+ + +
+
; } - private onBack = () => { - this.setState({ mode: null }); - }; - - private onCancel = () => { - this.props.onFinished(false); - }; - - render() { - let body; - - if (this.state.mode) { - body = - { (cli) => -
{ this.state.mode.getLabel() }
-
Room ID: { this.props.roomId }
-
- - } - ; - } else { - const classes = "mx_DevTools_RoomStateExplorer_button"; - body = -
-
{ _t('Toolbox') }
-
Room ID: { this.props.roomId }
+ const label = tool ? tool[0] : _t("Toolbox"); + return ( + + + { (cli) => <> +
{ label }
+
{ _t("Room ID: %(roomId)s", { roomId }) }
+ + { body } + + } + + + ); +}; -
- { Entries.map((Entry) => { - const label = Entry.getLabel(); - const onClick = this.setMode(Entry); - return ; - }) } -
-
-
- -
- ; - } - - return ( - - { body } - - ); - } -} +export default DevtoolsDialog; diff --git a/src/components/views/dialogs/devtools/AccountData.tsx b/src/components/views/dialogs/devtools/AccountData.tsx new file mode 100644 index 0000000000..96daa62b0e --- /dev/null +++ b/src/components/views/dialogs/devtools/AccountData.tsx @@ -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 ; +}; + +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 ; +}; + +interface IProps extends IDevtoolsProps { + events: Record; + Editor: React.FC; + actionLabel: string; +} + +const BaseAccountDataExplorer = ({ events, Editor, actionLabel, onBack, setTool }: IProps) => { + const [query, setQuery] = useState(""); + const [event, setEvent] = useState(null); + + if (event) { + const onBack = () => { + setEvent(null); + }; + return ; + } + + const onAction = async () => { + setTool(actionLabel, Editor); + }; + + return + + { + Object.entries(events).map(([eventType, ev]) => { + const onClick = () => { + setEvent(ev); + }; + + return ; + }) + } + + ; +}; + +export const AccountDataExplorer = ({ onBack, setTool }: IDevtoolsProps) => { + const cli = useContext(MatrixClientContext); + + return ; +}; + +export const RoomAccountDataExplorer = ({ onBack, setTool }: IDevtoolsProps) => { + const context = useContext(DevtoolsContext); + + return ; +}; diff --git a/src/components/views/dialogs/devtools/BaseTool.tsx b/src/components/views/dialogs/devtools/BaseTool.tsx new file mode 100644 index 0000000000..dcc3756064 --- /dev/null +++ b/src/components/views/dialogs/devtools/BaseTool.tsx @@ -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 { + className?: string; +} + +interface IProps extends IMinProps { + actionLabel: string; + onAction(): Promise; +} + +const BaseTool: React.FC> = ({ className, actionLabel, onBack, onAction, children }) => { + const [message, setMessage] = useState(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 = ( + + ); + } + + return <> +
+ { children } +
+
+ + { actionButton } +
+ ; +}; + +export default BaseTool; + +interface IContext { + room: Room; +} + +export const DevtoolsContext = createContext({} as IContext); diff --git a/src/components/views/dialogs/devtools/Event.tsx b/src/components/views/dialogs/devtools/Event.tsx new file mode 100644 index 0000000000..75baa690cd --- /dev/null +++ b/src/components/views/dialogs/devtools/Event.tsx @@ -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 { + fieldDefs: IFieldDef[]; // immutable + defaultContent?: string; + onSend(fields: string[], content?: IContent): Promise; +} + +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({ + 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(fieldDefs.map(def => def.default ?? "")); + const [content, setContent] = useState(defaultContent); + const contentField = useRef(); + + const fields = fieldDefs.map((def, i) => ( + 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 +
+ { fields } +
+ + setContent(ev.target.value)} + element="textarea" + onValidate={validateEventContent} + ref={contentField} + autoFocus={!!defaultContent} + /> +
; +}; + +export interface IEditorProps extends Pick { + mxEvent?: MatrixEvent; +} + +interface IViewerProps extends Required { + Editor: React.FC>; +} + +export const EventViewer = ({ mxEvent, onBack, Editor }: IViewerProps) => { + const [editing, setEditing] = useState(false); + + if (editing) { + const onBack = () => { + setEditing(false); + }; + return ; + } + + const onAction = async () => { + setEditing(true); + }; + + return + + { stringify(mxEvent.event) } + + ; +}; + +// 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 ; +}; diff --git a/src/components/views/dialogs/devtools/FilteredList.tsx b/src/components/views/dialogs/devtools/FilteredList.tsx new file mode 100644 index 0000000000..34b1c9244a --- /dev/null +++ b/src/components/views/dialogs/devtools/FilteredList.tsx @@ -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(INITIAL_LOAD_TILES); + const [filteredChildren, setFilteredChildren] = useState(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 ; + }; + + return <> + 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") + : + } + ; +}; + +export default FilteredList; diff --git a/src/components/views/dialogs/devtools/RoomState.tsx b/src/components/views/dialogs/devtools/RoomState.tsx new file mode 100644 index 0000000000..d8a6fc0408 --- /dev/null +++ b/src/components/views/dialogs/devtools/RoomState.tsx @@ -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 ; +}; + +interface IEventTypeProps extends Pick { + eventType: string; +} + +const RoomStateExplorerEventType = ({ eventType, onBack }: IEventTypeProps) => { + const context = useContext(DevtoolsContext); + const [query, setQuery] = useState(""); + const [event, setEvent] = useState(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 ; + } + + return + + { + Array.from(events.entries()).map(([stateKey, ev]) => { + const trimmed = stateKey.trim(); + const onClick = () => { + setEvent(ev); + }; + + return ; + }) + } + + ; +}; + +export const RoomStateExplorer = ({ onBack, setTool }: IDevtoolsProps) => { + const context = useContext(DevtoolsContext); + const [query, setQuery] = useState(""); + const [eventType, setEventType] = useState(null); + + const events = context.room.currentState.events; + + if (eventType) { + const onBack = () => { + setEventType(null); + }; + return ; + } + + const onAction = async () => { + setTool(_t("Send custom state event"), StateEventEditor); + }; + + return + + { + Array.from(events.keys()).map((eventType) => { + const onClick = () => { + setEventType(eventType); + }; + + return ; + }) + } + + ; +}; diff --git a/src/components/views/dialogs/devtools/ServerInfo.tsx b/src/components/views/dialogs/devtools/ServerInfo.tsx new file mode 100644 index 0000000000..ff18d836e5 --- /dev/null +++ b/src/components/views/dialogs/devtools/ServerInfo.tsx @@ -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(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 = ; + } else { + body = <> +

{ _t("Capabilities") }

+ { capabilities !== FAILED_TO_LOAD + ? + :
{ _t("Failed to load.") }
+ } + +

{ _t("Client Versions") }

+ { capabilities !== FAILED_TO_LOAD + ? + :
{ _t("Failed to load.") }
+ } + +

{ _t("Server Versions") }

+ { capabilities !== FAILED_TO_LOAD + ? + :
{ _t("Failed to load.") }
+ } + ; + } + + return + { body } + ; +}; + +export default ServerInfo; diff --git a/src/components/views/dialogs/devtools/ServersInRoom.tsx b/src/components/views/dialogs/devtools/ServersInRoom.tsx new file mode 100644 index 0000000000..b8947c3a52 --- /dev/null +++ b/src/components/views/dialogs/devtools/ServersInRoom.tsx @@ -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>(() => { + const servers: Record = {}; + 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 + + + + + + + + + { Object.entries(servers).map(([server, numUsers]) => ( + + + + + )) } + +
{ _t("Server") }{ _t("Number of users") }
{ server }{ numUsers }
+
; +}; + +export default ServersInRoom; diff --git a/src/components/views/dialogs/devtools/SettingExplorer.tsx b/src/components/views/dialogs/devtools/SettingExplorer.tsx new file mode 100644 index 0000000000..1fe09675e1 --- /dev/null +++ b/src/components/views/dialogs/devtools/SettingExplorer.tsx @@ -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(null); + const [editing, setEditing] = useState(false); + + if (setting && editing) { + const onBack = () => { + setEditing(false); + }; + return ; + } else if (setting) { + const onBack = () => { + setSetting(null); + }; + const onEdit = async () => { + setEditing(true); + }; + return ; + } else { + const onView = (setting: string) => { + setSetting(setting); + }; + const onEdit = (setting: string) => { + setSetting(setting); + setEditing(true); + }; + return ; + } +}; + +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 { canEdit.toString() }; +}; + +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 { + 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 +

{ _t("Setting:") } { setting }

+ +
+ { _t("Caution:") } { _t("This UI does NOT check the types of the values. Use at your own risk.") } +
+ +
+ { _t("Setting definition:") } +
{ JSON.stringify(SETTINGS[setting], null, 4) }
+
+ +
+ + + + + + + + + + { LEVEL_ORDER.map(lvl => ( + + + + + + )) } + +
{ _t("Level") }{ _t("Settable at global") }{ _t("Settable at room") }
{ lvl }
+
+ +
+ setExplicitValue(e.target.value)} + /> +
+ +
+ setExplicitRoomValue(e.target.value)} + /> +
+
; +}; + +interface IViewSettingProps extends Pick { + setting: string; + onEdit(): Promise; +} + +const ViewSetting = ({ setting, onEdit, onBack }: IViewSettingProps) => { + const context = useContext(DevtoolsContext); + + return +

{ _t("Setting:") } { setting }

+ +
+ { _t("Setting definition:") } +
{ JSON.stringify(SETTINGS[setting], null, 4) }
+
+ +
+ { _t("Value:") }  + { renderSettingValue(SettingsStore.getValue(setting)) } +
+ +
+ { _t("Value in this room:") }  + { renderSettingValue(SettingsStore.getValue(setting, context.room.roomId)) } +
+ +
+ { _t("Values at explicit levels:") } +
{ renderExplicitSettingValues(setting, null) }
+
+ +
+ { _t("Values at explicit levels in this room:") } +
{ renderExplicitSettingValues(setting, context.room.roomId) }
+
+
; +}; + +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 { + 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 + setQuery(ev.target.value)} + className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query" + /> + + + + + + + + + + { allSettings.map(i => ( + + + + + + )) } + +
{ _t("Setting ID") }{ _t("Value") }{ _t("Value in this room") }
+ onView(i)} + > + { i } + + onEdit(i)} + className="mx_DevTools_SettingsExplorer_edit" + > + ✏ + + + { renderSettingValue(SettingsStore.getValue(i)) } + + + { renderSettingValue(SettingsStore.getValue(i, context.room.roomId)) } + +
+
; +}; diff --git a/src/components/views/dialogs/devtools/VerificationExplorer.tsx b/src/components/views/dialogs/devtools/VerificationExplorer.tsx new file mode 100644 index 0000000000..157250be6e --- /dev/null +++ b/src/components/views/dialogs/devtools/VerificationExplorer.tsx @@ -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 (
+
+
{ _t("Transaction") }
+
{ txnId }
+
{ _t("Phase") }
+
{ PHASE_MAP[request.phase] || request.phase }
// TODO +
{ _t("Timeout") }
+
{ Math.floor(timeout / 1000) }
+
{ _t("Methods") }
+
{ request.methods && request.methods.join(", ") }
+
{ _t("Requester") }
+
{ request.requestingUserId }
+
{ _t("Observe only") }
+
{ JSON.stringify(request.observeOnly) }
+
+
); +}; + +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(); + }); + + return + { Array.from(requests.entries()).reverse().map(([txnId, request]) => + , + ) } + { requests.size < 1 && _t("No verification requests found") } + ; +}; + +export default VerificationExplorer; diff --git a/src/components/views/dialogs/devtools/WidgetExplorer.tsx b/src/components/views/dialogs/devtools/WidgetExplorer.tsx new file mode 100644 index 0000000000..3d8f4b8471 --- /dev/null +++ b/src/components/views/dialogs/devtools/WidgetExplorer.tsx @@ -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(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) => { + 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 + { _t("There was an error finding this widget.") } + ; + } + + return ; + } + + return + + { widgets.map(w => ( + + )) } + + ; +}; + +export default WidgetExplorer; diff --git a/src/components/views/messages/EditHistoryMessage.tsx b/src/components/views/messages/EditHistoryMessage.tsx index e0edbff046..116bfe8268 100644 --- a/src/components/views/messages/EditHistoryMessage.tsx +++ b/src/components/views/messages/EditHistoryMessage.tsx @@ -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 @@ -120,11 +121,16 @@ export default class EditHistoryMessage extends React.PureComponent ); } - const viewSourceButton = ( - - { _t("View Source") } - - ); + + let viewSourceButton: JSX.Element; + if (SettingsStore.getValue("developerMode")) { + viewSourceButton = ( + + { _t("View Source") } + + ); + } + // disabled remove button when not allowed return (
diff --git a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx index fd5aadc90f..97f388d893 100644 --- a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx @@ -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 { - 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 -
- { _t("Developer options") } - - { _t("Open Devtools") } - -
); } diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx index cb028ed3ec..0ae1a85c40 100644 --- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx @@ -110,19 +110,6 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> { />, ); - groups.getOrCreate(LabGroup.Developer, []).push( - , - , - ); - groups.getOrCreate(LabGroup.Analytics, []).push( { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); @@ -63,6 +67,20 @@ const QuickSettingsButton = ({ isPanelCollapsed = false }) => { { _t("All settings") } + { SettingsStore.getValue("developerMode") && ( + { + closeMenu(); + Modal.createDialog(DevtoolsDialog, { + roomId: RoomViewStore.getRoomId(), + }, "mx_DevtoolsDialog_wrapper"); + }} + kind="danger_outline" + > + { _t("Developer tools") } + + ) } +

{ _t("Pin to sidebar") } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 40936213a8..6d6699045c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -931,7 +931,6 @@ "Enable URL previews by default for participants in this room": "Enable URL previews by default for participants in this room", "Enable widget screenshots on supported widgets": "Enable widget screenshots on supported widgets", "Prompt before sending invites to potentially invalid matrix IDs": "Prompt before sending invites to potentially invalid matrix IDs", - "Show developer tools": "Show developer tools", "Order rooms by name": "Order rooms by name", "Show rooms with unread notifications first": "Show rooms with unread notifications first", "Show shortcuts to recently viewed rooms above the room list": "Show shortcuts to recently viewed rooms above the room list", @@ -1104,6 +1103,7 @@ "Accept to continue:": "Accept to continue:", "Quick settings": "Quick settings", "All settings": "All settings", + "Developer tools": "Developer tools", "Pin to sidebar": "Pin to sidebar", "More options": "More options", "Settings": "Settings", @@ -1523,8 +1523,6 @@ "Internal room ID": "Internal room ID", "Room version": "Room version", "Room version:": "Room version:", - "Developer options": "Developer options", - "Open Devtools": "Open Devtools", "This room is bridging messages to the following platforms. Learn more.": "This room is bridging messages to the following platforms. Learn more.", "This room isn't bridging messages to any platforms. Learn more.": "This room isn't bridging messages to any platforms. Learn more.", "Bridges": "Bridges", @@ -2447,48 +2445,19 @@ "Deactivating your account does not by default cause us to forget messages you have sent. If you would like us to forget your messages, please tick the box below.": "Deactivating your account does not by default cause us to forget messages you have sent. If you would like us to forget your messages, please tick the box below.", "Message visibility in Matrix is similar to email. Our forgetting your messages means that messages you have sent will not be shared with any new or unregistered users, but registered users who already have access to these messages will still have access to their copy.": "Message visibility in Matrix is similar to email. Our forgetting your messages means that messages you have sent will not be shared with any new or unregistered users, but registered users who already have access to these messages will still have access to their copy.", "Please forget all messages I have sent when my account is deactivated (Warning: this will cause future users to see an incomplete view of conversations)": "Please forget all messages I have sent when my account is deactivated (Warning: this will cause future users to see an incomplete view of conversations)", - "Send": "Send", - "Send Custom Event": "Send Custom Event", - "You must specify an event type!": "You must specify an event type!", - "Event sent!": "Event sent!", - "Failed to send custom event.": "Failed to send custom event.", - "Event Type": "Event Type", - "State Key": "State Key", - "Event Content": "Event Content", - "Send Account Data": "Send Account Data", - "Filter results": "Filter results", - "Explore Room State": "Explore Room State", - "<%(count)s spaces>|other": "<%(count)s spaces>", - "<%(count)s spaces>|one": "", - "<%(count)s spaces>|zero": "", - "Explore Account Data": "Explore Account Data", - "View Servers in Room": "View Servers in Room", - "Verification Requests": "Verification Requests", + "Room": "Room", + "Send custom timeline event": "Send custom timeline event", + "Explore room state": "Explore room state", + "Explore room account data": "Explore room account data", + "View servers in room": "View servers in room", + "Verification explorer": "Verification explorer", "Active Widgets": "Active Widgets", - "There was an error finding this widget.": "There was an error finding this widget.", - "Settings Explorer": "Settings Explorer", - "Failed to save settings": "Failed to save settings", - "Setting ID": "Setting ID", - "Value": "Value", - "Value in this room": "Value in this room", - "Edit setting": "Edit setting", - "Setting:": "Setting:", - "Caution:": "Caution:", - "This UI does NOT check the types of the values. Use at your own risk.": "This UI does NOT check the types of the values. Use at your own risk.", - "Setting definition:": "Setting definition:", - "Level": "Level", - "Settable at global": "Settable at global", - "Settable at room": "Settable at room", - "Values at explicit levels": "Values at explicit levels", - "Values at explicit levels in this room": "Values at explicit levels in this room", - "Save setting values": "Save setting values", - "Value:": "Value:", - "Value in this room:": "Value in this room:", - "Values at explicit levels:": "Values at explicit levels:", - "Values at explicit levels in this room:": "Values at explicit levels in this room:", - "Edit Values": "Edit Values", + "Explore account data": "Explore account data", + "Settings explorer": "Settings explorer", + "Server info": "Server info", "Toolbox": "Toolbox", "Developer Tools": "Developer Tools", + "Room ID: %(roomId)s": "Room ID: %(roomId)s", "The poll has ended. No votes were cast.": "The poll has ended. No votes were cast.", "The poll has ended. Top answer: %(topAnswer)s": "The poll has ended. Top answer: %(topAnswer)s", "Failed to end poll": "Failed to end poll", @@ -2527,6 +2496,7 @@ "Sending": "Sending", "Sent": "Sent", "Open link": "Open link", + "Send": "Send", "Forward message": "Forward message", "Message preview": "Message preview", "Search for rooms or people": "Search for rooms or people", @@ -2825,6 +2795,59 @@ "Warning: You should only set up key backup from a trusted computer.": "Warning: You should only set up key backup from a trusted computer.", "Access your secure message history and set up secure messaging by entering your Security Key.": "Access your secure message history and set up secure messaging by entering your Security Key.", "If you've forgotten your Security Key you can ": "If you've forgotten your Security Key you can ", + "Send custom account data event": "Send custom account data event", + "Send custom room account data event": "Send custom room account data event", + "Event Type": "Event Type", + "State Key": "State Key", + "Doesn't look like valid JSON.": "Doesn't look like valid JSON.", + "Failed to send event!": "Failed to send event!", + "Event sent!": "Event sent!", + "Event Content": "Event Content", + "Filter results": "Filter results", + "No results found": "No results found", + "<%(count)s spaces>|other": "<%(count)s spaces>", + "<%(count)s spaces>|one": "", + "<%(count)s spaces>|zero": "", + "Send custom state event": "Send custom state event", + "Capabilities": "Capabilities", + "Failed to load.": "Failed to load.", + "Client Versions": "Client Versions", + "Server Versions": "Server Versions", + "Server": "Server", + "Number of users": "Number of users", + "Failed to save settings.": "Failed to save settings.", + "Save setting values": "Save setting values", + "Setting:": "Setting:", + "Caution:": "Caution:", + "This UI does NOT check the types of the values. Use at your own risk.": "This UI does NOT check the types of the values. Use at your own risk.", + "Setting definition:": "Setting definition:", + "Level": "Level", + "Settable at global": "Settable at global", + "Settable at room": "Settable at room", + "Values at explicit levels": "Values at explicit levels", + "Values at explicit levels in this room": "Values at explicit levels in this room", + "Edit values": "Edit values", + "Value:": "Value:", + "Value in this room:": "Value in this room:", + "Values at explicit levels:": "Values at explicit levels:", + "Values at explicit levels in this room:": "Values at explicit levels in this room:", + "Setting ID": "Setting ID", + "Value": "Value", + "Value in this room": "Value in this room", + "Edit setting": "Edit setting", + "Unsent": "Unsent", + "Requested": "Requested", + "Ready": "Ready", + "Started": "Started", + "Cancelled": "Cancelled", + "Transaction": "Transaction", + "Phase": "Phase", + "Timeout": "Timeout", + "Methods": "Methods", + "Requester": "Requester", + "Observe only": "Observe only", + "No verification requests found": "No verification requests found", + "There was an error finding this widget.": "There was an error finding this widget.", "Resume": "Resume", "Hold": "Hold", "Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)", @@ -2838,7 +2861,6 @@ "Forget": "Forget", "Mentions only": "Mentions only", "See room timeline (devtools)": "See room timeline (devtools)", - "Room": "Room", "Space": "Space", "Space home": "Space home", "Manage & explore rooms": "Manage & explore rooms", @@ -3019,7 +3041,6 @@ "Mark as suggested": "Mark as suggested", "Failed to load list of rooms.": "Failed to load list of rooms.", "Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.", - "No results found": "No results found", "You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.", "Results": "Results", "Rooms and spaces": "Rooms and spaces", @@ -3087,6 +3108,7 @@ "Could not load user profile": "Could not load user profile", "Decrypted event source": "Decrypted event source", "Original event source": "Original event source", + "Event ID: %(eventId)s": "Event ID: %(eventId)s", "Unable to verify this device": "Unable to verify this device", "Verify this device": "Verify this device", "Device verified": "Device verified", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 3fb228a919..f8e7bb6a30 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -736,11 +736,6 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td('Prompt before sending invites to potentially invalid matrix IDs'), default: true, }, - "showDeveloperTools": { - supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: _td('Show developer tools'), - default: false, - }, "widgetOpenIDPermissions": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, default: {