diff --git a/src/components/views/dialogs/DevtoolsDialog.js b/src/components/views/dialogs/DevtoolsDialog.js index 7760ea8418..a7155ad12d 100644 --- a/src/components/views/dialogs/DevtoolsDialog.js +++ b/src/components/views/dialogs/DevtoolsDialog.js @@ -15,35 +15,24 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from 'matrix-react-sdk'; import { _t } from 'matrix-react-sdk/lib/languageHandler'; import MatrixClientPeg from 'matrix-react-sdk/lib/MatrixClientPeg'; -class SendCustomEvent extends React.Component { - static propTypes = { - roomId: React.PropTypes.string.isRequired, - onBack: React.PropTypes.func.isRequired, - - eventType: React.PropTypes.string.isRequired, - evContent: React.PropTypes.string.isRequired, +class DevtoolsComponent extends React.Component { + static contextTypes = { + roomId: PropTypes.string.isRequired, }; +} - static defaultProps = { - eventType: '', - evContent: '{\n\n}', - }; +class GenericEditor extends DevtoolsComponent { + // static propTypes = {onBack: PropTypes.func.isRequired}; constructor(props, context) { super(props, context); - this._send = this._send.bind(this); - this.onBack = this.onBack.bind(this); this._onChange = this._onChange.bind(this); - - this.state = { - message: null, - input_eventType: this.props.eventType, - input_evContent: this.props.evContent, - }; + this.onBack = this.onBack.bind(this); } onBack() { @@ -54,6 +43,10 @@ class SendCustomEvent extends React.Component { } } + _onChange(e) { + this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); + } + _buttons() { return <div className="mx_Dialog_buttons"> <button onClick={this.onBack}>{ _t('Back') }</button> @@ -61,19 +54,64 @@ class SendCustomEvent extends React.Component { </div>; } + textInput(id, label) { + return <div className="mx_DevTools_inputRow"> + <div className="mx_DevTools_inputLabelCell"> + <label htmlFor={id}>{ label }</label> + </div> + <div className="mx_DevTools_inputCell"> + <input id={id} onChange={this._onChange} value={this.state[id]} size="32" /> + </div> + </div>; + } +} + +class SendCustomEvent extends GenericEditor { + static getLabel() { return _t('Send Custom Event'); } + + static propTypes = { + onBack: PropTypes.func.isRequired, + forceStateEvent: PropTypes.bool, + inputs: PropTypes.object, + }; + + constructor(props, context) { + super(props, context); + this._send = this._send.bind(this); + + const {eventType, stateKey, evContent} = Object.assign({ + eventType: '', + stateKey: '', + evContent: '{\n\n}', + }, this.props.inputs); + + this.state = { + isStateEvent: Boolean(this.props.forceStateEvent), + + eventType, + stateKey, + evContent, + }; + } + send(content) { - return MatrixClientPeg.get().sendEvent(this.props.roomId, this.state.input_eventType, content); + const cli = MatrixClientPeg.get(); + if (this.state.isStateEvent) { + return cli.sendStateEvent(this.context.roomId, this.state.eventType, content, this.state.stateKey); + } else { + return cli.sendEvent(this.context.roomId, this.state.eventType, content); + } } async _send() { - if (this.state.input_eventType === '') { + if (this.state.eventType === '') { this.setState({ message: _t('You must specify an event type!') }); return; } let message; try { - const content = JSON.parse(this.state.input_evContent); + const content = JSON.parse(this.state.evContent); await this.send(content); message = _t('Event sent!'); } catch (e) { @@ -82,14 +120,6 @@ class SendCustomEvent extends React.Component { this.setState({ message }); } - _additionalFields() { - return <div />; - } - - _onChange(e) { - this.setState({[`input_${e.target.id}`]: e.target.value}); - } - render() { if (this.state.message) { return <div> @@ -102,87 +132,176 @@ class SendCustomEvent extends React.Component { return <div> <div className="mx_Dialog_content"> - { this._additionalFields() } - <div className="mx_TextInputDialog_label"> - <label htmlFor="eventType"> { _t('Event Type') } </label> - </div> - <div> - <input id="eventType" onChange={this._onChange} value={this.state.input_eventType} className="mx_TextInputDialog_input" size="64" /> - </div> + { this.textInput('eventType', _t('Event Type')) } + { this.state.isStateEvent && this.textInput('stateKey', _t('State Key')) } - <div className="mx_TextInputDialog_label"> + <br /> + + <div className="mx_UserSettings_profileLabelCell"> <label htmlFor="evContent"> { _t('Event Content') } </label> </div> <div> - <textarea id="evContent" onChange={this._onChange} value={this.state.input_evContent} className="mx_TextInputDialog_input" cols="63" rows="5" /> + <textarea id="evContent" onChange={this._onChange} value={this.state.evContent} className="mx_TextInputDialog_input" cols="63" rows="5" /> </div> </div> - { this._buttons() } + <div className="mx_Dialog_buttons"> + <button onClick={this.onBack}>{ _t('Back') }</button> + { !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> } + { !this.state.message && !this.props.forceStateEvent && <div style={{float: "right"}}> + <input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isStateEvent} /> + <label className="mx_DevTools_tgl-btn" data-tg-off="Event" data-tg-on="State Event" htmlFor="isStateEvent" /> + </div> } + </div> </div>; } } -class SendCustomStateEvent extends SendCustomEvent { +class SendAccountData extends GenericEditor { + static getLabel() { return _t('Send Account Data'); } + static propTypes = { - roomId: React.PropTypes.string.isRequired, - onBack: React.PropTypes.func.isRequired, - - eventType: React.PropTypes.string.isRequired, - evContent: React.PropTypes.string.isRequired, - stateKey: React.PropTypes.string.isRequired, - }; - - static defaultProps = { - eventType: '', - evContent: '{\n\n}', - stateKey: '', + isRoomAccountData: PropTypes.bool, + forceMode: PropTypes.bool, + inputs: PropTypes.object, }; constructor(props, context) { super(props, context); - this.state['input_stateKey'] = this.props.stateKey; + this._send = this._send.bind(this); + + const {eventType, evContent} = Object.assign({ + eventType: '', + evContent: '{\n\n}', + }, this.props.inputs); + + this.state = { + isRoomAccountData: Boolean(this.props.isRoomAccountData), + + eventType, + evContent, + }; } send(content) { const cli = MatrixClientPeg.get(); - return cli.sendStateEvent(this.props.roomId, this.state.input_eventType, content, this.state.input_stateKey); + if (this.state.isRoomAccountData) { + return cli.setRoomAccountData(this.context.roomId, this.state.eventType, content); + } + return cli.setAccountData(this.state.eventType, content); } - _additionalFields() { + async _send() { + 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.send(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 <div> + <div className="mx_Dialog_content"> + { this.state.message } + </div> + { this._buttons() } + </div>; + } + return <div> - <div className="mx_TextInputDialog_label"> - <label htmlFor="stateKey"> { _t('State Key') } </label> + <div className="mx_Dialog_content"> + { this.textInput('eventType', _t('Event Type')) } + <br /> + + <div className="mx_UserSettings_profileLabelCell"> + <label htmlFor="evContent"> { _t('Event Content') } </label> + </div> + <div> + <textarea id="evContent" onChange={this._onChange} value={this.state.evContent} className="mx_TextInputDialog_input" cols="63" rows="5" /> + </div> </div> - <div> - <input id="stateKey" onChange={this._onChange} value={this.state.input_stateKey} className="mx_TextInputDialog_input" size="64" /> + <div className="mx_Dialog_buttons"> + <button onClick={this.onBack}>{ _t('Back') }</button> + { !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> } + { !this.state.message && <div style={{float: "right"}}> + <input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isRoomAccountData} disabled={this.props.forceMode} /> + <label className="mx_DevTools_tgl-btn" data-tg-off="Account Data" data-tg-on="Room Data" htmlFor="isRoomAccountData" /> + </div> } </div> </div>; } } -class RoomStateExplorer extends React.Component { +class FilteredList extends React.Component { static propTypes = { - setMode: React.PropTypes.func.isRequired, - roomId: React.PropTypes.string.isRequired, - onBack: React.PropTypes.func.isRequired, + children: PropTypes.any, + }; + + constructor(props, context) { + super(props, context); + this.onQuery = this.onQuery.bind(this); + + this.state = { + query: '', + }; + } + + onQuery(ev) { + this.setState({ query: ev.target.value }); + } + + filterChildren() { + if (this.state.query) { + const lowerQuery = this.state.query.toLowerCase(); + return this.props.children.filter((child) => child.key.toLowerCase().includes(lowerQuery)); + } + return this.props.children; + } + + render() { + return <div> + <input size="64" + onChange={this.onQuery} + value={this.state.query} + placeholder={_t('Filter results')} + className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query" /> + { this.filterChildren() } + </div>; + } +} + +class RoomStateExplorer extends DevtoolsComponent { + static getLabel() { return _t('Explore Room State'); } + + + static propTypes = { + onBack: PropTypes.func.isRequired, }; constructor(props, context) { super(props, context); - const room = MatrixClientPeg.get().getRoom(this.props.roomId); + const room = MatrixClientPeg.get().getRoom(this.context.roomId); this.roomStateEvents = room.currentState.events; this.onBack = this.onBack.bind(this); this.editEv = this.editEv.bind(this); - this.onQuery = this.onQuery.bind(this); - } - state = { - query: '', - eventType: null, - event: null, - }; + this.state = { + eventType: null, + event: null, + editing: false, + }; + } browseEventType(eventType) { return () => { @@ -197,7 +316,9 @@ class RoomStateExplorer extends React.Component { } onBack() { - if (this.state.event) { + 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 }); @@ -207,20 +328,19 @@ class RoomStateExplorer extends React.Component { } editEv() { - const ev = this.state.event; - this.props.setMode(SendCustomStateEvent, { - eventType: ev.getType(), - evContent: JSON.stringify(ev.getContent(), null, '\t'), - stateKey: ev.getStateKey(), - }); - } - - onQuery(ev) { - this.setState({ query: ev.target.value }); + this.setState({ editing: true }); } render() { if (this.state.event) { + if (this.state.editing) { + return <SendCustomEvent forceStateEvent={true} onBack={this.onBack} inputs={{ + eventType: this.state.event.getType(), + evContent: JSON.stringify(this.state.event.getContent(), null, '\t'), + stateKey: this.state.event.getStateKey(), + }} />; + } + return <div className="mx_ViewSource"> <div className="mx_Dialog_content"> <pre>{ JSON.stringify(this.state.event.event, null, 2) }</pre> @@ -234,11 +354,9 @@ class RoomStateExplorer extends React.Component { const rows = []; + const classes = 'mx_DevTools_RoomStateExplorer_button'; if (this.state.eventType === null) { Object.keys(this.roomStateEvents).forEach((evType) => { - // Skip this entry if does not contain search query - if (this.state.query && !evType.toLowerCase().includes(this.state.query.toLowerCase())) return; - const stateGroup = this.roomStateEvents[evType]; const stateKeys = Object.keys(stateGroup); @@ -249,7 +367,7 @@ class RoomStateExplorer extends React.Component { onClickFn = this.onViewSourceClick(stateGroup[stateKeys[0]]); } - rows.push(<button className="mx_DevTools_RoomStateExplorer_button" key={evType} onClick={onClickFn}> + rows.push(<button className={classes} key={evType} onClick={onClickFn}> { evType } </button>); }); @@ -257,12 +375,8 @@ class RoomStateExplorer extends React.Component { const evType = this.state.eventType; const stateGroup = this.roomStateEvents[evType]; Object.keys(stateGroup).forEach((stateKey) => { - // Skip this entry if does not contain search query - if (this.state.query && !stateKey.toLowerCase().includes(this.state.query.toLowerCase())) return; - const ev = stateGroup[stateKey]; - rows.push(<button className="mx_DevTools_RoomStateExplorer_button" key={stateKey} - onClick={this.onViewSourceClick(ev)}> + rows.push(<button className={classes} key={stateKey} onClick={this.onViewSourceClick(ev)}> { stateKey } </button>); }); @@ -270,8 +384,9 @@ class RoomStateExplorer extends React.Component { return <div> <div className="mx_Dialog_content"> - <input onChange={this.onQuery} placeholder={_t('Filter results')} size="64" className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query" value={this.state.query} /> - { rows } + <FilteredList> + { rows } + </FilteredList> </div> <div className="mx_Dialog_buttons"> <button onClick={this.onBack}>{ _t('Back') }</button> @@ -280,40 +395,157 @@ class RoomStateExplorer extends React.Component { } } -export default class DevtoolsDialog extends React.Component { +class AccountDataExplorer extends DevtoolsComponent { + static getLabel() { return _t('Explore Account Data'); } + static propTypes = { - roomId: React.PropTypes.string.isRequired, - onFinished: React.PropTypes.func.isRequired, + onBack: PropTypes.func.isRequired, }; - state = { - mode: null, - modeArgs: {}, + constructor(props, context) { + super(props, context); + + this.onBack = this.onBack.bind(this); + this.editEv = this.editEv.bind(this); + this._onChange = this._onChange.bind(this); + + this.state = { + isRoomAccountData: false, + event: null, + editing: false, + }; + } + + getData() { + const cli = MatrixClientPeg.get(); + if (this.state.isRoomAccountData) { + return cli.getRoom(this.context.roomId).accountData; + } + return cli.store.accountData; + } + + onViewSourceClick(event) { + return () => { + this.setState({ event }); + }; + } + + onBack() { + if (this.state.editing) { + this.setState({ editing: false }); + } else if (this.state.event) { + this.setState({ event: null }); + } else { + this.props.onBack(); + } + } + + _onChange(e) { + this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); + } + + editEv() { + this.setState({ editing: true }); + } + + render() { + if (this.state.event) { + if (this.state.editing) { + return <SendAccountData isRoomAccountData={this.state.isRoomAccountData} onBack={this.onBack} inputs={{ + eventType: this.state.event.getType(), + evContent: JSON.stringify(this.state.event.getContent(), null, '\t'), + }} forceMode={true} />; + } + + return <div className="mx_ViewSource"> + <div className="mx_Dialog_content"> + <pre>{ JSON.stringify(this.state.event.event, null, 2) }</pre> + </div> + <div className="mx_Dialog_buttons"> + <button onClick={this.onBack}>{ _t('Back') }</button> + <button onClick={this.editEv}>{ _t('Edit') }</button> + </div> + </div>; + } + + const rows = []; + + const classes = 'mx_DevTools_RoomStateExplorer_button'; + + const data = this.getData(); + Object.keys(data).forEach((evType) => { + const ev = data[evType]; + rows.push(<button className={classes} key={evType} onClick={this.onViewSourceClick(ev)}> + { evType } + </button>); + }); + + return <div> + <div className="mx_Dialog_content"> + <FilteredList> + { rows } + </FilteredList> + </div> + <div className="mx_Dialog_buttons"> + <button onClick={this.onBack}>{ _t('Back') }</button> + { !this.state.message && <div style={{float: "right"}}> + <input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isRoomAccountData} /> + <label className="mx_DevTools_tgl-btn" data-tg-off="Account Data" data-tg-on="Room Data" htmlFor="isRoomAccountData" /> + </div> } + </div> + </div>; + } +} + +const Entries = [ + SendCustomEvent, + RoomStateExplorer, + SendAccountData, + AccountDataExplorer, +]; + +export default class DevtoolsDialog extends React.Component { + static childContextTypes = { + roomId: PropTypes.string.isRequired, + // client: PropTypes.instanceOf(MatixClient), + }; + + static propTypes = { + roomId: PropTypes.string.isRequired, + onFinished: PropTypes.func.isRequired, }; constructor(props, context) { super(props, context); this.onBack = this.onBack.bind(this); - this.setMode = this.setMode.bind(this); this.onCancel = this.onCancel.bind(this); + + this.state = { + mode: null, + }; } componentWillUnmount() { this._unmounted = true; } + getChildContext() { + return { roomId: this.props.roomId }; + } + _setMode(mode) { return () => { - this.setMode(mode); + this.setState({ mode }); }; } - setMode(mode, modeArgs={}) { - this.setState({ mode, modeArgs }); - } - onBack() { - this.setState({ mode: null }); + if (this.prevMode) { + this.setState({ mode: this.prevMode }); + this.prevMode = null; + } else { + this.setState({ mode: null }); + } } onCancel() { @@ -324,14 +556,27 @@ export default class DevtoolsDialog extends React.Component { let body; if (this.state.mode) { - body = - <this.state.mode {...this.props} {...this.state.modeArgs} onBack={this.onBack} setMode={this.setMode} />; - } else { body = <div> - <div className="mx_Dialog_content"> - <button onClick={this._setMode(SendCustomEvent)}>{ _t('Send Custom Event') }</button> - <button onClick={this._setMode(SendCustomStateEvent)}>{ _t('Send Custom State Event') }</button> - <button onClick={this._setMode(RoomStateExplorer)}>{ _t('Explore Room State') }</button> + <div className="mx_DevTools_label_left">{ this.state.mode.getLabel() }</div> + <div className="mx_DevTools_label_right">Room ID: { this.props.roomId }</div> + <div className="mx_DevTools_label_bottom" /> + <this.state.mode onBack={this.onBack} /> + </div>; + } else { + const classes = "mx_DevTools_RoomStateExplorer_button"; + body = <div> + <div> + <div className="mx_DevTools_label_left">{ _t('Toolbox') }</div> + <div className="mx_DevTools_label_right">Room ID: { this.props.roomId }</div> + <div className="mx_DevTools_label_bottom" /> + + <div className="mx_Dialog_content"> + { Entries.map((Entry) => { + const label = Entry.getLabel(); + const onClick = this._setMode(Entry); + return <button className={classes} key={label} onClick={onClick}>{ label }</button>; + }) } + </div> </div> <div className="mx_Dialog_buttons"> <button onClick={this.onCancel}>{ _t('Cancel') }</button> @@ -342,7 +587,6 @@ export default class DevtoolsDialog extends React.Component { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( <BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished} title={_t('Developer Tools')}> - <div>Room ID: { this.props.roomId }</div> { body } </BaseDialog> ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 543ee7bbda..4702ae93a1 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -70,6 +70,7 @@ "What's new?": "What's new?", "A new version of Riot is available.": "A new version of Riot is available.", "To return to your account in future you need to <u>set a password</u>": "To return to your account in future you need to <u>set a password</u>", + "Toolbox": "Toolbox", "Set Password": "Set Password", "Error encountered (%(errorDetail)s).": "Error encountered (%(errorDetail)s).", "Checking for an update...": "Checking for an update...", @@ -106,7 +107,8 @@ "Edit": "Edit", "Filter results": "Filter results", "Send Custom Event": "Send Custom Event", - "Send Custom State Event": "Send Custom State Event", + "Send Account Data": "Send Account Data", + "Explore Account Data": "Explore Account Data", "Explore Room State": "Explore Room State", "Developer Tools": "Developer Tools", "You have successfully set a password!": "You have successfully set a password!", diff --git a/src/skins/vector/css/vector-web/views/dialogs/_DevtoolsDialog.scss b/src/skins/vector/css/vector-web/views/dialogs/_DevtoolsDialog.scss index 975ee8c1a0..8918373ecf 100644 --- a/src/skins/vector/css/vector-web/views/dialogs/_DevtoolsDialog.scss +++ b/src/skins/vector/css/vector-web/views/dialogs/_DevtoolsDialog.scss @@ -17,3 +17,150 @@ limitations under the License. .mx_DevTools_RoomStateExplorer_button, .mx_DevTools_RoomStateExplorer_query { margin-bottom: 10px; } + +.mx_DevTools_label_left { + float: left; +} + +.mx_DevTools_label_right { + float: right; +} + +.mx_DevTools_label_bottom { + clear: both; + border-bottom: 1px solid #e5e5e5; +} + +.mx_DevTools_inputRow +{ + display: table-row; +} + +.mx_DevTools_inputLabelCell +{ + padding-bottom: 21px; + display: table-cell; + font-weight: bold; + padding-right: 24px; +} + +.mx_DevTools_inputCell { + display: table-cell; + padding-bottom: 21px; + width: 240px; +} + +.mx_DevTools_inputCell input +{ + display: inline-block; + border: 0; + border-bottom: 1px solid $input-underline-color; + padding: 0; + width: 240px; + color: $input-fg-color; + font-family: 'Open Sans', Helvetica, Arial, Sans-Serif; + font-size: 16px; +} + +.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; + font-family: sans-serif; + 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); + } + } +}