diff --git a/src/components/structures/BottomLeftMenu.js b/src/components/structures/BottomLeftMenu.js
new file mode 100644
index 0000000000..3271d5aeab
--- /dev/null
+++ b/src/components/structures/BottomLeftMenu.js
@@ -0,0 +1,197 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+
+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 from 'react';
+import ReactDOM from 'react-dom';
+import sdk from 'matrix-react-sdk';
+import dis from 'matrix-react-sdk/lib/dispatcher';
+import Velocity from 'velocity-vector';
+import 'velocity-vector/velocity.ui';
+import SettingsStore from "matrix-react-sdk/lib/settings/SettingsStore";
+
+const CALLOUT_ANIM_DURATION = 1000;
+
+module.exports = React.createClass({
+ displayName: 'BottomLeftMenu',
+
+ propTypes: {
+ collapsed: React.PropTypes.bool.isRequired,
+ },
+
+ getInitialState: function() {
+ return({
+ directoryHover : false,
+ roomsHover : false,
+ homeHover: false,
+ peopleHover : false,
+ settingsHover : false,
+ });
+ },
+
+ componentWillMount: function() {
+ this._dispatcherRef = dis.register(this.onAction);
+ this._peopleButton = null;
+ this._directoryButton = null;
+ this._createRoomButton = null;
+ this._lastCallouts = {};
+ },
+
+ componentWillUnmount: function() {
+ dis.unregister(this._dispatcherRef);
+ },
+
+ // Room events
+ onDirectoryClick: function() {
+ dis.dispatch({ action: 'view_room_directory' });
+ },
+
+ onDirectoryMouseEnter: function() {
+ this.setState({ directoryHover: true });
+ },
+
+ onDirectoryMouseLeave: function() {
+ this.setState({ directoryHover: false });
+ },
+
+ onRoomsClick: function() {
+ dis.dispatch({ action: 'view_create_room' });
+ },
+
+ onRoomsMouseEnter: function() {
+ this.setState({ roomsHover: true });
+ },
+
+ onRoomsMouseLeave: function() {
+ this.setState({ roomsHover: false });
+ },
+
+ // Home button events
+ onHomeClick: function() {
+ dis.dispatch({ action: 'view_home_page' });
+ },
+
+ onHomeMouseEnter: function() {
+ this.setState({ homeHover: true });
+ },
+
+ onHomeMouseLeave: function() {
+ this.setState({ homeHover: false });
+ },
+
+ // People events
+ onPeopleClick: function() {
+ dis.dispatch({ action: 'view_create_chat' });
+ },
+
+ onPeopleMouseEnter: function() {
+ this.setState({ peopleHover: true });
+ },
+
+ onPeopleMouseLeave: function() {
+ this.setState({ peopleHover: false });
+ },
+
+ // Settings events
+ onSettingsClick: function() {
+ dis.dispatch({ action: 'view_user_settings' });
+ },
+
+ onSettingsMouseEnter: function() {
+ this.setState({ settingsHover: true });
+ },
+
+ onSettingsMouseLeave: function() {
+ this.setState({ settingsHover: false });
+ },
+
+ onAction: function(payload) {
+ let calloutElement;
+ switch (payload.action) {
+ // Incoming instruction: dance!
+ case 'callout_start_chat':
+ calloutElement = this._peopleButton;
+ break;
+ case 'callout_room_directory':
+ calloutElement = this._directoryButton;
+ break;
+ case 'callout_create_room':
+ calloutElement = this._createRoomButton;
+ break;
+ }
+ if (calloutElement) {
+ const lastCallout = this._lastCallouts[payload.action];
+ const now = Date.now();
+ if (lastCallout == undefined || lastCallout < now - CALLOUT_ANIM_DURATION) {
+ this._lastCallouts[payload.action] = now;
+ Velocity(ReactDOM.findDOMNode(calloutElement), "callout.bounce", CALLOUT_ANIM_DURATION);
+ }
+ }
+ },
+
+ // Get the label/tooltip to show
+ getLabel: function(label, show) {
+ if (show) {
+ var RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
+ return ;
+ }
+ },
+
+ _collectPeopleButton: function(e) {
+ this._peopleButton = e;
+ },
+
+ _collectDirectoryButton: function(e) {
+ this._directoryButton = e;
+ },
+
+ _collectCreateRoomButton: function(e) {
+ this._createRoomButton = e;
+ },
+
+ render: function() {
+ const HomeButton = sdk.getComponent('elements.HomeButton');
+ const StartChatButton = sdk.getComponent('elements.StartChatButton');
+ const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
+ const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton');
+ const SettingsButton = sdk.getComponent('elements.SettingsButton');
+ const GroupsButton = sdk.getComponent('elements.GroupsButton');
+
+ const groupsButton = SettingsStore.getValue("TagPanel.disableTagPanel") ?
+ : null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ { groupsButton }
+
+
+
+
+
+ );
+ },
+});
diff --git a/src/components/structures/CompatibilityPage.js b/src/components/structures/CompatibilityPage.js
new file mode 100644
index 0000000000..10806f4ffa
--- /dev/null
+++ b/src/components/structures/CompatibilityPage.js
@@ -0,0 +1,73 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+var React = require('react');
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+
+module.exports = React.createClass({
+ displayName: 'CompatibilityPage',
+ propTypes: {
+ onAccept: React.PropTypes.func
+ },
+
+ getDefaultProps: function() {
+ return {
+ onAccept: function() {} // NOP
+ };
+ },
+
+ onAccept: function() {
+ this.props.onAccept();
+ },
+
+ render: function() {
+
+ return (
+
+
+
{ _t("Sorry, your browser is not able to run Riot.", {}, { 'b': (sub) => {sub} }) }
+
+ { _t("Riot uses many advanced browser features, some of which are not available or experimental in your current browser.") }
+
+
+ { _t('Please install Chrome or Firefox for the best experience.',
+ {},
+ {
+ 'chromeLink': (sub) => {sub},
+ 'firefoxLink': (sub) => {sub},
+ },
+ )}
+ { _t('Safari and Opera work too.',
+ {},
+ {
+ 'safariLink': (sub) => {sub},
+ 'operaLink': (sub) => {sub},
+ },
+ )}
+
+
+ { _t("With your current browser, the look and feel of the application may be completely incorrect, and some or all features may not function. If you want to try it anyway you can continue, but you are on your own in terms of any issues you may encounter!") }
+
+
+
+
+ );
+ }
+});
diff --git a/src/components/structures/HomePage.js b/src/components/structures/HomePage.js
new file mode 100644
index 0000000000..c46ceeba7b
--- /dev/null
+++ b/src/components/structures/HomePage.js
@@ -0,0 +1,111 @@
+/*
+Copyright 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+
+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.
+*/
+
+'use strict';
+
+import React from 'react';
+import request from 'browser-request';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+import sanitizeHtml from 'sanitize-html';
+import sdk from 'matrix-react-sdk/lib';
+
+module.exports = React.createClass({
+ displayName: 'HomePage',
+
+ propTypes: {
+ // URL base of the team server. Optional.
+ teamServerUrl: React.PropTypes.string,
+ // Team token. Optional. If set, used to get the static homepage of the team
+ // associated. If unset, homePageUrl will be used.
+ teamToken: React.PropTypes.string,
+ // URL to use as the iFrame src. Defaults to /home.html.
+ homePageUrl: React.PropTypes.string,
+ },
+
+ getInitialState: function() {
+ return {
+ iframeSrc: '',
+ page: '',
+ };
+ },
+
+ translate: function(s) {
+ s = sanitizeHtml(_t(s));
+ // ugly fix for https://github.com/vector-im/riot-web/issues/4243
+ s = s.replace(/Riot\.im/, 'Riot.im');
+ s = s.replace(/\[matrix\]/, '');
+ return s;
+ },
+
+ componentWillMount: function() {
+ this._unmounted = false;
+
+ if (this.props.teamToken && this.props.teamServerUrl) {
+ this.setState({
+ iframeSrc: `${this.props.teamServerUrl}/static/${this.props.teamToken}/home.html`
+ });
+ }
+ else {
+ // we use request() to inline the homepage into the react component
+ // so that it can inherit CSS and theming easily rather than mess around
+ // with iframes and trying to synchronise document.stylesheets.
+
+ let src = this.props.homePageUrl || 'home.html';
+
+ request(
+ { method: "GET", url: src },
+ (err, response, body) => {
+ if (this._unmounted) {
+ return;
+ }
+
+ if (err || response.status < 200 || response.status >= 300) {
+ console.warn(`Error loading home page: ${err}`);
+ this.setState({ page: _t("Couldn't load home page") });
+ return;
+ }
+
+ body = body.replace(/_t\(['"]([\s\S]*?)['"]\)/mg, (match, g1)=>this.translate(g1));
+ this.setState({ page: body });
+ }
+ );
+ }
+ },
+
+ componentWillUnmount: function() {
+ this._unmounted = true;
+ },
+
+ render: function() {
+ if (this.state.iframeSrc) {
+ return (
+
+
+ );
+ }
+ }
+});
diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js
new file mode 100644
index 0000000000..0fc3e0259b
--- /dev/null
+++ b/src/components/structures/LeftPanel.js
@@ -0,0 +1,226 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { MatrixClient } from 'matrix-js-sdk';
+import { KeyCode } from 'matrix-react-sdk/lib/Keyboard';
+import sdk from 'matrix-react-sdk';
+import dis from 'matrix-react-sdk/lib/dispatcher';
+import VectorConferenceHandler from '../../VectorConferenceHandler';
+
+import SettingsStore from 'matrix-react-sdk/lib/settings/SettingsStore';
+
+
+var LeftPanel = React.createClass({
+ displayName: 'LeftPanel',
+
+ // NB. If you add props, don't forget to update
+ // shouldComponentUpdate!
+ propTypes: {
+ collapsed: PropTypes.bool.isRequired,
+ },
+
+ contextTypes: {
+ matrixClient: PropTypes.instanceOf(MatrixClient),
+ },
+
+ getInitialState: function() {
+ return {
+ searchFilter: '',
+ };
+ },
+
+ componentWillMount: function() {
+ this.focusedElement = null;
+ },
+
+ shouldComponentUpdate: function(nextProps, nextState) {
+ // MatrixChat will update whenever the user switches
+ // rooms, but propagating this change all the way down
+ // the react tree is quite slow, so we cut this off
+ // here. The RoomTiles listen for the room change
+ // events themselves to know when to update.
+ // We just need to update if any of these things change.
+ if (
+ this.props.collapsed !== nextProps.collapsed ||
+ this.props.disabled !== nextProps.disabled
+ ) {
+ return true;
+ }
+
+ if (this.state.searchFilter !== nextState.searchFilter) {
+ return true;
+ }
+
+ return false;
+ },
+
+ _onFocus: function(ev) {
+ this.focusedElement = ev.target;
+ },
+
+ _onBlur: function(ev) {
+ this.focusedElement = null;
+ },
+
+ _onKeyDown: function(ev) {
+ if (!this.focusedElement) return;
+ let handled = false;
+
+ switch (ev.keyCode) {
+ case KeyCode.UP:
+ this._onMoveFocus(true);
+ handled = true;
+ break;
+ case KeyCode.DOWN:
+ this._onMoveFocus(false);
+ handled = true;
+ break;
+ }
+
+ if (handled) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ }
+ },
+
+ _onMoveFocus: function(up) {
+ var element = this.focusedElement;
+
+ // unclear why this isn't needed
+ // var descending = (up == this.focusDirection) ? this.focusDescending : !this.focusDescending;
+ // this.focusDirection = up;
+
+ var descending = false; // are we currently descending or ascending through the DOM tree?
+ var classes;
+
+ do {
+ var child = up ? element.lastElementChild : element.firstElementChild;
+ var sibling = up ? element.previousElementSibling : element.nextElementSibling;
+
+ if (descending) {
+ if (child) {
+ element = child;
+ }
+ else if (sibling) {
+ element = sibling;
+ }
+ else {
+ descending = false;
+ element = element.parentElement;
+ }
+ }
+ else {
+ if (sibling) {
+ element = sibling;
+ descending = true;
+ }
+ else {
+ element = element.parentElement;
+ }
+ }
+
+ if (element) {
+ classes = element.classList;
+ if (classes.contains("mx_LeftPanel")) { // we hit the top
+ element = up ? element.lastElementChild : element.firstElementChild;
+ descending = true;
+ }
+ }
+
+ } while(element && !(
+ classes.contains("mx_RoomTile") ||
+ classes.contains("mx_SearchBox_search") ||
+ classes.contains("mx_RoomSubList_ellipsis")));
+
+ if (element) {
+ element.focus();
+ this.focusedElement = element;
+ this.focusedDescending = descending;
+ }
+ },
+
+ onHideClick: function() {
+ dis.dispatch({
+ action: 'hide_left_panel',
+ });
+ },
+
+ onSearch: function(term) {
+ this.setState({ searchFilter: term });
+ },
+
+ collectRoomList: function(ref) {
+ this._roomList = ref;
+ },
+
+ render: function() {
+ const RoomList = sdk.getComponent('rooms.RoomList');
+ const TagPanel = sdk.getComponent('structures.TagPanel');
+ const BottomLeftMenu = sdk.getComponent('structures.BottomLeftMenu');
+ const CallPreview = sdk.getComponent('voip.CallPreview');
+
+ let topBox;
+ if (this.context.matrixClient.isGuest()) {
+ const LoginBox = sdk.getComponent('structures.LoginBox');
+ topBox = ;
+ } else {
+ const SearchBox = sdk.getComponent('structures.SearchBox');
+ topBox = ;
+ }
+
+ const classes = classNames(
+ "mx_LeftPanel",
+ {
+ "collapsed": this.props.collapsed,
+ },
+ );
+
+ const tagPanelEnabled = !SettingsStore.getValue("TagPanel.disableTagPanel");
+ const tagPanel = tagPanelEnabled ? : ;
+
+ const containerClasses = classNames(
+ "mx_LeftPanel_container", "mx_fadable",
+ {
+ "mx_LeftPanel_container_collapsed": this.props.collapsed,
+ "mx_LeftPanel_container_hasTagPanel": tagPanelEnabled,
+ "mx_fadable_faded": this.props.disabled,
+ },
+ );
+
+ return (
+
+ { tagPanel }
+
+
+ );
+ }
+});
+
+module.exports = LeftPanel;
diff --git a/src/components/structures/LoginBox.js b/src/components/structures/LoginBox.js
new file mode 100644
index 0000000000..7cacc14e8a
--- /dev/null
+++ b/src/components/structures/LoginBox.js
@@ -0,0 +1,93 @@
+/*
+Copyright 2017 Vector Creations Ltd
+
+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.
+*/
+
+'use strict';
+
+var React = require('react');
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+var sdk = require('matrix-react-sdk')
+var dis = require('matrix-react-sdk/lib/dispatcher');
+var rate_limited_func = require('matrix-react-sdk/lib/ratelimitedfunc');
+var AccessibleButton = require('matrix-react-sdk/lib/components/views/elements/AccessibleButton');
+
+module.exports = React.createClass({
+ displayName: 'LoginBox',
+
+ propTypes: {
+ collapsed: React.PropTypes.bool,
+ },
+
+ onToggleCollapse: function(show) {
+ if (show) {
+ dis.dispatch({
+ action: 'show_left_panel',
+ });
+ }
+ else {
+ dis.dispatch({
+ action: 'hide_left_panel',
+ });
+ }
+ },
+
+ onLoginClick: function() {
+ dis.dispatch({ action: 'start_login' });
+ },
+
+ onRegisterClick: function() {
+ dis.dispatch({ action: 'start_registration' });
+ },
+
+ render: function() {
+ var TintableSvg = sdk.getComponent('elements.TintableSvg');
+
+ var toggleCollapse;
+ if (this.props.collapsed) {
+ toggleCollapse =
+
+
+
+ }
+ else {
+ toggleCollapse =
+
+
+
+ }
+
+ var loginButton;
+ if (!this.props.collapsed) {
+ loginButton = (
+
+
+ { _t("Login") }
+
+
+ { _t("Register") }
+
+
+ );
+ }
+
+ var self = this;
+ return (
+
+ { loginButton }
+ { toggleCollapse }
+
+ );
+ }
+});
diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js
new file mode 100644
index 0000000000..39463a6700
--- /dev/null
+++ b/src/components/structures/RightPanel.js
@@ -0,0 +1,424 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2017 New Vector Ltd
+
+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 from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+import sdk from 'matrix-react-sdk';
+import dis from 'matrix-react-sdk/lib/dispatcher';
+import { MatrixClient } from 'matrix-js-sdk';
+import Analytics from 'matrix-react-sdk/lib/Analytics';
+import RateLimitedFunc from 'matrix-react-sdk/lib/ratelimitedfunc';
+import AccessibleButton from 'matrix-react-sdk/lib/components/views/elements/AccessibleButton';
+import { showGroupInviteDialog, showGroupAddRoomDialog } from 'matrix-react-sdk/lib/GroupAddressPicker';
+import GroupStoreCache from 'matrix-react-sdk/lib/stores/GroupStoreCache';
+
+import { formatCount } from 'matrix-react-sdk/lib/utils/FormattingUtils';
+
+class HeaderButton extends React.Component {
+ constructor() {
+ super();
+ this.onClick = this.onClick.bind(this);
+ }
+
+ onClick(ev) {
+ Analytics.trackEvent(...this.props.analytics);
+ dis.dispatch({
+ action: 'view_right_panel_phase',
+ phase: this.props.clickPhase,
+ });
+ }
+
+ render() {
+ const TintableSvg = sdk.getComponent("elements.TintableSvg");
+ const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
+
+ return
+
+
+ { this.props.badge ? this.props.badge : }
+
+
+ { this.props.isHighlighted ? : }
+
+ ;
+ }
+}
+
+HeaderButton.propTypes = {
+ // Whether this button is highlighted
+ isHighlighted: PropTypes.bool.isRequired,
+ // The phase to swap to when the button is clicked
+ clickPhase: PropTypes.string.isRequired,
+ // The source file of the icon to display
+ iconSrc: PropTypes.string.isRequired,
+
+ // The badge to display above the icon
+ badge: PropTypes.node,
+ // The parameters to track the click event
+ analytics: PropTypes.arrayOf(PropTypes.string).isRequired,
+
+ // Button title
+ title: PropTypes.string.isRequired,
+};
+
+module.exports = React.createClass({
+ displayName: 'RightPanel',
+
+ propTypes: {
+ // TODO: We're trying to move away from these being props, but we need to know
+ // whether we should be displaying a room or group member list
+ roomId: React.PropTypes.string, // if showing panels for a given room, this is set
+ groupId: React.PropTypes.string, // if showing panels for a given group, this is set
+ collapsed: React.PropTypes.bool, // currently unused property to request for a minimized view of the panel
+ },
+
+ contextTypes: {
+ matrixClient: PropTypes.instanceOf(MatrixClient),
+ },
+
+ Phase: {
+ RoomMemberList: 'RoomMemberList',
+ GroupMemberList: 'GroupMemberList',
+ GroupRoomList: 'GroupRoomList',
+ GroupRoomInfo: 'GroupRoomInfo',
+ FilePanel: 'FilePanel',
+ NotificationPanel: 'NotificationPanel',
+ RoomMemberInfo: 'RoomMemberInfo',
+ GroupMemberInfo: 'GroupMemberInfo',
+ },
+
+ componentWillMount: function() {
+ this.dispatcherRef = dis.register(this.onAction);
+ const cli = this.context.matrixClient;
+ cli.on("RoomState.members", this.onRoomStateMember);
+ this._initGroupStore(this.props.groupId);
+ },
+
+ componentWillUnmount: function() {
+ dis.unregister(this.dispatcherRef);
+ if (this.context.matrixClient) {
+ this.context.matrixClient.removeListener("RoomState.members", this.onRoomStateMember);
+ }
+ this._unregisterGroupStore();
+ },
+
+ getInitialState: function() {
+ return {
+ phase: this.props.groupId ? this.Phase.GroupMemberList : this.Phase.RoomMemberList,
+ isUserPrivilegedInGroup: null,
+ };
+ },
+
+ componentWillReceiveProps(newProps) {
+ if (newProps.groupId !== this.props.groupId) {
+ this._unregisterGroupStore();
+ this._initGroupStore(newProps.groupId);
+ }
+ },
+
+ _initGroupStore(groupId) {
+ if (!groupId) return;
+ this._groupStore = GroupStoreCache.getGroupStore(groupId);
+ this._groupStore.registerListener(this.onGroupStoreUpdated);
+ },
+
+ _unregisterGroupStore() {
+ if (this._groupStore) {
+ this._groupStore.unregisterListener(this.onGroupStoreUpdated);
+ }
+ },
+
+ onGroupStoreUpdated: function() {
+ this.setState({
+ isUserPrivilegedInGroup: this._groupStore.isUserPrivileged(),
+ });
+ },
+
+ onCollapseClick: function() {
+ dis.dispatch({
+ action: 'hide_right_panel',
+ });
+ },
+
+ onInviteButtonClick: function() {
+ if (this.context.matrixClient.isGuest()) {
+ dis.dispatch({action: 'view_set_mxid'});
+ return;
+ }
+
+ // call AddressPickerDialog
+ dis.dispatch({
+ action: 'view_invite',
+ roomId: this.props.roomId,
+ });
+ },
+
+ onInviteToGroupButtonClick: function() {
+ showGroupInviteDialog(this.props.groupId).then(() => {
+ this.setState({
+ phase: this.Phase.GroupMemberList,
+ });
+ });
+ },
+
+ onAddRoomToGroupButtonClick: function() {
+ showGroupAddRoomDialog(this.props.groupId).then(() => {
+ this.forceUpdate();
+ });
+ },
+
+ onRoomStateMember: function(ev, state, member) {
+ // redraw the badge on the membership list
+ if (this.state.phase === this.Phase.RoomMemberList && member.roomId === this.props.roomId) {
+ this._delayedUpdate();
+ } else if (this.state.phase === this.Phase.RoomMemberInfo && member.roomId === this.props.roomId &&
+ member.userId === this.state.member.userId) {
+ // refresh the member info (e.g. new power level)
+ this._delayedUpdate();
+ }
+ },
+
+ _delayedUpdate: new RateLimitedFunc(function() {
+ this.forceUpdate(); // eslint-disable-line babel/no-invalid-this
+ }, 500),
+
+ onAction: function(payload) {
+ if (payload.action === "view_user") {
+ dis.dispatch({
+ action: 'show_right_panel',
+ });
+ if (payload.member) {
+ this.setState({
+ phase: this.Phase.RoomMemberInfo,
+ member: payload.member,
+ });
+ } else {
+ if (this.props.roomId) {
+ this.setState({
+ phase: this.Phase.RoomMemberList,
+ });
+ } else if (this.props.groupId) {
+ this.setState({
+ phase: this.Phase.GroupMemberList,
+ member: payload.member,
+ });
+ }
+ }
+ } else if (payload.action === "view_group") {
+ this.setState({
+ phase: this.Phase.GroupMemberList,
+ member: null,
+ });
+ } else if (payload.action === "view_group_room") {
+ this.setState({
+ phase: this.Phase.GroupRoomInfo,
+ groupRoomId: payload.groupRoomId,
+ });
+ } else if (payload.action === "view_group_room_list") {
+ this.setState({
+ phase: this.Phase.GroupRoomList,
+ });
+ } else if (payload.action === "view_group_member_list") {
+ this.setState({
+ phase: this.Phase.GroupMemberList,
+ });
+ } else if (payload.action === "view_group_user") {
+ this.setState({
+ phase: this.Phase.GroupMemberInfo,
+ member: payload.member,
+ });
+ } else if (payload.action === "view_room") {
+ this.setState({
+ phase: this.Phase.RoomMemberList,
+ });
+ } else if (payload.action === "view_right_panel_phase") {
+ this.setState({
+ phase: payload.phase,
+ });
+ }
+ },
+
+ render: function() {
+ const MemberList = sdk.getComponent('rooms.MemberList');
+ const MemberInfo = sdk.getComponent('rooms.MemberInfo');
+ const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
+ const FilePanel = sdk.getComponent('structures.FilePanel');
+
+ const GroupMemberList = sdk.getComponent('groups.GroupMemberList');
+ const GroupMemberInfo = sdk.getComponent('groups.GroupMemberInfo');
+ const GroupRoomList = sdk.getComponent('groups.GroupRoomList');
+ const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo');
+
+ const TintableSvg = sdk.getComponent("elements.TintableSvg");
+
+ let inviteGroup;
+
+ let membersBadge;
+ let membersTitle = _t('Members');
+ if ((this.state.phase === this.Phase.RoomMemberList || this.state.phase === this.Phase.RoomMemberInfo)
+ && this.props.roomId
+ ) {
+ const cli = this.context.matrixClient;
+ const room = cli.getRoom(this.props.roomId);
+ let isUserInRoom;
+ if (room) {
+ const numMembers = room.getJoinedMembers().length;
+ membersTitle = _t('%(count)s Members', { count: numMembers });
+ membersBadge =
+ ;
+ }
+ }
+
+ const isPhaseGroup = [
+ this.Phase.GroupMemberInfo,
+ this.Phase.GroupMemberList,
+ ].includes(this.state.phase);
+
+ let headerButtons = [];
+ if (this.props.roomId) {
+ headerButtons = [
+ ,
+ ,
+ ,
+ ];
+ } else if (this.props.groupId) {
+ headerButtons = [
+ ,
+ ,
+ ];
+ }
+
+ if (this.props.roomId || this.props.groupId) {
+ // Hiding the right panel hides it completely and relies on an 'expand' button
+ // being put in the RoomHeader or GroupView header, so only show the minimise
+ // button on these 2 screens or you won't be able to re-expand the panel.
+ headerButtons.push(
+
+
+ );
+ }
+
+ const classes = classNames("mx_RightPanel", "mx_fadable", {
+ "collapsed": this.props.collapsed,
+ "mx_fadable_faded": this.props.disabled,
+ });
+
+ return (
+
+ );
+ },
+});
diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js
new file mode 100644
index 0000000000..126ae40401
--- /dev/null
+++ b/src/components/structures/RoomDirectory.js
@@ -0,0 +1,587 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+var React = require('react');
+
+var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg');
+var ContentRepo = require("matrix-js-sdk").ContentRepo;
+var Modal = require('matrix-react-sdk/lib/Modal');
+var sdk = require('matrix-react-sdk');
+var dis = require('matrix-react-sdk/lib/dispatcher');
+
+var linkify = require('linkifyjs');
+var linkifyString = require('linkifyjs/string');
+var linkifyMatrix = require('matrix-react-sdk/lib/linkify-matrix');
+var sanitizeHtml = require('sanitize-html');
+import Promise from 'bluebird';
+
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+
+import {instanceForInstanceId, protocolNameForInstanceId} from '../../utils/DirectoryUtils';
+
+linkifyMatrix(linkify);
+
+module.exports = React.createClass({
+ displayName: 'RoomDirectory',
+
+ propTypes: {
+ config: React.PropTypes.object,
+ },
+
+ getDefaultProps: function() {
+ return {
+ config: {},
+ }
+ },
+
+ getInitialState: function() {
+ return {
+ publicRooms: [],
+ loading: true,
+ protocolsLoading: true,
+ instanceId: null,
+ includeAll: false,
+ roomServer: null,
+ filterString: null,
+ }
+ },
+
+ componentWillMount: function() {
+ this._unmounted = false;
+ this.nextBatch = null;
+ this.filterTimeout = null;
+ this.scrollPanel = null;
+ this.protocols = null;
+
+ this.setState({protocolsLoading: true});
+ MatrixClientPeg.get().getThirdpartyProtocols().done((response) => {
+ this.protocols = response;
+ this.setState({protocolsLoading: false});
+ }, (err) => {
+ console.warn(`error loading thirdparty protocols: ${err}`);
+ this.setState({protocolsLoading: false});
+ if (MatrixClientPeg.get().isGuest()) {
+ // Guests currently aren't allowed to use this API, so
+ // ignore this as otherwise this error is literally the
+ // thing you see when loading the client!
+ return;
+ }
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createTrackedDialog('Failed to get protocol list from Home Server', '', ErrorDialog, {
+ title: _t('Failed to get protocol list from Home Server'),
+ description: _t('The Home Server may be too old to support third party networks'),
+ });
+ });
+
+ // dis.dispatch({
+ // action: 'panel_disable',
+ // sideDisabled: true,
+ // middleDisabled: true,
+ // });
+ },
+
+ componentWillUnmount: function() {
+ // dis.dispatch({
+ // action: 'panel_disable',
+ // sideDisabled: false,
+ // middleDisabled: false,
+ // });
+ if (this.filterTimeout) {
+ clearTimeout(this.filterTimeout);
+ }
+ this._unmounted = true;
+ },
+
+ refreshRoomList: function() {
+ this.nextBatch = null;
+ this.setState({
+ publicRooms: [],
+ loading: true,
+ });
+ this.getMoreRooms().done();
+ },
+
+ getMoreRooms: function() {
+ if (!MatrixClientPeg.get()) return Promise.resolve();
+
+ const my_filter_string = this.state.filterString;
+ const my_server = this.state.roomServer;
+ // remember the next batch token when we sent the request
+ // too. If it's changed, appending to the list will corrupt it.
+ const my_next_batch = this.nextBatch;
+ const opts = {limit: 20};
+ if (my_server != MatrixClientPeg.getHomeServerName()) {
+ opts.server = my_server;
+ }
+ if (this.state.instanceId) {
+ opts.third_party_instance_id = this.state.instanceId;
+ } else if (this.state.includeAll) {
+ opts.include_all_networks = true;
+ }
+ if (this.nextBatch) opts.since = this.nextBatch;
+ if (my_filter_string) opts.filter = { generic_search_term: my_filter_string } ;
+ return MatrixClientPeg.get().publicRooms(opts).then((data) => {
+ if (
+ my_filter_string != this.state.filterString ||
+ my_server != this.state.roomServer ||
+ my_next_batch != this.nextBatch)
+ {
+ // if the filter or server has changed since this request was sent,
+ // throw away the result (don't even clear the busy flag
+ // since we must still have a request in flight)
+ return;
+ }
+
+ if (this._unmounted) {
+ // if we've been unmounted, we don't care either.
+ return;
+ }
+
+ this.nextBatch = data.next_batch;
+ this.setState((s) => {
+ s.publicRooms.push(...data.chunk);
+ s.loading = false;
+ return s;
+ });
+ return Boolean(data.next_batch);
+ }, (err) => {
+ if (
+ my_filter_string != this.state.filterString ||
+ my_server != this.state.roomServer ||
+ my_next_batch != this.nextBatch)
+ {
+ // as above: we don't care about errors for old
+ // requests either
+ return;
+ }
+
+ if (this._unmounted) {
+ // if we've been unmounted, we don't care either.
+ return;
+ }
+
+ this.setState({ loading: false });
+ console.error("Failed to get publicRooms: %s", JSON.stringify(err));
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createTrackedDialog('Failed to get public room list', '', ErrorDialog, {
+ title: _t('Failed to get public room list'),
+ description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded'))
+ });
+ });
+ },
+
+ /**
+ * A limited interface for removing rooms from the directory.
+ * Will set the room to not be publicly visible and delete the
+ * default alias. In the long term, it would be better to allow
+ * HS admins to do this through the RoomSettings interface, but
+ * this needs SPEC-417.
+ */
+ removeFromDirectory: function(room) {
+ var alias = get_display_alias_for_room(room);
+ var name = room.name || alias || _t('Unnamed room');
+
+ var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+
+ var desc;
+ if (alias) {
+ desc = _t('Delete the room alias %(alias)s and remove %(name)s from the directory?', {alias: alias, name: name});
+ } else {
+ desc = _t('Remove %(name)s from the directory?', {name: name});
+ }
+
+ Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, {
+ title: _t('Remove from Directory'),
+ description: desc,
+ onFinished: (should_delete) => {
+ if (!should_delete) return;
+
+ var Loader = sdk.getComponent("elements.Spinner");
+ var modal = Modal.createDialog(Loader);
+ var step = _t('remove %(name)s from the directory.', {name: name});
+
+ MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
+ if (!alias) return;
+ step = _t('delete the alias.');
+ return MatrixClientPeg.get().deleteAlias(alias);
+ }).done(() => {
+ modal.close();
+ this.refreshRoomList();
+ }, (err) => {
+ modal.close();
+ this.refreshRoomList();
+ console.error("Failed to " + step + ": " + err);
+ Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, {
+ title: _t('Error'),
+ description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded'))
+ });
+ });
+ }
+ });
+ },
+
+ onRoomClicked: function(room, ev) {
+ if (ev.shiftKey) {
+ ev.preventDefault();
+ this.removeFromDirectory(room);
+ } else {
+ this.showRoom(room);
+ }
+ },
+
+ onOptionChange: function(server, instanceId, includeAll) {
+ // clear next batch so we don't try to load more rooms
+ this.nextBatch = null;
+ this.setState({
+ // Clear the public rooms out here otherwise we needlessly
+ // spend time filtering lots of rooms when we're about to
+ // to clear the list anyway.
+ publicRooms: [],
+ roomServer: server,
+ instanceId: instanceId,
+ includeAll: includeAll,
+ }, this.refreshRoomList);
+ // We also refresh the room list each time even though this
+ // filtering is client-side. It hopefully won't be client side
+ // for very long, and we may have fetched a thousand rooms to
+ // find the five gitter ones, at which point we do not want
+ // to render all those rooms when switching back to 'all networks'.
+ // Easiest to just blow away the state & re-fetch.
+ },
+
+ onFillRequest: function(backwards) {
+ if (backwards || !this.nextBatch) return Promise.resolve(false);
+
+ return this.getMoreRooms();
+ },
+
+ onFilterChange: function(alias) {
+ this.setState({
+ filterString: alias || null,
+ });
+
+ // don't send the request for a little bit,
+ // no point hammering the server with a
+ // request for every keystroke, let the
+ // user finish typing.
+ if (this.filterTimeout) {
+ clearTimeout(this.filterTimeout);
+ }
+ this.filterTimeout = setTimeout(() => {
+ this.filterTimeout = null;
+ this.refreshRoomList();
+ }, 700);
+ },
+
+ onFilterClear: function() {
+ // update immediately
+ this.setState({
+ filterString: null,
+ }, this.refreshRoomList);
+
+ if (this.filterTimeout) {
+ clearTimeout(this.filterTimeout);
+ }
+ },
+
+ onJoinClick: function(alias) {
+ // If we don't have a particular instance id selected, just show that rooms alias
+ if (!this.state.instanceId) {
+ // If the user specified an alias without a domain, add on whichever server is selected
+ // in the dropdown
+ if (alias.indexOf(':') == -1) {
+ alias = alias + ':' + this.state.roomServer;
+ }
+ this.showRoomAlias(alias);
+ } else {
+ // This is a 3rd party protocol. Let's see if we can join it
+ const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
+ const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
+ const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null;
+ if (!fields) {
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, {
+ title: _t('Unable to join network'),
+ description: _t('Riot does not know how to join a room on this network'),
+ });
+ return;
+ }
+ MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).done((resp) => {
+ if (resp.length > 0 && resp[0].alias) {
+ this.showRoomAlias(resp[0].alias);
+ } else {
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
+ title: _t('Room not found'),
+ description: _t('Couldn\'t find a matching Matrix room'),
+ });
+ }
+ }, (e) => {
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, {
+ title: _t('Fetching third party location failed'),
+ description: _t('Unable to look up room ID from server'),
+ });
+ });
+ }
+ },
+
+ showRoomAlias: function(alias) {
+ this.showRoom(null, alias);
+ },
+
+ showRoom: function(room, room_alias) {
+ var payload = {action: 'view_room'};
+ if (room) {
+ // Don't let the user view a room they won't be able to either
+ // peek or join: fail earlier so they don't have to click back
+ // to the directory.
+ if (MatrixClientPeg.get().isGuest()) {
+ if (!room.world_readable && !room.guest_can_join) {
+ dis.dispatch({action: 'view_set_mxid'});
+ return;
+ }
+ }
+
+ if (!room_alias) {
+ room_alias = get_display_alias_for_room(room);
+ }
+
+ payload.oob_data = {
+ avatarUrl: room.avatar_url,
+ // XXX: This logic is duplicated from the JS SDK which
+ // would normally decide what the name is.
+ name: room.name || room_alias || _t('Unnamed room'),
+ };
+ }
+ // It's not really possible to join Matrix rooms by ID because the HS has no way to know
+ // which servers to start querying. However, there's no other way to join rooms in
+ // this list without aliases at present, so if roomAlias isn't set here we have no
+ // choice but to supply the ID.
+ if (room_alias) {
+ payload.room_alias = room_alias;
+ } else {
+ payload.room_id = room.room_id;
+ }
+ dis.dispatch(payload);
+ },
+
+ getRows: function() {
+ var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
+
+ if (!this.state.publicRooms) return [];
+
+ var rooms = this.state.publicRooms;
+ var rows = [];
+ var self = this;
+ var guestRead, guestJoin, perms;
+ for (var i = 0; i < rooms.length; i++) {
+ var name = rooms[i].name || get_display_alias_for_room(rooms[i]) || _t('Unnamed room');
+ guestRead = null;
+ guestJoin = null;
+
+ if (rooms[i].world_readable) {
+ guestRead = (
+
+ );
+ }
+ return rows;
+ },
+
+ collectScrollPanel: function(element) {
+ this.scrollPanel = element;
+ },
+
+ _stringLooksLikeId: function(s, field_type) {
+ let pat = /^#[^\s]+:[^\s]/;
+ if (field_type && field_type.regexp) {
+ pat = new RegExp(field_type.regexp);
+ }
+
+ return pat.test(s);
+ },
+
+ _getFieldsForThirdPartyLocation: function(userInput, protocol, instance) {
+ // make an object with the fields specified by that protocol. We
+ // require that the values of all but the last field come from the
+ // instance. The last is the user input.
+ const requiredFields = protocol.location_fields;
+ if (!requiredFields) return null;
+ const fields = {};
+ for (let i = 0; i < requiredFields.length - 1; ++i) {
+ const thisField = requiredFields[i];
+ if (instance.fields[thisField] === undefined) return null;
+ fields[thisField] = instance.fields[thisField];
+ }
+ fields[requiredFields[requiredFields.length - 1]] = userInput;
+ return fields;
+ },
+
+ /**
+ * called by the parent component when PageUp/Down/etc is pressed.
+ *
+ * We pass it down to the scroll panel.
+ */
+ handleScrollKey: function(ev) {
+ if (this.scrollPanel) {
+ this.scrollPanel.handleScrollKey(ev);
+ }
+ },
+
+ render: function() {
+ const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
+ const Loader = sdk.getComponent("elements.Spinner");
+
+ if (this.state.protocolsLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ let content;
+ if (this.state.loading) {
+ content =
+
+
;
+ } else {
+ const rows = this.getRows();
+ // we still show the scrollpanel, at least for now, because
+ // otherwise we don't fetch more because we don't get a fill
+ // request from the scrollpanel because there isn't one
+ let scrollpanel_content;
+ if (rows.length == 0) {
+ scrollpanel_content = { _t('No rooms to show') };
+ } else {
+ scrollpanel_content =
+ );
+ }
+});
+
+// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
+// but works with the objects we get from the public room list
+function get_display_alias_for_room(room) {
+ return room.canonical_alias || (room.aliases ? room.aliases[0] : "");
+}
diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js
new file mode 100644
index 0000000000..ed092d94ad
--- /dev/null
+++ b/src/components/structures/RoomSubList.js
@@ -0,0 +1,401 @@
+/*
+Copyright 2017 Vector Creations Ltd
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+var React = require('react');
+var ReactDOM = require('react-dom');
+var classNames = require('classnames');
+var sdk = require('matrix-react-sdk');
+import { Droppable } from 'react-beautiful-dnd';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+var dis = require('matrix-react-sdk/lib/dispatcher');
+var Unread = require('matrix-react-sdk/lib/Unread');
+var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg');
+var RoomNotifs = require('matrix-react-sdk/lib/RoomNotifs');
+var FormattingUtils = require('matrix-react-sdk/lib/utils/FormattingUtils');
+var AccessibleButton = require('matrix-react-sdk/lib/components/views/elements/AccessibleButton');
+import Modal from 'matrix-react-sdk/lib/Modal';
+import { KeyCode } from 'matrix-react-sdk/lib/Keyboard';
+
+
+// turn this on for drop & drag console debugging galore
+var debug = false;
+
+const TRUNCATE_AT = 10;
+
+var RoomSubList = React.createClass({
+ displayName: 'RoomSubList',
+
+ debug: debug,
+
+ propTypes: {
+ list: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
+ label: React.PropTypes.string.isRequired,
+ tagName: React.PropTypes.string,
+ editable: React.PropTypes.bool,
+
+ order: React.PropTypes.string.isRequired,
+
+ // passed through to RoomTile and used to highlight room with `!` regardless of notifications count
+ isInvite: React.PropTypes.bool,
+
+ startAsHidden: React.PropTypes.bool,
+ showSpinner: React.PropTypes.bool, // true to show a spinner if 0 elements when expanded
+ collapsed: React.PropTypes.bool.isRequired, // is LeftPanel collapsed?
+ onHeaderClick: React.PropTypes.func,
+ alwaysShowHeader: React.PropTypes.bool,
+ incomingCall: React.PropTypes.object,
+ onShowMoreRooms: React.PropTypes.func,
+ searchFilter: React.PropTypes.string,
+ emptyContent: React.PropTypes.node, // content shown if the list is empty
+ headerItems: React.PropTypes.node, // content shown in the sublist header
+ extraTiles: React.PropTypes.arrayOf(React.PropTypes.node), // extra elements added beneath tiles
+ },
+
+ getInitialState: function() {
+ return {
+ hidden: this.props.startAsHidden || false,
+ truncateAt: TRUNCATE_AT,
+ sortedList: [],
+ };
+ },
+
+ getDefaultProps: function() {
+ return {
+ onHeaderClick: function() {}, // NOP
+ onShowMoreRooms: function() {}, // NOP
+ extraTiles: [],
+ isInvite: false,
+ };
+ },
+
+ componentWillMount: function() {
+ this.setState({
+ sortedList: this.applySearchFilter(this.props.list, this.props.searchFilter),
+ });
+ this.dispatcherRef = dis.register(this.onAction);
+ },
+
+ componentWillUnmount: function() {
+ dis.unregister(this.dispatcherRef);
+ },
+
+ componentWillReceiveProps: function(newProps) {
+ // order the room list appropriately before we re-render
+ //if (debug) console.log("received new props, list = " + newProps.list);
+ this.setState({
+ sortedList: this.applySearchFilter(newProps.list, newProps.searchFilter),
+ });
+ },
+
+ applySearchFilter: function(list, filter) {
+ if (filter === "") return list;
+ return list.filter((room) => {
+ return room.name && room.name.toLowerCase().indexOf(filter.toLowerCase()) >= 0
+ });
+ },
+
+ // The header is collapsable if it is hidden or not stuck
+ // The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
+ isCollapsableOnClick: function() {
+ var stuck = this.refs.header.dataset.stuck;
+ if (this.state.hidden || stuck === undefined || stuck === "none") {
+ return true;
+ } else {
+ return false;
+ }
+ },
+
+ onAction: function(payload) {
+ // XXX: Previously RoomList would forceUpdate whenever on_room_read is dispatched,
+ // but this is no longer true, so we must do it here (and can apply the small
+ // optimisation of checking that we care about the room being read).
+ //
+ // Ultimately we need to transition to a state pushing flow where something
+ // explicitly notifies the components concerned that the notif count for a room
+ // has change (e.g. a Flux store).
+ if (payload.action === 'on_room_read' &&
+ this.props.list.some((r) => r.roomId === payload.roomId)
+ ) {
+ this.forceUpdate();
+ }
+ },
+
+ onClick: function(ev) {
+ if (this.isCollapsableOnClick()) {
+ // The header isCollapsable, so the click is to be interpreted as collapse and truncation logic
+ var isHidden = !this.state.hidden;
+ this.setState({ hidden : isHidden });
+
+ if (isHidden) {
+ // as good a way as any to reset the truncate state
+ this.setState({ truncateAt : TRUNCATE_AT });
+ }
+
+ this.props.onShowMoreRooms();
+ this.props.onHeaderClick(isHidden);
+ } else {
+ // The header is stuck, so the click is to be interpreted as a scroll to the header
+ this.props.onHeaderClick(this.state.hidden, this.refs.header.dataset.originalPosition);
+ }
+ },
+
+ onRoomTileClick(roomId, ev) {
+ dis.dispatch({
+ action: 'view_room',
+ room_id: roomId,
+ clear_search: (ev && (ev.keyCode == KeyCode.ENTER || ev.keyCode == KeyCode.SPACE)),
+ });
+ },
+
+ _shouldShowNotifBadge: function(roomNotifState) {
+ const showBadgeInStates = [RoomNotifs.ALL_MESSAGES, RoomNotifs.ALL_MESSAGES_LOUD];
+ return showBadgeInStates.indexOf(roomNotifState) > -1;
+ },
+
+ _shouldShowMentionBadge: function(roomNotifState) {
+ return roomNotifState != RoomNotifs.MUTE;
+ },
+
+ /**
+ * Total up all the notification counts from the rooms
+ *
+ * @param {Number} If supplied will only total notifications for rooms outside the truncation number
+ * @returns {Array} The array takes the form [total, highlight] where highlight is a bool
+ */
+ roomNotificationCount: function(truncateAt) {
+ var self = this;
+
+ if (this.props.isInvite) {
+ return [0, true];
+ }
+
+ return this.props.list.reduce(function(result, room, index) {
+ if (truncateAt === undefined || index >= truncateAt) {
+ var roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
+ var highlight = room.getUnreadNotificationCount('highlight') > 0;
+ var notificationCount = room.getUnreadNotificationCount();
+
+ const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState);
+ const mentionBadges = highlight && self._shouldShowMentionBadge(roomNotifState);
+ const badges = notifBadges || mentionBadges;
+
+ if (badges) {
+ result[0] += notificationCount;
+ if (highlight) {
+ result[1] = true;
+ }
+ }
+ }
+ return result;
+ }, [0, false]);
+ },
+
+ _updateSubListCount: function() {
+ // Force an update by setting the state to the current state
+ // Doing it this way rather than using forceUpdate(), so that the shouldComponentUpdate()
+ // method is honoured
+ this.setState(this.state);
+ },
+
+ makeRoomTiles: function() {
+ const DNDRoomTile = sdk.getComponent("rooms.DNDRoomTile");
+ const RoomTile = sdk.getComponent("rooms.RoomTile");
+ return this.state.sortedList.map((room, index) => {
+ // XXX: is it evil to pass in this as a prop to RoomTile? Yes.
+
+ // We should only use when editable
+ const RoomTileComponent = this.props.editable ? DNDRoomTile : RoomTile;
+ return 0 || this.props.isInvite}
+ isInvite={this.props.isInvite}
+ refreshSubList={this._updateSubListCount}
+ incomingCall={null}
+ onClick={this.onRoomTileClick}
+ />;
+ });
+ },
+
+ _getHeaderJsx: function() {
+ var TintableSvg = sdk.getComponent("elements.TintableSvg");
+
+ var subListNotifications = this.roomNotificationCount();
+ var subListNotifCount = subListNotifications[0];
+ var subListNotifHighlight = subListNotifications[1];
+
+ var totalTiles = this.props.list.length + (this.props.extraTiles || []).length;
+ var roomCount = totalTiles > 0 ? totalTiles : '';
+
+ var chevronClasses = classNames({
+ 'mx_RoomSubList_chevron': true,
+ 'mx_RoomSubList_chevronRight': this.state.hidden,
+ 'mx_RoomSubList_chevronDown': !this.state.hidden,
+ });
+
+ var badgeClasses = classNames({
+ 'mx_RoomSubList_badge': true,
+ 'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
+ });
+
+ var badge;
+ if (subListNotifCount > 0) {
+ badge =
;
+ } else if (this.props.isInvite) {
+ // no notifications but highlight anyway because this is an invite badge
+ badge =
!
;
+ }
+
+ // When collapsed, allow a long hover on the header to show user
+ // the full tag name and room count
+ var title;
+ if (this.props.collapsed) {
+ title = this.props.label;
+ if (roomCount !== '') {
+ title += " [" + roomCount + "]";
+ }
+ }
+
+ var incomingCall;
+ if (this.props.incomingCall) {
+ var self = this;
+ // Check if the incoming call is for this section
+ var incomingCallRoom = this.props.list.filter(function(room) {
+ return self.props.incomingCall.roomId === room.roomId;
+ });
+
+ if (incomingCallRoom.length === 1) {
+ var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
+ incomingCall = ;
+ }
+ }
+
+ var tabindex = this.props.searchFilter === "" ? "0" : "-1";
+
+ return (
+
+ );
+ }
+ }
+});
+
+module.exports = RoomSubList;
diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js
new file mode 100644
index 0000000000..2d6e6ae04c
--- /dev/null
+++ b/src/components/structures/SearchBox.js
@@ -0,0 +1,163 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+import React from 'react';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+import { KeyCode } from 'matrix-react-sdk/lib/Keyboard';
+import sdk from 'matrix-react-sdk';
+import dis from 'matrix-react-sdk/lib/dispatcher';
+import rate_limited_func from 'matrix-react-sdk/lib/ratelimitedfunc';
+import AccessibleButton from 'matrix-react-sdk/lib/components/views/elements/AccessibleButton';
+
+module.exports = React.createClass({
+ displayName: 'SearchBox',
+
+ propTypes: {
+ collapsed: React.PropTypes.bool,
+ onSearch: React.PropTypes.func,
+ },
+
+ getInitialState: function() {
+ return {
+ searchTerm: "",
+ };
+ },
+
+ componentDidMount: function() {
+ this.dispatcherRef = dis.register(this.onAction);
+ },
+
+ componentWillUnmount: function() {
+ dis.unregister(this.dispatcherRef);
+ },
+
+ onAction: function(payload) {
+ switch (payload.action) {
+ case 'view_room':
+ if (this.refs.search && payload.clear_search) {
+ this._clearSearch();
+ }
+ break;
+ case 'focus_room_filter':
+ if (this.refs.search) {
+ this.refs.search.focus();
+ this.refs.search.select();
+ }
+ break;
+ }
+ },
+
+ onChange: function() {
+ if (!this.refs.search) return;
+ this.setState({ searchTerm: this.refs.search.value });
+ this.onSearch();
+ },
+
+ onSearch: new rate_limited_func(
+ function() {
+ this.props.onSearch(this.refs.search.value);
+ },
+ 100
+ ),
+
+ onToggleCollapse: function(show) {
+ if (show) {
+ dis.dispatch({
+ action: 'show_left_panel',
+ });
+ }
+ else {
+ dis.dispatch({
+ action: 'hide_left_panel',
+ });
+ }
+ },
+
+ _onKeyDown: function(ev) {
+ switch (ev.keyCode) {
+ case KeyCode.ESCAPE:
+ this._clearSearch();
+ dis.dispatch({action: 'focus_composer'});
+ break;
+ }
+ },
+
+ _clearSearch: function() {
+ this.refs.search.value = "";
+ this.onChange();
+ },
+
+ render: function() {
+ var TintableSvg = sdk.getComponent('elements.TintableSvg');
+
+ var collapseTabIndex = this.refs.search && this.refs.search.value !== "" ? "-1" : "0";
+
+ var toggleCollapse;
+ if (this.props.collapsed) {
+ toggleCollapse =
+
+
+
+ }
+ else {
+ toggleCollapse =
+
+
+
+ }
+
+ var searchControls;
+ if (!this.props.collapsed) {
+ searchControls = [
+ this.state.searchTerm.length > 0 ?
+ { this._clearSearch(); } }>
+
+
+ :
+ ,
+
+ ];
+ }
+
+ var self = this;
+ return (
+
+ { searchControls }
+ { toggleCollapse }
+
+ );
+ }
+});
diff --git a/src/components/structures/ViewSource.js b/src/components/structures/ViewSource.js
new file mode 100644
index 0000000000..3a5d35a561
--- /dev/null
+++ b/src/components/structures/ViewSource.js
@@ -0,0 +1,57 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import SyntaxHighlight from '../views/elements/SyntaxHighlight';
+
+
+module.exports = React.createClass({
+ displayName: 'ViewSource',
+
+ propTypes: {
+ content: PropTypes.object.isRequired,
+ onFinished: PropTypes.func.isRequired,
+ },
+
+ componentDidMount: function() {
+ document.addEventListener("keydown", this.onKeyDown);
+ },
+
+ componentWillUnmount: function() {
+ document.removeEventListener("keydown", this.onKeyDown);
+ },
+
+ onKeyDown: function(ev) {
+ if (ev.keyCode == 27) { // escape
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.props.onFinished();
+ }
+ },
+
+ render: function() {
+ return (
+
+ );
+ }
+});
diff --git a/src/components/views/context_menus/GenericElementContextMenu.js b/src/components/views/context_menus/GenericElementContextMenu.js
new file mode 100644
index 0000000000..3f4804dbd1
--- /dev/null
+++ b/src/components/views/context_menus/GenericElementContextMenu.js
@@ -0,0 +1,60 @@
+/*
+Copyright 2017 New Vector Ltd
+
+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.
+*/
+
+'use strict';
+
+import React from 'react';
+import PropTypes from 'prop-types';
+
+/*
+ * This component can be used to display generic HTML content in a contextual
+ * menu.
+ */
+
+
+export default class GenericElementContextMenu extends React.Component {
+ static PropTypes = {
+ element: PropTypes.element.isRequired,
+ // Function to be called when the parent window is resized
+ // This can be used to reposition or close the menu on resize and
+ // ensure that it is not displayed in a stale position.
+ onResize: PropTypes.func,
+ };
+
+ constructor(props) {
+ super(props);
+ this.resize = this.resize.bind(this);
+ }
+
+ componentDidMount() {
+ this.resize = this.resize.bind(this);
+ window.addEventListener("resize", this.resize);
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener("resize", this.resize);
+ }
+
+ resize() {
+ if (this.props.onResize) {
+ this.props.onResize();
+ }
+ }
+
+ render() {
+ return
{ this.props.element }
;
+ }
+}
diff --git a/src/components/views/context_menus/GenericTextContextMenu.js b/src/components/views/context_menus/GenericTextContextMenu.js
new file mode 100644
index 0000000000..2319fe05a2
--- /dev/null
+++ b/src/components/views/context_menus/GenericTextContextMenu.js
@@ -0,0 +1,30 @@
+/*
+Copyright 2017 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.
+*/
+
+'use strict';
+
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class GenericTextContextMenu extends React.Component {
+ static PropTypes = {
+ message: PropTypes.string.isRequired,
+ };
+
+ render() {
+ return
{ this.props.message }
;
+ }
+}
diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js
new file mode 100644
index 0000000000..11f14f367e
--- /dev/null
+++ b/src/components/views/context_menus/MessageContextMenu.js
@@ -0,0 +1,326 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+
+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.
+*/
+
+'use strict';
+
+import React from 'react';
+
+import MatrixClientPeg from 'matrix-react-sdk/lib/MatrixClientPeg';
+import dis from 'matrix-react-sdk/lib/dispatcher';
+import sdk from 'matrix-react-sdk';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+import Modal from 'matrix-react-sdk/lib/Modal';
+import Resend from "matrix-react-sdk/lib/Resend";
+import SettingsStore from "matrix-react-sdk/lib/settings/SettingsStore";
+import {makeEventPermalink} from 'matrix-react-sdk/lib/matrix-to';
+import { isUrlPermitted } from 'matrix-react-sdk/lib/HtmlUtils';
+
+module.exports = React.createClass({
+ displayName: 'MessageContextMenu',
+
+ propTypes: {
+ /* the MatrixEvent associated with the context menu */
+ mxEvent: React.PropTypes.object.isRequired,
+
+ /* an optional EventTileOps implementation that can be used to unhide preview widgets */
+ eventTileOps: React.PropTypes.object,
+
+ /* callback called when the menu is dismissed */
+ onFinished: React.PropTypes.func,
+ },
+
+ getInitialState: function() {
+ return {
+ canRedact: false,
+ canPin: false,
+ };
+ },
+
+ componentWillMount: function() {
+ MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions);
+ this._checkPermissions();
+ },
+
+ componentWillUnmount: function() {
+ const cli = MatrixClientPeg.get();
+ if (cli) {
+ cli.removeListener('RoomMember.powerLevel', this._checkPermissions);
+ }
+ },
+
+ _checkPermissions: function() {
+ const cli = MatrixClientPeg.get();
+ const room = cli.getRoom(this.props.mxEvent.getRoomId());
+
+ const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId);
+ let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
+
+ // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
+ if (!SettingsStore.isFeatureEnabled("feature_pinning")) canPin = false;
+
+ this.setState({canRedact, canPin});
+ },
+
+ _isPinned: function() {
+ const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
+ const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', '');
+ if (!pinnedEvent) return false;
+ return pinnedEvent.getContent().pinned.includes(this.props.mxEvent.getId());
+ },
+
+ onResendClick: function() {
+ Resend.resend(this.props.mxEvent);
+ this.closeMenu();
+ },
+
+ onViewSourceClick: function() {
+ const ViewSource = sdk.getComponent('structures.ViewSource');
+ Modal.createTrackedDialog('View Event Source', '', ViewSource, {
+ content: this.props.mxEvent.event,
+ }, 'mx_Dialog_viewsource');
+ this.closeMenu();
+ },
+
+ onViewClearSourceClick: function() {
+ const ViewSource = sdk.getComponent('structures.ViewSource');
+ Modal.createTrackedDialog('View Clear Event Source', '', ViewSource, {
+ // FIXME: _clearEvent is private
+ content: this.props.mxEvent._clearEvent,
+ }, 'mx_Dialog_viewsource');
+ this.closeMenu();
+ },
+
+ onRedactClick: function() {
+ const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
+ Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
+ onFinished: (proceed) => {
+ if (!proceed) return;
+
+ const cli = MatrixClientPeg.get();
+ cli.redactEvent(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId()).catch(function(e) {
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ // display error message stating you couldn't delete this.
+ const code = e.errcode || e.statusCode;
+ Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, {
+ title: _t('Error'),
+ description: _t('You cannot delete this message. (%(code)s)', {code}),
+ });
+ }).done();
+ },
+ }, 'mx_Dialog_confirmredact');
+ this.closeMenu();
+ },
+
+ onCancelSendClick: function() {
+ Resend.removeFromQueue(this.props.mxEvent);
+ this.closeMenu();
+ },
+
+ onForwardClick: function() {
+ dis.dispatch({
+ action: 'forward_event',
+ event: this.props.mxEvent,
+ });
+ this.closeMenu();
+ },
+
+ onPinClick: function() {
+ MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '')
+ .catch((e) => {
+ // Intercept the Event Not Found error and fall through the promise chain with no event.
+ if (e.errcode === "M_NOT_FOUND") return null;
+ throw e;
+ })
+ .then((event) => {
+ const eventIds = (event ? event.pinned : []) || [];
+ if (!eventIds.includes(this.props.mxEvent.getId())) {
+ // Not pinned - add
+ eventIds.push(this.props.mxEvent.getId());
+ } else {
+ // Pinned - remove
+ eventIds.splice(eventIds.indexOf(this.props.mxEvent.getId()), 1);
+ }
+
+ const cli = MatrixClientPeg.get();
+ cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, '');
+ });
+ this.closeMenu();
+ },
+
+ closeMenu: function() {
+ if (this.props.onFinished) this.props.onFinished();
+ },
+
+ onUnhidePreviewClick: function() {
+ if (this.props.eventTileOps) {
+ this.props.eventTileOps.unhideWidget();
+ }
+ this.closeMenu();
+ },
+
+ onQuoteClick: function() {
+ dis.dispatch({
+ action: 'quote',
+ text: this.props.eventTileOps.getInnerText(),
+ });
+ this.closeMenu();
+ },
+
+ onReplyClick: function() {
+ dis.dispatch({
+ action: 'quote_event',
+ event: this.props.mxEvent,
+ });
+ this.closeMenu();
+ },
+
+ render: function() {
+ const eventStatus = this.props.mxEvent.status;
+ let resendButton;
+ let redactButton;
+ let cancelButton;
+ let forwardButton;
+ let pinButton;
+ let viewClearSourceButton;
+ let unhidePreviewButton;
+ let externalURLButton;
+ let quoteButton;
+ let replyButton;
+
+ if (eventStatus === 'not_sent') {
+ resendButton = (
+
+ );
+ }
+
+ if (this.props.eventTileOps) {
+ if (this.props.eventTileOps.isWidgetHidden()) {
+ unhidePreviewButton = (
+
+ { _t('Unhide Preview') }
+
+ );
+ }
+ }
+
+ // XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID)
+ const permalinkButton = (
+
+ );
+ }
+
+ // Bridges can provide a 'external_url' to link back to the source.
+ if (
+ typeof(this.props.mxEvent.event.content.external_url) === "string" &&
+ isUrlPermitted(this.props.mxEvent.event.content.external_url)
+ ) {
+ externalURLButton = (
+
+ );
+ },
+});
diff --git a/src/components/views/context_menus/TagTileContextMenu.js b/src/components/views/context_menus/TagTileContextMenu.js
new file mode 100644
index 0000000000..576e8485a8
--- /dev/null
+++ b/src/components/views/context_menus/TagTileContextMenu.js
@@ -0,0 +1,75 @@
+/*
+Copyright 2018 New Vector Ltd
+
+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 from 'react';
+import PropTypes from 'prop-types';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+import dis from 'matrix-react-sdk/lib/dispatcher';
+import TagOrderActions from 'matrix-react-sdk/lib/actions/TagOrderActions';
+import MatrixClientPeg from 'matrix-react-sdk/lib/MatrixClientPeg';
+import sdk from 'matrix-react-sdk/lib/index';
+
+export default class TagTileContextMenu extends React.Component {
+ static propTypes = {
+ tag: PropTypes.string.isRequired,
+ /* callback called when the menu is dismissed */
+ onFinished: PropTypes.func.isRequired,
+ };
+
+ constructor() {
+ super();
+
+ this._onViewCommunityClick = this._onViewCommunityClick.bind(this);
+ this._onRemoveClick = this._onRemoveClick.bind(this);
+ }
+
+ _onViewCommunityClick() {
+ dis.dispatch({
+ action: 'view_group',
+ group_id: this.props.tag,
+ });
+ this.props.onFinished();
+ }
+
+ _onRemoveClick() {
+ dis.dispatch(TagOrderActions.removeTag(
+ // XXX: Context menus don't have a MatrixClient context
+ MatrixClientPeg.get(),
+ this.props.tag,
+ ));
+ this.props.onFinished();
+ }
+
+ render() {
+ const TintableSvg = sdk.getComponent("elements.TintableSvg");
+ return
+
+
+ { _t('View Community') }
+
+
+
+
+ { _t('Remove') }
+
+
;
+ }
+}
diff --git a/src/components/views/dialogs/BugReportDialog.js b/src/components/views/dialogs/BugReportDialog.js
new file mode 100644
index 0000000000..95b5a6a068
--- /dev/null
+++ b/src/components/views/dialogs/BugReportDialog.js
@@ -0,0 +1,212 @@
+/*
+Copyright 2017 OpenMarket Ltd
+
+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 from 'react';
+import sdk from 'matrix-react-sdk';
+import SdkConfig from 'matrix-react-sdk/lib/SdkConfig';
+import Modal from 'matrix-react-sdk/lib/Modal';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+
+export default class BugReportDialog extends React.Component {
+ constructor(props, context) {
+ super(props, context);
+ this.state = {
+ sendLogs: true,
+ busy: false,
+ err: null,
+ issueUrl: "",
+ text: "",
+ progress: null,
+ };
+ this._unmounted = false;
+ this._onSubmit = this._onSubmit.bind(this);
+ this._onCancel = this._onCancel.bind(this);
+ this._onTextChange = this._onTextChange.bind(this);
+ this._onIssueUrlChange = this._onIssueUrlChange.bind(this);
+ this._onSendLogsChange = this._onSendLogsChange.bind(this);
+ this._sendProgressCallback = this._sendProgressCallback.bind(this);
+ }
+
+ componentWillUnmount() {
+ this._unmounted = true;
+ }
+
+ _onCancel(ev) {
+ this.props.onFinished(false);
+ }
+
+ _onSubmit(ev) {
+ const userText =
+ (this.state.text.length > 0 ? this.state.text + '\n\n': '') + 'Issue: ' +
+ (this.state.issueUrl.length > 0 ? this.state.issueUrl : 'No issue link given');
+
+ this.setState({ busy: true, progress: null, err: null });
+ this._sendProgressCallback(_t("Preparing to send logs"));
+
+ require(['../../../vector/submit-rageshake'], (s) => {
+ s(SdkConfig.get().bug_report_endpoint_url, {
+ userText,
+ sendLogs: true,
+ progressCallback: this._sendProgressCallback,
+ }).then(() => {
+ if (!this._unmounted) {
+ this.props.onFinished(false);
+ const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
+ Modal.createTrackedDialog('Bug report sent', '', QuestionDialog, {
+ title: _t('Logs sent'),
+ description: _t('Thank you!'),
+ hasCancelButton: false,
+ });
+ }
+ }, (err) => {
+ if (!this._unmounted) {
+ this.setState({
+ busy: false,
+ progress: null,
+ err: _t("Failed to send logs: ") + `${err.message}`,
+ });
+ }
+ });
+ });
+ }
+
+ _onTextChange(ev) {
+ this.setState({ text: ev.target.value });
+ }
+
+ _onIssueUrlChange(ev) {
+ this.setState({ issueUrl: ev.target.value });
+ }
+
+ _onSendLogsChange(ev) {
+ this.setState({ sendLogs: ev.target.checked });
+ }
+
+ _sendProgressCallback(progress) {
+ if (this._unmounted) {
+ return;
+ }
+ this.setState({progress: progress});
+ }
+
+ render() {
+ const Loader = sdk.getComponent("elements.Spinner");
+
+ let error = null;
+ if (this.state.err) {
+ error =
+ {this.state.err}
+
;
+ }
+
+ let cancelButton = null;
+ if (!this.state.busy) {
+ cancelButton = ;
+ }
+
+ let progress = null;
+ if (this.state.busy) {
+ progress = (
+
+
+ {this.state.progress} ...
+
+ );
+ }
+
+ return (
+
+
+ { _t("Submit debug logs") }
+
+
+
+ { _t(
+ "Debug logs contain application usage data including your " +
+ "username, the IDs or aliases of the rooms or groups you " +
+ "have visited and the usernames of other users. They do " +
+ "not contain messages.",
+ ) }
+
+
+ { _t(
+ "Click here to create a GitHub issue.",
+ {},
+ {
+ a: (sub) =>
+ { sub }
+ ,
+ },
+ ) }
+
+
+
+
+
+
+
+
+
+ {progress}
+ {error}
+
+
+
+
+ {cancelButton}
+
+
+ );
+ }
+}
+
+BugReportDialog.propTypes = {
+ onFinished: React.PropTypes.func.isRequired,
+};
diff --git a/src/components/views/dialogs/ChangelogDialog.js b/src/components/views/dialogs/ChangelogDialog.js
new file mode 100644
index 0000000000..92aeadf8c8
--- /dev/null
+++ b/src/components/views/dialogs/ChangelogDialog.js
@@ -0,0 +1,94 @@
+/*
+ Copyright 2016 Aviral Dasgupta
+
+ 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 from 'react';
+import sdk from 'matrix-react-sdk';
+import request from 'browser-request';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+
+const REPOS = ['vector-im/riot-web', 'matrix-org/matrix-react-sdk', 'matrix-org/matrix-js-sdk'];
+
+export default class ChangelogDialog extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+ }
+
+ componentDidMount() {
+ const version = this.props.newVersion.split('-');
+ const version2 = this.props.version.split('-');
+ if(version == null || version2 == null) return;
+ // parse versions of form: [vectorversion]-react-[react-sdk-version]-js-[js-sdk-version]
+ for(let i=0; i {
+ if(body == null) return;
+ this.setState({[REPOS[i]]: JSON.parse(body).commits});
+ });
+ }
+ }
+
+ _elementsForCommit(commit) {
+ return (
+
;
+ }
+
+ const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
+ return (
+
+ { body }
+
+ );
+ }
+}
diff --git a/src/components/views/dialogs/SetPasswordDialog.js b/src/components/views/dialogs/SetPasswordDialog.js
new file mode 100644
index 0000000000..b82d03468c
--- /dev/null
+++ b/src/components/views/dialogs/SetPasswordDialog.js
@@ -0,0 +1,137 @@
+/*
+Copyright 2017 Vector Creations Ltd
+
+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 from 'react';
+import sdk from 'matrix-react-sdk';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+import Modal from 'matrix-react-sdk/lib/Modal';
+
+const WarmFuzzy = function(props) {
+ const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
+ let title = _t('You have successfully set a password!');
+ if (props.didSetEmail) {
+ title = _t('You have successfully set a password and an email address!');
+ }
+ const advice = _t('You can now return to your account after signing out, and sign in on other devices.');
+ let extraAdvice = null;
+ if (!props.didSetEmail) {
+ extraAdvice = _t('Remember, you can always set an email address in user settings if you change your mind.');
+ }
+
+ return
+
+ { _t('This will allow you to return to your account after signing out, and sign in on other devices.') }
+
+
+
+ { this.state.error }
+
+
+
+ );
+ },
+});
diff --git a/src/components/views/directory/NetworkDropdown.js b/src/components/views/directory/NetworkDropdown.js
new file mode 100644
index 0000000000..9c19cdbced
--- /dev/null
+++ b/src/components/views/directory/NetworkDropdown.js
@@ -0,0 +1,254 @@
+/*
+Copyright 2016 OpenMarket Ltd
+
+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 from 'react';
+import MatrixClientPeg from 'matrix-react-sdk/lib/MatrixClientPeg';
+import {instanceForInstanceId} from '../../../utils/DirectoryUtils';
+
+const DEFAULT_ICON_URL = "img/network-matrix.svg";
+
+export default class NetworkDropdown extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.dropdownRootElement = null;
+ this.ignoreEvent = null;
+
+ this.onInputClick = this.onInputClick.bind(this);
+ this.onRootClick = this.onRootClick.bind(this);
+ this.onDocumentClick = this.onDocumentClick.bind(this);
+ this.onMenuOptionClick = this.onMenuOptionClick.bind(this);
+ this.onInputKeyUp = this.onInputKeyUp.bind(this);
+ this.collectRoot = this.collectRoot.bind(this);
+ this.collectInputTextBox = this.collectInputTextBox.bind(this);
+
+ this.inputTextBox = null;
+
+ const server = MatrixClientPeg.getHomeServerName();
+ this.state = {
+ expanded: false,
+ selectedServer: server,
+ selectedInstance: null,
+ includeAllNetworks: false,
+ };
+ }
+
+ componentWillMount() {
+ // Listen for all clicks on the document so we can close the
+ // menu when the user clicks somewhere else
+ document.addEventListener('click', this.onDocumentClick, false);
+
+ // fire this now so the defaults can be set up
+ this.props.onOptionChange(this.state.selectedServer, this.state.selectedInstance, this.state.includeAllNetworks);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('click', this.onDocumentClick, false);
+ }
+
+ componentDidUpdate() {
+ if (this.state.expanded && this.inputTextBox) {
+ this.inputTextBox.focus();
+ }
+ }
+
+ onDocumentClick(ev) {
+ // Close the dropdown if the user clicks anywhere that isn't
+ // within our root element
+ if (ev !== this.ignoreEvent) {
+ this.setState({
+ expanded: false,
+ });
+ }
+ }
+
+ onRootClick(ev) {
+ // This captures any clicks that happen within our elements,
+ // such that we can then ignore them when they're seen by the
+ // click listener on the document handler, ie. not close the
+ // dropdown immediately after opening it.
+ // NB. We can't just stopPropagation() because then the event
+ // doesn't reach the React onClick().
+ this.ignoreEvent = ev;
+ }
+
+ onInputClick(ev) {
+ this.setState({
+ expanded: !this.state.expanded,
+ });
+ ev.preventDefault();
+ }
+
+ onMenuOptionClick(server, instance, includeAll) {
+ this.setState({
+ expanded: false,
+ selectedServer: server,
+ selectedInstanceId: instance ? instance.instance_id : null,
+ includeAll: includeAll,
+ });
+ this.props.onOptionChange(server, instance ? instance.instance_id : null, includeAll);
+ }
+
+ onInputKeyUp(e) {
+ if (e.key == 'Enter') {
+ this.setState({
+ expanded: false,
+ selectedServer: e.target.value,
+ selectedNetwork: null,
+ });
+ this.props.onOptionChange(e.target.value, null);
+ }
+ }
+
+ collectRoot(e) {
+ if (this.dropdownRootElement) {
+ this.dropdownRootElement.removeEventListener('click', this.onRootClick, false);
+ }
+ if (e) {
+ e.addEventListener('click', this.onRootClick, false);
+ }
+ this.dropdownRootElement = e;
+ }
+
+ collectInputTextBox(e) {
+ this.inputTextBox = e;
+ }
+
+ _getMenuOptions() {
+ const options = [];
+
+ let servers = [];
+ if (this.props.config.servers) {
+ servers = servers.concat(this.props.config.servers);
+ }
+
+ if (servers.indexOf(MatrixClientPeg.getHomeServerName()) == -1) {
+ servers.unshift(MatrixClientPeg.getHomeServerName());
+ }
+
+ // For our own HS, we can use the instance_ids given in the third party protocols
+ // response to get the server to filter the room list by network for us.
+ // We can't get thirdparty protocols for remote server yet though, so for those
+ // we can only show the default room list.
+ for (const server of servers) {
+ options.push(this._makeMenuOption(server, null, true));
+ if (server == MatrixClientPeg.getHomeServerName()) {
+ options.push(this._makeMenuOption(server, null, false));
+ if (this.props.protocols) {
+ for (const proto of Object.keys(this.props.protocols)) {
+ if (!this.props.protocols[proto].instances) continue;
+
+ const sortedInstances = this.props.protocols[proto].instances;
+ sortedInstances.sort(function(x, y) {
+ const a = x.desc
+ const b = y.desc
+ if (a < b) {
+ return -1;
+ } else if (a > b) {
+ return 1;
+ } else {
+ return 0;
+ }
+ });
+
+ for (const instance of sortedInstances) {
+ if (!instance.instance_id) continue;
+ options.push(this._makeMenuOption(server, instance, false));
+ }
+ }
+ }
+ }
+ }
+
+ return options;
+ }
+
+ _makeMenuOption(server, instance, includeAll, handleClicks) {
+ if (handleClicks === undefined) handleClicks = true;
+
+ let icon;
+ let name;
+ let span_class;
+ let key;
+
+ if (!instance && includeAll) {
+ key = server;
+ name = server;
+ span_class = 'mx_NetworkDropdown_menu_all';
+ } else if (!instance) {
+ key = server + '_all';
+ name = 'Matrix';
+ icon = ;
+ span_class = 'mx_NetworkDropdown_menu_network';
+ } else {
+ key = server + '_inst_' + instance.instance_id;
+ const imgUrl = instance.icon ?
+ MatrixClientPeg.get().mxcUrlToHttp(instance.icon, 25, 25, 'crop', true) :
+ DEFAULT_ICON_URL;
+ icon = ;
+ name = instance.desc;
+ span_class = 'mx_NetworkDropdown_menu_network';
+ }
+
+ const click_handler = handleClicks ? this.onMenuOptionClick.bind(this, server, instance, includeAll) : null;
+
+ return
+ {icon}
+ {name}
+
+ }
+
+ render() {
+ let current_value;
+
+ let menu;
+ if (this.state.expanded) {
+ const menu_options = this._getMenuOptions();
+ menu =
;
+ }
+}
+
+NetworkDropdown.propTypes = {
+ onOptionChange: React.PropTypes.func.isRequired,
+ protocols: React.PropTypes.object,
+ // The room directory config. May have a 'servers' key that is a list of server names to include in the dropdown
+ config: React.PropTypes.object,
+};
+
+NetworkDropdown.defaultProps = {
+ protocols: {},
+ config: {},
+};
diff --git a/src/components/views/elements/ImageView.js b/src/components/views/elements/ImageView.js
new file mode 100644
index 0000000000..d1a34d02df
--- /dev/null
+++ b/src/components/views/elements/ImageView.js
@@ -0,0 +1,205 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+var React = require('react');
+
+var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg');
+
+import {formatDate} from 'matrix-react-sdk/lib/DateUtils';
+var filesize = require('filesize');
+var AccessibleButton = require('matrix-react-sdk/lib/components/views/elements/AccessibleButton');
+const Modal = require('matrix-react-sdk/lib/Modal');
+const sdk = require('matrix-react-sdk');
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+
+module.exports = React.createClass({
+ displayName: 'ImageView',
+
+ propTypes: {
+ src: React.PropTypes.string.isRequired, // the source of the image being displayed
+ name: React.PropTypes.string, // the main title ('name') for the image
+ link: React.PropTypes.string, // the link (if any) applied to the name of the image
+ width: React.PropTypes.number, // width of the image src in pixels
+ height: React.PropTypes.number, // height of the image src in pixels
+ fileSize: React.PropTypes.number, // size of the image src in bytes
+ onFinished: React.PropTypes.func.isRequired, // callback when the lightbox is dismissed
+
+ // the event (if any) that the Image is displaying. Used for event-specific stuff like
+ // redactions, senders, timestamps etc. Other descriptors are taken from the explicit
+ // properties above, which let us use lightboxes to display images which aren't associated
+ // with events.
+ mxEvent: React.PropTypes.object,
+ },
+
+ // XXX: keyboard shortcuts for managing dialogs should be done by the modal
+ // dialog base class somehow, surely...
+ componentDidMount: function() {
+ document.addEventListener("keydown", this.onKeyDown);
+ },
+
+ componentWillUnmount: function() {
+ document.removeEventListener("keydown", this.onKeyDown);
+ },
+
+ onKeyDown: function(ev) {
+ if (ev.keyCode == 27) { // escape
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.props.onFinished();
+ }
+ },
+
+ onRedactClick: function() {
+ const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
+ Modal.createTrackedDialog('Confirm Redact Dialog', 'Image View', ConfirmRedactDialog, {
+ onFinished: (proceed) => {
+ if (!proceed) return;
+ var self = this;
+ MatrixClientPeg.get().redactEvent(
+ this.props.mxEvent.getRoomId(), this.props.mxEvent.getId()
+ ).catch(function(e) {
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ // display error message stating you couldn't delete this.
+ var code = e.errcode || e.statusCode;
+ Modal.createTrackedDialog('You cannot delete this image.', '', ErrorDialog, {
+ title: _t('Error'),
+ description: _t('You cannot delete this image. (%(code)s)', {code: code})
+ });
+ }).done();
+ }
+ });
+ },
+
+ getName: function () {
+ var name = this.props.name;
+ if (name && this.props.link) {
+ name = { name };
+ }
+ return name;
+ },
+
+ render: function() {
+
+/*
+ // In theory max-width: 80%, max-height: 80% on the CSS should work
+ // but in practice, it doesn't, so do it manually:
+
+ var width = this.props.width || 500;
+ var height = this.props.height || 500;
+
+ var maxWidth = document.documentElement.clientWidth * 0.8;
+ var maxHeight = document.documentElement.clientHeight * 0.8;
+
+ var widthFrac = width / maxWidth;
+ var heightFrac = height / maxHeight;
+
+ var displayWidth;
+ var displayHeight;
+ if (widthFrac > heightFrac) {
+ displayWidth = Math.min(width, maxWidth);
+ displayHeight = (displayWidth / width) * height;
+ } else {
+ displayHeight = Math.min(height, maxHeight);
+ displayWidth = (displayHeight / height) * width;
+ }
+
+ var style = {
+ width: displayWidth,
+ height: displayHeight
+ };
+*/
+ var style, res;
+
+ if (this.props.width && this.props.height) {
+ style = {
+ width: this.props.width,
+ height: this.props.height,
+ };
+ res = style.width + "x" + style.height + "px";
+ }
+
+ var size;
+ if (this.props.fileSize) {
+ size = filesize(this.props.fileSize);
+ }
+
+ var size_res;
+ if (size && res) {
+ size_res = size + ", " + res;
+ }
+ else {
+ size_res = size || res;
+ }
+
+ var showEventMeta = !!this.props.mxEvent;
+
+ var eventMeta;
+ if(showEventMeta) {
+ // Figure out the sender, defaulting to mxid
+ let sender = this.props.mxEvent.getSender();
+ const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
+ if (room) {
+ const member = room.getMember(sender);
+ if (member) sender = member.name;
+ }
+
+ eventMeta = (
+ { _t('Uploaded on %(date)s by %(user)s', {date: formatDate(new Date(this.props.mxEvent.getTs())), user: sender}) }
+
+ );
+ }
+});
diff --git a/src/components/views/elements/InlineSpinner.js b/src/components/views/elements/InlineSpinner.js
new file mode 100644
index 0000000000..adb916fcf0
--- /dev/null
+++ b/src/components/views/elements/InlineSpinner.js
@@ -0,0 +1,33 @@
+/*
+Copyright 2017 New Vector Ltd.
+
+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.
+*/
+
+const React = require('react');
+
+module.exports = React.createClass({
+ displayName: 'InlineSpinner',
+
+ render: function() {
+ var w = this.props.w || 16;
+ var h = this.props.h || 16;
+ var imgClass = this.props.imgClassName || "";
+
+ return (
+
+
+
+ );
+ }
+});
diff --git a/src/components/views/elements/Spinner.js b/src/components/views/elements/Spinner.js
new file mode 100644
index 0000000000..2b620f12c5
--- /dev/null
+++ b/src/components/views/elements/Spinner.js
@@ -0,0 +1,34 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+var React = require('react');
+
+module.exports = React.createClass({
+ displayName: 'Spinner',
+
+ render: function() {
+ var w = this.props.w || 32;
+ var h = this.props.h || 32;
+ var imgClass = this.props.imgClassName || "";
+ return (
+
+
+
+ );
+ }
+});
diff --git a/src/components/views/elements/SyntaxHighlight.js b/src/components/views/elements/SyntaxHighlight.js
new file mode 100644
index 0000000000..82b5ae572c
--- /dev/null
+++ b/src/components/views/elements/SyntaxHighlight.js
@@ -0,0 +1,53 @@
+/*
+Copyright 2017 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 from 'react';
+import PropTypes from 'prop-types';
+import {highlightBlock} from 'highlight.js';
+
+export default class SyntaxHighlight extends React.Component {
+ static propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node,
+ };
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._ref = this._ref.bind(this);
+ }
+
+ // componentDidUpdate used here for reusability
+ // componentWillReceiveProps fires too early to call highlightBlock on.
+ componentDidUpdate() {
+ if (this._el) highlightBlock(this._el);
+ }
+
+ // call componentDidUpdate because _ref is fired on initial render
+ // which does not fire componentDidUpdate
+ _ref(el) {
+ this._el = el;
+ this.componentDidUpdate();
+ }
+
+ render() {
+ const { className, children } = this.props;
+
+ return
+ { children }
+
;
+ }
+}
diff --git a/src/components/views/globals/MatrixToolbar.js b/src/components/views/globals/MatrixToolbar.js
new file mode 100644
index 0000000000..06bfa36e9e
--- /dev/null
+++ b/src/components/views/globals/MatrixToolbar.js
@@ -0,0 +1,46 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+import React from 'react';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+import Notifier from 'matrix-react-sdk/lib/Notifier';
+import AccessibleButton from 'matrix-react-sdk/lib/components/views/elements/AccessibleButton';
+
+module.exports = React.createClass({
+ displayName: 'MatrixToolbar',
+
+ hideToolbar: function() {
+ Notifier.setToolbarHidden(true);
+ },
+
+ onClick: function() {
+ Notifier.setEnabled(true);
+ },
+
+ render: function() {
+ return (
+
+ );
+ },
+});
diff --git a/src/components/views/globals/NewVersionBar.js b/src/components/views/globals/NewVersionBar.js
new file mode 100644
index 0000000000..2aedf39204
--- /dev/null
+++ b/src/components/views/globals/NewVersionBar.js
@@ -0,0 +1,107 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+import React from 'react';
+import sdk from 'matrix-react-sdk';
+import Modal from 'matrix-react-sdk/lib/Modal';
+import PlatformPeg from 'matrix-react-sdk/lib/PlatformPeg';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+
+/**
+ * Check a version string is compatible with the Changelog
+ * dialog ([vectorversion]-react-[react-sdk-version]-js-[js-sdk-version])
+ */
+function checkVersion(ver) {
+ const parts = ver.split('-');
+ return parts.length == 5 && parts[1] == 'react' && parts[3] == 'js';
+}
+
+export default React.createClass({
+ propTypes: {
+ version: React.PropTypes.string.isRequired,
+ newVersion: React.PropTypes.string.isRequired,
+ releaseNotes: React.PropTypes.string,
+ },
+
+ displayReleaseNotes: function(releaseNotes) {
+ const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
+ Modal.createTrackedDialog('Display release notes', '', QuestionDialog, {
+ title: _t("What's New"),
+ description:
{releaseNotes}
,
+ button: _t("Update"),
+ onFinished: (update) => {
+ if(update && PlatformPeg.get()) {
+ PlatformPeg.get().installUpdate();
+ }
+ }
+ });
+ },
+
+ displayChangelog: function() {
+ const ChangelogDialog = sdk.getComponent('dialogs.ChangelogDialog');
+ Modal.createTrackedDialog('Display Changelog', '', ChangelogDialog, {
+ version: this.props.version,
+ newVersion: this.props.newVersion,
+ onFinished: (update) => {
+ if(update && PlatformPeg.get()) {
+ PlatformPeg.get().installUpdate();
+ }
+ }
+ });
+ },
+
+ onUpdateClicked: function() {
+ PlatformPeg.get().installUpdate();
+ },
+
+ render: function() {
+ let action_button;
+ // If we have release notes to display, we display them. Otherwise,
+ // we display the Changelog Dialog which takes two versions and
+ // automatically tells you what's changed (provided the versions
+ // are in the right format)
+ if (this.props.releaseNotes) {
+ action_button = (
+
+ );
+ } else if (checkVersion(this.props.version) && checkVersion(this.props.newVersion)) {
+ action_button = (
+
+ );
+ } else if (PlatformPeg.get()) {
+ action_button = (
+
+ );
+ }
+ return (
+
+
+
+ {_t("A new version of Riot is available.")}
+
+ {action_button}
+
+ );
+ }
+});
diff --git a/src/components/views/globals/PasswordNagBar.js b/src/components/views/globals/PasswordNagBar.js
new file mode 100644
index 0000000000..a04d48e0c5
--- /dev/null
+++ b/src/components/views/globals/PasswordNagBar.js
@@ -0,0 +1,64 @@
+/*
+Copyright 2017 Vector Creations Ltd
+
+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.
+*/
+
+'use strict';
+
+import React from 'react';
+import sdk from 'matrix-react-sdk';
+import Modal from 'matrix-react-sdk/lib/Modal';
+import dis from 'matrix-react-sdk/lib/dispatcher';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+
+export default React.createClass({
+ onUpdateClicked: function() {
+ const SetPasswordDialog = sdk.getComponent('dialogs.SetPasswordDialog');
+ Modal.createTrackedDialog('Set Password Dialog', 'Password Nag Bar', SetPasswordDialog, {
+ onFinished: (passwordChanged) => {
+ if (!passwordChanged) {
+ return;
+ }
+ // Notify SessionStore that the user's password was changed
+ dis.dispatch({
+ action: 'password_changed',
+ });
+ },
+ });
+ },
+
+ render: function() {
+ const toolbarClasses = "mx_MatrixToolbar mx_MatrixToolbar_clickable";
+ return (
+
+
+
+ { _t(
+ "To return to your account in future you need to set a password",
+ {},
+ { 'u': (sub) => { sub } },
+ ) }
+
+
+
+ );
+ },
+});
diff --git a/src/components/views/globals/UpdateCheckBar.js b/src/components/views/globals/UpdateCheckBar.js
new file mode 100644
index 0000000000..926ccbcccf
--- /dev/null
+++ b/src/components/views/globals/UpdateCheckBar.js
@@ -0,0 +1,85 @@
+/*
+Copyright 2017 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.
+*/
+
+'use strict';
+
+import React from 'react';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+import PlatformPeg from 'matrix-react-sdk/lib/PlatformPeg';
+import {updateCheckStatusEnum} from '../../../vector/platform/VectorBasePlatform';
+import AccessibleButton from 'matrix-react-sdk/lib/components/views/elements/AccessibleButton';
+
+const doneStatuses = [
+ updateCheckStatusEnum.ERROR,
+ updateCheckStatusEnum.NOTAVAILABLE,
+];
+
+export default React.createClass({
+ propTypes: {
+ status: React.PropTypes.oneOf(Object.values(updateCheckStatusEnum)).isRequired,
+ // Currently for error detail but will be usable for download progress
+ // once that is a thing that squirrel passes through electron.
+ detail: React.PropTypes.string,
+ },
+
+ getDefaultProps: function() {
+ return {
+ detail: '',
+ }
+ },
+
+ getStatusText: function() {
+ switch(this.props.status) {
+ case updateCheckStatusEnum.ERROR:
+ return _t('Error encountered (%(errorDetail)s).', { errorDetail: this.props.detail });
+ case updateCheckStatusEnum.CHECKING:
+ return _t('Checking for an update...');
+ case updateCheckStatusEnum.NOTAVAILABLE:
+ return _t('No update available.');
+ case updateCheckStatusEnum.DOWNLOADING:
+ return _t('Downloading update...');
+ }
+ }
+ ,
+
+ hideToolbar: function() {
+ PlatformPeg.get().stopUpdateCheck();
+ },
+
+ render: function() {
+ const message = this.getStatusText();
+ const warning = _t('Warning');
+
+ let image;
+ if (doneStatuses.includes(this.props.status)) {
+ image = ;
+ } else {
+ image = ;
+ }
+
+ return (
+
+ {image}
+
+ {message}
+
+
+
+
+
+ );
+ }
+});
diff --git a/src/components/views/messages/DateSeparator.js b/src/components/views/messages/DateSeparator.js
new file mode 100644
index 0000000000..2e081bc69b
--- /dev/null
+++ b/src/components/views/messages/DateSeparator.js
@@ -0,0 +1,61 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 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 from 'react';
+import PropTypes from 'prop-types';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+import {formatFullDateNoTime} from 'matrix-react-sdk/lib/DateUtils';
+
+function getdaysArray() {
+ return [
+ _t('Sunday'),
+ _t('Monday'),
+ _t('Tuesday'),
+ _t('Wednesday'),
+ _t('Thursday'),
+ _t('Friday'),
+ _t('Saturday'),
+ ];
+}
+
+export default class DateSeparator extends React.Component {
+ static propTypes = {
+ ts: PropTypes.number.isRequired,
+ };
+
+ getLabel() {
+ const date = new Date(this.props.ts);
+ const today = new Date();
+ const yesterday = new Date();
+ const days = getdaysArray();
+ yesterday.setDate(today.getDate() - 1);
+
+ if (date.toDateString() === today.toDateString()) {
+ return _t('Today');
+ } else if (date.toDateString() === yesterday.toDateString()) {
+ return _t('Yesterday');
+ } else if (today.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
+ return days[date.getDay()];
+ } else {
+ return formatFullDateNoTime(date);
+ }
+ }
+
+ render() {
+ return
{ this.getLabel() }
;
+ }
+}
diff --git a/src/components/views/messages/MessageTimestamp.js b/src/components/views/messages/MessageTimestamp.js
new file mode 100644
index 0000000000..6d43306708
--- /dev/null
+++ b/src/components/views/messages/MessageTimestamp.js
@@ -0,0 +1,36 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 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 from 'react';
+import PropTypes from 'prop-types';
+import {formatFullDate, formatTime} from 'matrix-react-sdk/lib/DateUtils';
+
+export default class MessageTimestamp extends React.Component {
+ static propTypes = {
+ ts: PropTypes.number.isRequired,
+ showTwelveHour: PropTypes.bool,
+ };
+
+ render() {
+ const date = new Date(this.props.ts);
+ return (
+
+ { formatTime(date, this.props.showTwelveHour) }
+
+ );
+ }
+}
diff --git a/src/components/views/rooms/DNDRoomTile.js b/src/components/views/rooms/DNDRoomTile.js
new file mode 100644
index 0000000000..d32ecbbb1d
--- /dev/null
+++ b/src/components/views/rooms/DNDRoomTile.js
@@ -0,0 +1,65 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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 from 'react';
+import { Draggable } from 'react-beautiful-dnd';
+import RoomTile from 'matrix-react-sdk/lib/components/views/rooms/RoomTile';
+
+import classNames from 'classnames';
+
+export default class DNDRoomTile extends React.PureComponent {
+ constructor() {
+ super();
+ this.getClassName = this.getClassName.bind(this);
+ }
+
+ getClassName(isDragging) {
+ return classNames({
+ "mx_DNDRoomTile": true,
+ "mx_DNDRoomTile_dragging": isDragging,
+ });
+ }
+
+ render() {
+ const props = this.props;
+
+ return
+
+ { (provided, snapshot) => {
+ return (
+
+
+
+
+
+
+ { provided.placeholder }
+
+ );
+ } }
+
+
;
+ }
+}
diff --git a/src/components/views/rooms/RoomDropTarget.js b/src/components/views/rooms/RoomDropTarget.js
new file mode 100644
index 0000000000..3cb10630fa
--- /dev/null
+++ b/src/components/views/rooms/RoomDropTarget.js
@@ -0,0 +1,35 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+var React = require('react');
+
+module.exports = React.createClass({
+ displayName: 'RoomDropTarget',
+
+ render: function() {
+ return (
+
+
+
+ { this.props.label }
+
+
+
+ );
+ }
+});
diff --git a/src/components/views/rooms/RoomTooltip.js b/src/components/views/rooms/RoomTooltip.js
new file mode 100644
index 0000000000..39d8958d5e
--- /dev/null
+++ b/src/components/views/rooms/RoomTooltip.js
@@ -0,0 +1,120 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+var React = require('react');
+var ReactDOM = require('react-dom');
+var dis = require('matrix-react-sdk/lib/dispatcher');
+import classNames from 'classnames';
+
+const MIN_TOOLTIP_HEIGHT = 25;
+
+module.exports = React.createClass({
+ displayName: 'RoomTooltip',
+
+ propTypes: {
+ // Class applied to the element used to position the tooltip
+ className: React.PropTypes.string.isRequired,
+ // Class applied to the tooltip itself
+ tooltipClassName: React.PropTypes.string,
+ // The tooltip is derived from either the room name or a label
+ room: React.PropTypes.object,
+ label: React.PropTypes.node,
+ },
+
+ // Create a wrapper for the tooltip outside the parent and attach it to the body element
+ componentDidMount: function() {
+ this.tooltipContainer = document.createElement("div");
+ this.tooltipContainer.className = "mx_RoomTileTooltip_wrapper";
+ document.body.appendChild(this.tooltipContainer);
+ window.addEventListener('scroll', this._renderTooltip, true);
+
+ this.parent = ReactDOM.findDOMNode(this).parentNode;
+
+ this._renderTooltip();
+ },
+
+ componentDidUpdate: function() {
+ this._renderTooltip();
+ },
+
+ // Remove the wrapper element, as the tooltip has finished using it
+ componentWillUnmount: function() {
+ dis.dispatch({
+ action: 'view_tooltip',
+ tooltip: null,
+ parent: null,
+ });
+
+ ReactDOM.unmountComponentAtNode(this.tooltipContainer);
+ document.body.removeChild(this.tooltipContainer);
+ window.removeEventListener('scroll', this._renderTooltip, true);
+ },
+
+ _updatePosition(style) {
+ const parentBox = this.parent.getBoundingClientRect();
+ let offset = 0;
+ if (parentBox.height > MIN_TOOLTIP_HEIGHT) {
+ offset = Math.floor((parentBox.height - MIN_TOOLTIP_HEIGHT) / 2);
+ }
+ style.top = (parentBox.top - 2) + window.pageYOffset + offset;
+ style.left = 6 + parentBox.right + window.pageXOffset;
+ return style;
+ },
+
+ _renderTooltip: function() {
+ var label = this.props.room ? this.props.room.name : this.props.label;
+
+ // Add the parent's position to the tooltips, so it's correctly
+ // positioned, also taking into account any window zoom
+ // NOTE: The additional 6 pixels for the left position, is to take account of the
+ // tooltips chevron
+ var parent = ReactDOM.findDOMNode(this).parentNode;
+ var style = {};
+ style = this._updatePosition(style);
+ style.display = "block";
+
+ const tooltipClasses = classNames(
+ "mx_RoomTooltip", this.props.tooltipClassName,
+ );
+
+ var tooltip = (
+
+
+ { label }
+
+ );
+
+ // Render the tooltip manually, as we wish it not to be rendered within the parent
+ this.tooltip = ReactDOM.render(tooltip, this.tooltipContainer);
+
+ // Tell the roomlist about us so it can manipulate us if it wishes
+ dis.dispatch({
+ action: 'view_tooltip',
+ tooltip: this.tooltip,
+ parent: parent,
+ });
+ },
+
+ render: function() {
+ // Render a placeholder
+ return (
+
+
+ );
+ },
+});
diff --git a/src/components/views/rooms/SearchBar.js b/src/components/views/rooms/SearchBar.js
new file mode 100644
index 0000000000..26bf81e428
--- /dev/null
+++ b/src/components/views/rooms/SearchBar.js
@@ -0,0 +1,71 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+var React = require('react');
+var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg');
+var sdk = require('matrix-react-sdk');
+var classNames = require('classnames');
+var AccessibleButton = require('matrix-react-sdk/lib/components/views/elements/AccessibleButton');
+import { _t } from "matrix-react-sdk/lib/languageHandler";
+
+module.exports = React.createClass({
+ displayName: 'SearchBar',
+
+ getInitialState: function() {
+ return ({
+ scope: 'Room'
+ });
+ },
+
+ onThisRoomClick: function() {
+ this.setState({ scope: 'Room' });
+ },
+
+ onAllRoomsClick: function() {
+ this.setState({ scope: 'All' });
+ },
+
+ onSearchChange: function(e) {
+ if (e.keyCode === 13) { // on enter...
+ this.onSearch();
+ }
+ if (e.keyCode === 27) { // escape...
+ this.props.onCancelClick();
+ }
+ },
+
+ onSearch: function() {
+ this.props.onSearch(this.refs.search_term.value, this.state.scope);
+ },
+
+ render: function() {
+ var searchButtonClasses = classNames({ mx_SearchBar_searchButton : true, mx_SearchBar_searching: this.props.searchInProgress });
+ var thisRoomClasses = classNames({ mx_SearchBar_button : true, mx_SearchBar_unselected : this.state.scope !== 'Room' });
+ var allRoomsClasses = classNames({ mx_SearchBar_button : true, mx_SearchBar_unselected : this.state.scope !== 'All' });
+
+ return (
+
+
+
+ {_t("This Room")}
+ {_t("All Rooms")}
+
+
+ );
+ }
+});
diff --git a/src/components/views/settings/IntegrationsManager.js b/src/components/views/settings/IntegrationsManager.js
new file mode 100644
index 0000000000..4a2482f686
--- /dev/null
+++ b/src/components/views/settings/IntegrationsManager.js
@@ -0,0 +1,63 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+
+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.
+*/
+
+'use strict';
+
+var React = require('react');
+var sdk = require('matrix-react-sdk');
+var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg');
+var dis = require('matrix-react-sdk/lib/dispatcher');
+
+module.exports = React.createClass({
+ displayName: 'IntegrationsManager',
+
+ propTypes: {
+ src: React.PropTypes.string.isRequired, // the source of the integration manager being embedded
+ onFinished: React.PropTypes.func.isRequired, // callback when the lightbox is dismissed
+ },
+
+ // XXX: keyboard shortcuts for managing dialogs should be done by the modal
+ // dialog base class somehow, surely...
+ componentDidMount: function() {
+ this.dispatcherRef = dis.register(this.onAction);
+ document.addEventListener("keydown", this.onKeyDown);
+ },
+
+ componentWillUnmount: function() {
+ dis.unregister(this.dispatcherRef);
+ document.removeEventListener("keydown", this.onKeyDown);
+ },
+
+ onKeyDown: function(ev) {
+ if (ev.keyCode == 27) { // escape
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.props.onFinished();
+ }
+ },
+
+ onAction: function(payload) {
+ if (payload.action === 'close_scalar') {
+ this.props.onFinished();
+ }
+ },
+
+ render: function() {
+ return (
+
+ );
+ }
+});
diff --git a/src/components/views/settings/Notifications.js b/src/components/views/settings/Notifications.js
new file mode 100644
index 0000000000..be03b4d70a
--- /dev/null
+++ b/src/components/views/settings/Notifications.js
@@ -0,0 +1,919 @@
+/*
+Copyright 2016 OpenMarket Ltd
+
+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 from 'react';
+import Promise from 'bluebird';
+import sdk from 'matrix-react-sdk';
+import { _t } from 'matrix-react-sdk/lib/languageHandler';
+import MatrixClientPeg from 'matrix-react-sdk/lib/MatrixClientPeg';
+import UserSettingsStore from 'matrix-react-sdk/lib/UserSettingsStore';
+import SettingsStore, {SettingLevel} from "matrix-react-sdk/lib/settings/SettingsStore";
+import Modal from 'matrix-react-sdk/lib/Modal';
+import {
+ NotificationUtils,
+ VectorPushRulesDefinitions,
+ PushRuleVectorState,
+ ContentRules
+} from '../../../notifications';
+
+// TODO: this "view" component still has far too much application logic in it,
+// which should be factored out to other files.
+
+// TODO: this component also does a lot of direct poking into this.state, which
+// is VERY NAUGHTY.
+
+
+/**
+ * Rules that Vector used to set in order to override the actions of default rules.
+ * These are used to port peoples existing overrides to match the current API.
+ * These can be removed and forgotten once everyone has moved to the new client.
+ */
+const LEGACY_RULES = {
+ "im.vector.rule.contains_display_name": ".m.rule.contains_display_name",
+ "im.vector.rule.room_one_to_one": ".m.rule.room_one_to_one",
+ "im.vector.rule.room_message": ".m.rule.message",
+ "im.vector.rule.invite_for_me": ".m.rule.invite_for_me",
+ "im.vector.rule.call": ".m.rule.call",
+ "im.vector.rule.notices": ".m.rule.suppress_notices"
+};
+
+function portLegacyActions(actions) {
+ const decoded = NotificationUtils.decodeActions(actions);
+ if (decoded !== null) {
+ return NotificationUtils.encodeActions(decoded);
+ } else {
+ // We don't recognise one of the actions here, so we don't try to
+ // canonicalise them.
+ return actions;
+ }
+}
+
+module.exports = React.createClass({
+ displayName: 'Notifications',
+
+ phases: {
+ LOADING: "LOADING", // The component is loading or sending data to the hs
+ DISPLAY: "DISPLAY", // The component is ready and display data
+ ERROR: "ERROR" // There was an error
+ },
+
+ propTypes: {
+ // The array of threepids from the JS SDK (required for email notifications)
+ threepids: React.PropTypes.array.isRequired,
+ // The brand string set when creating an email pusher
+ brand: React.PropTypes.string,
+ },
+
+ getDefaultProps: function() {
+ return {
+ threepids: []
+ };
+ },
+
+ getInitialState: function() {
+ return {
+ phase: this.phases.LOADING,
+ masterPushRule: undefined, // The master rule ('.m.rule.master')
+ vectorPushRules: [], // HS default push rules displayed in Vector UI
+ vectorContentRules: { // Keyword push rules displayed in Vector UI
+ vectorState: PushRuleVectorState.ON,
+ rules: []
+ },
+ externalPushRules: [], // Push rules (except content rule) that have been defined outside Vector UI
+ externalContentRules: [] // Keyword push rules that have been defined outside Vector UI
+ };
+ },
+
+ componentWillMount: function() {
+ this._refreshFromServer();
+ },
+
+ onEnableNotificationsChange: function(event) {
+ const self = this;
+ this.setState({
+ phase: this.phases.LOADING
+ });
+
+ MatrixClientPeg.get().setPushRuleEnabled('global', self.state.masterPushRule.kind, self.state.masterPushRule.rule_id, !event.target.checked).done(function() {
+ self._refreshFromServer();
+ });
+ },
+
+ onEnableDesktopNotificationsChange: function(event) {
+ SettingsStore.setValue(
+ "notificationsEnabled", null,
+ SettingLevel.DEVICE,
+ event.target.checked,
+ ).finally(() => {
+ this.forceUpdate();
+ });
+ },
+
+ onEnableDesktopNotificationBodyChange: function(event) {
+ SettingsStore.setValue(
+ "notificationBodyEnabled", null,
+ SettingLevel.DEVICE,
+ event.target.checked,
+ ).finally(() => {
+ this.forceUpdate();
+ });
+ },
+
+ onEnableAudioNotificationsChange: function(event) {
+ SettingsStore.setValue(
+ "audioNotificationsEnabled", null,
+ SettingLevel.DEVICE,
+ event.target.checked,
+ ).finally(() => {
+ this.forceUpdate();
+ });
+ },
+
+ onEnableEmailNotificationsChange: function(address, event) {
+ let emailPusherPromise;
+ if (event.target.checked) {
+ const data = {}
+ data['brand'] = this.props.brand || 'Riot';
+ emailPusherPromise = UserSettingsStore.addEmailPusher(address, data);
+ } else {
+ const emailPusher = UserSettingsStore.getEmailPusher(this.state.pushers, address);
+ emailPusher.kind = null;
+ emailPusherPromise = MatrixClientPeg.get().setPusher(emailPusher);
+ }
+ emailPusherPromise.done(() => {
+ this._refreshFromServer();
+ }, (error) => {
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createTrackedDialog('Error saving email notification preferences', '', ErrorDialog, {
+ title: _t('Error saving email notification preferences'),
+ description: _t('An error occurred whilst saving your email notification preferences.'),
+ });
+ });
+ },
+
+ onNotifStateButtonClicked: function(event) {
+ // FIXME: use .bind() rather than className metadata here surely
+ const vectorRuleId = event.target.className.split("-")[0];
+ const newPushRuleVectorState = event.target.className.split("-")[1];
+
+ if ("_keywords" === vectorRuleId) {
+ this._setKeywordsPushRuleVectorState(newPushRuleVectorState)
+ }
+ else {
+ const rule = this.getRule(vectorRuleId);
+ if (rule) {
+ this._setPushRuleVectorState(rule, newPushRuleVectorState);
+ }
+ }
+ },
+
+ onKeywordsClicked: function(event) {
+ const self = this;
+
+ // Compute the keywords list to display
+ let keywords = [];
+ for (let i in this.state.vectorContentRules.rules) {
+ const rule = this.state.vectorContentRules.rules[i];
+ keywords.push(rule.pattern);
+ }
+ if (keywords.length) {
+ // As keeping the order of per-word push rules hs side is a bit tricky to code,
+ // display the keywords in alphabetical order to the user
+ keywords.sort();
+
+ keywords = keywords.join(", ");
+ }
+ else {
+ keywords = "";
+ }
+
+ const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
+ Modal.createTrackedDialog('Keywords Dialog', '', TextInputDialog, {
+ title: _t('Keywords'),
+ description: _t('Enter keywords separated by a comma:'),
+ button: _t('OK'),
+ value: keywords,
+ onFinished: function onFinished(should_leave, newValue) {
+
+ if (should_leave && newValue !== keywords) {
+ let newKeywords = newValue.split(',');
+ for (let i in newKeywords) {
+ newKeywords[i] = newKeywords[i].trim();
+ }
+
+ // Remove duplicates and empty
+ newKeywords = newKeywords.reduce(function(array, keyword){
+ if (keyword !== "" && array.indexOf(keyword) < 0) {
+ array.push(keyword);
+ }
+ return array;
+ },[]);
+
+ self._setKeywords(newKeywords);
+ }
+ }
+ });
+ },
+
+ getRule: function(vectorRuleId) {
+ for (let i in this.state.vectorPushRules) {
+ const rule = this.state.vectorPushRules[i];
+ if (rule.vectorRuleId === vectorRuleId) {
+ return rule;
+ }
+ }
+ },
+
+ _setPushRuleVectorState: function(rule, newPushRuleVectorState) {
+ if (rule && rule.vectorState !== newPushRuleVectorState) {
+
+ this.setState({
+ phase: this.phases.LOADING
+ });
+
+ const self = this;
+ const cli = MatrixClientPeg.get();
+ const deferreds = [];
+ const ruleDefinition = VectorPushRulesDefinitions[rule.vectorRuleId];
+
+ if (rule.rule) {
+ const actions = ruleDefinition.vectorStateToActions[newPushRuleVectorState];
+
+ if (!actions) {
+ // The new state corresponds to disabling the rule.
+ deferreds.push(cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, false));
+ }
+ else {
+ // The new state corresponds to enabling the rule and setting specific actions
+ deferreds.push(this._updatePushRuleActions(rule.rule, actions, true));
+ }
+ }
+
+ Promise.all(deferreds).done(function() {
+ self._refreshFromServer();
+ }, function(error) {
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ console.error("Failed to change settings: " + error);
+ Modal.createTrackedDialog('Failed to change settings', '', ErrorDialog, {
+ title: _t('Failed to change settings'),
+ description: ((error && error.message) ? error.message : _t('Operation failed')),
+ onFinished: self._refreshFromServer
+ });
+ });
+ }
+ },
+
+ _setKeywordsPushRuleVectorState: function(newPushRuleVectorState) {
+ // Is there really a change?
+ if (this.state.vectorContentRules.vectorState === newPushRuleVectorState
+ || this.state.vectorContentRules.rules.length === 0) {
+ return;
+ }
+
+ const self = this;
+ const cli = MatrixClientPeg.get();
+
+ this.setState({
+ phase: this.phases.LOADING
+ });
+
+ // Update all rules in self.state.vectorContentRules
+ const deferreds = [];
+ for (let i in this.state.vectorContentRules.rules) {
+ const rule = this.state.vectorContentRules.rules[i];
+
+ let enabled, actions;
+ switch (newPushRuleVectorState) {
+ case PushRuleVectorState.ON:
+ if (rule.actions.length !== 1) {
+ actions = PushRuleVectorState.actionsFor(PushRuleVectorState.ON);
+ }
+
+ if (this.state.vectorContentRules.vectorState === PushRuleVectorState.OFF) {
+ enabled = true;
+ }
+ break;
+
+ case PushRuleVectorState.LOUD:
+ if (rule.actions.length !== 3) {
+ actions = PushRuleVectorState.actionsFor(PushRuleVectorState.LOUD);
+ }
+
+ if (this.state.vectorContentRules.vectorState === PushRuleVectorState.OFF) {
+ enabled = true;
+ }
+ break;
+
+ case PushRuleVectorState.OFF:
+ enabled = false;
+ break;
+ }
+
+ if (actions) {
+ // Note that the workaround in _updatePushRuleActions will automatically
+ // enable the rule
+ deferreds.push(this._updatePushRuleActions(rule, actions, enabled));
+ }
+ else if (enabled != undefined) {
+ deferreds.push(cli.setPushRuleEnabled('global', rule.kind, rule.rule_id, enabled));
+ }
+ }
+
+ Promise.all(deferreds).done(function(resps) {
+ self._refreshFromServer();
+ }, function(error) {
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ console.error("Can't update user notification settings: " + error);
+ Modal.createTrackedDialog('Can\'t update user notifcation settings', '', ErrorDialog, {
+ title: _t('Can\'t update user notification settings'),
+ description: ((error && error.message) ? error.message : _t('Operation failed')),
+ onFinished: self._refreshFromServer
+ });
+ });
+ },
+
+ _setKeywords: function(newKeywords) {
+ this.setState({
+ phase: this.phases.LOADING
+ });
+
+ const self = this;
+ const cli = MatrixClientPeg.get();
+ const removeDeferreds = [];
+
+ // Remove per-word push rules of keywords that are no more in the list
+ const vectorContentRulesPatterns = [];
+ for (let i in self.state.vectorContentRules.rules) {
+ const rule = self.state.vectorContentRules.rules[i];
+
+ vectorContentRulesPatterns.push(rule.pattern);
+
+ if (newKeywords.indexOf(rule.pattern) < 0) {
+ removeDeferreds.push(cli.deletePushRule('global', rule.kind, rule.rule_id));
+ }
+ }
+
+ // If the keyword is part of `externalContentRules`, remove the rule
+ // before recreating it in the right Vector path
+ for (let i in self.state.externalContentRules) {
+ const rule = self.state.externalContentRules[i];
+
+ if (newKeywords.indexOf(rule.pattern) >= 0) {
+ removeDeferreds.push(cli.deletePushRule('global', rule.kind, rule.rule_id));
+ }
+ }
+
+ const onError = function(error) {
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ console.error("Failed to update keywords: " + error);
+ Modal.createTrackedDialog('Failed to update keywords', '', ErrorDialog, {
+ title: _t('Failed to update keywords'),
+ description: ((error && error.message) ? error.message : _t('Operation failed')),
+ onFinished: self._refreshFromServer
+ });
+ }
+
+ // Then, add the new ones
+ Promise.all(removeDeferreds).done(function(resps) {
+ const deferreds = [];
+
+ let pushRuleVectorStateKind = self.state.vectorContentRules.vectorState;
+ if (pushRuleVectorStateKind === PushRuleVectorState.OFF) {
+ // When the current global keywords rule is OFF, we need to look at
+ // the flavor of rules in 'vectorContentRules' to apply the same actions
+ // when creating the new rule.
+ // Thus, this new rule will join the 'vectorContentRules' set.
+ if (self.state.vectorContentRules.rules.length) {
+ pushRuleVectorStateKind = PushRuleVectorState.contentRuleVectorStateKind(self.state.vectorContentRules.rules[0]);
+ }
+ else {
+ // ON is default
+ pushRuleVectorStateKind = PushRuleVectorState.ON;
+ }
+ }
+
+ for (let i in newKeywords) {
+ const keyword = newKeywords[i];
+
+ if (vectorContentRulesPatterns.indexOf(keyword) < 0) {
+ if (self.state.vectorContentRules.vectorState !== PushRuleVectorState.OFF) {
+ deferreds.push(cli.addPushRule
+ ('global', 'content', keyword, {
+ actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
+ pattern: keyword
+ }));
+ }
+ else {
+ deferreds.push(self._addDisabledPushRule('global', 'content', keyword, {
+ actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
+ pattern: keyword
+ }));
+ }
+ }
+ }
+
+ Promise.all(deferreds).done(function(resps) {
+ self._refreshFromServer();
+ }, onError);
+ }, onError);
+ },
+
+ // Create a push rule but disabled
+ _addDisabledPushRule: function(scope, kind, ruleId, body) {
+ const cli = MatrixClientPeg.get();
+ return cli.addPushRule(scope, kind, ruleId, body).then(() =>
+ cli.setPushRuleEnabled(scope, kind, ruleId, false)
+ );
+ },
+
+ // Check if any legacy im.vector rules need to be ported to the new API
+ // for overriding the actions of default rules.
+ _portRulesToNewAPI: function(rulesets) {
+ const self = this;
+ const needsUpdate = [];
+ const cli = MatrixClientPeg.get();
+
+ for (let kind in rulesets.global) {
+ const ruleset = rulesets.global[kind];
+ for (let i = 0; i < ruleset.length; ++i) {
+ const rule = ruleset[i];
+ if (rule.rule_id in LEGACY_RULES) {
+ console.log("Porting legacy rule", rule);
+ needsUpdate.push( function(kind, rule) {
+ return cli.setPushRuleActions(
+ 'global', kind, LEGACY_RULES[rule.rule_id], portLegacyActions(rule.actions)
+ ).then(() =>
+ cli.deletePushRule('global', kind, rule.rule_id)
+ ).catch( (e) => {
+ console.warn(`Error when porting legacy rule: ${e}`);
+ });
+ }(kind, rule));
+ }
+ }
+ }
+
+ if (needsUpdate.length > 0) {
+ // If some of the rules need to be ported then wait for the porting
+ // to happen and then fetch the rules again.
+ return Promise.all(needsUpdate).then(() =>
+ cli.getPushRules()
+ );
+ } else {
+ // Otherwise return the rules that we already have.
+ return rulesets;
+ }
+ },
+
+ _refreshFromServer: function() {
+ const self = this;
+ const pushRulesPromise = MatrixClientPeg.get().getPushRules().then(self._portRulesToNewAPI).then(function(rulesets) {
+
+ /// XXX seriously? wtf is this?
+ MatrixClientPeg.get().pushRules = rulesets;
+
+ // Get homeserver default rules and triage them by categories
+ const rule_categories = {
+ // The master rule (all notifications disabling)
+ '.m.rule.master': 'master',
+
+ // The default push rules displayed by Vector UI
+ '.m.rule.contains_display_name': 'vector',
+ '.m.rule.contains_user_name': 'vector',
+ '.m.rule.room_one_to_one': 'vector',
+ '.m.rule.message': 'vector',
+ '.m.rule.invite_for_me': 'vector',
+ //'.m.rule.member_event': 'vector',
+ '.m.rule.call': 'vector',
+ '.m.rule.suppress_notices': 'vector'
+
+ // Others go to others
+ };
+
+ // HS default rules
+ const defaultRules = {master: [], vector: {}, others: []};
+
+ for (let kind in rulesets.global) {
+ for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) {
+ const r = rulesets.global[kind][i];
+ const cat = rule_categories[r.rule_id];
+ r.kind = kind;
+
+ if (r.rule_id[0] === '.') {
+ if (cat === 'vector') {
+ defaultRules.vector[r.rule_id] = r;
+ }
+ else if (cat === 'master') {
+ defaultRules.master.push(r);
+ }
+ else {
+ defaultRules['others'].push(r);
+ }
+ }
+ }
+ }
+
+ // Get the master rule if any defined by the hs
+ if (defaultRules.master.length > 0) {
+ self.state.masterPushRule = defaultRules.master[0];
+ }
+
+ // parse the keyword rules into our state
+ const contentRules = ContentRules.parseContentRules(rulesets);
+ self.state.vectorContentRules = {
+ vectorState: contentRules.vectorState,
+ rules: contentRules.rules,
+ };
+ self.state.externalContentRules = contentRules.externalRules;
+
+ // Build the rules displayed in the Vector UI matrix table
+ self.state.vectorPushRules = [];
+ self.state.externalPushRules = [];
+
+ const vectorRuleIds = [
+ '.m.rule.contains_display_name',
+ '.m.rule.contains_user_name',
+ '_keywords',
+ '.m.rule.room_one_to_one',
+ '.m.rule.message',
+ '.m.rule.invite_for_me',
+ //'im.vector.rule.member_event',
+ '.m.rule.call',
+ '.m.rule.suppress_notices'
+ ];
+ for (let i in vectorRuleIds) {
+ const vectorRuleId = vectorRuleIds[i];
+
+ if (vectorRuleId === '_keywords') {
+ // keywords needs a special handling
+ // For Vector UI, this is a single global push rule but translated in Matrix,
+ // it corresponds to all content push rules (stored in self.state.vectorContentRule)
+ self.state.vectorPushRules.push({
+ "vectorRuleId": "_keywords",
+ "description" : (
+
+ { _t('Messages containing keywords',
+ {},
+ { 'span': (sub) =>
+ {sub}
+ },
+ )}
+
+ ),
+ "vectorState": self.state.vectorContentRules.vectorState
+ });
+ }
+ else {
+ const ruleDefinition = VectorPushRulesDefinitions[vectorRuleId];
+ const rule = defaultRules.vector[vectorRuleId];
+
+ const vectorState = ruleDefinition.ruleToVectorState(rule);
+
+ //console.log("Refreshing vectorPushRules for " + vectorRuleId +", "+ ruleDefinition.description +", " + rule +", " + vectorState);
+
+ self.state.vectorPushRules.push({
+ "vectorRuleId": vectorRuleId,
+ "description" : _t(ruleDefinition.description), // Text from VectorPushRulesDefinitions.js
+ "rule": rule,
+ "vectorState": vectorState,
+ });
+
+ // if there was a rule which we couldn't parse, add it to the external list
+ if (rule && !vectorState) {
+ rule.description = ruleDefinition.description;
+ self.state.externalPushRules.push(rule);
+ }
+ }
+ }
+
+ // Build the rules not managed by Vector UI
+ const otherRulesDescriptions = {
+ '.m.rule.message': _t('Notify for all other messages/rooms'),
+ '.m.rule.fallback': _t('Notify me for anything else'),
+ };
+
+ for (let i in defaultRules.others) {
+ const rule = defaultRules.others[i];
+ const ruleDescription = otherRulesDescriptions[rule.rule_id];
+
+ // Show enabled default rules that was modified by the user
+ if (ruleDescription && rule.enabled && !rule.default) {
+ rule.description = ruleDescription;
+ self.state.externalPushRules.push(rule);
+ }
+ }
+ });
+
+ const pushersPromise = MatrixClientPeg.get().getPushers().then(function(resp) {
+ self.setState({pushers: resp.pushers});
+ });
+
+ Promise.all([pushRulesPromise, pushersPromise]).then(function() {
+ self.setState({
+ phase: self.phases.DISPLAY
+ });
+ }, function(error) {
+ console.error(error);
+ self.setState({
+ phase: self.phases.ERROR
+ });
+ }).finally(() => {
+ // actually explicitly update our state having been deep-manipulating it
+ self.setState({
+ masterPushRule: self.state.masterPushRule,
+ vectorContentRules: self.state.vectorContentRules,
+ vectorPushRules: self.state.vectorPushRules,
+ externalContentRules: self.state.externalContentRules,
+ externalPushRules: self.state.externalPushRules,
+ });
+ }).done();
+ },
+
+ _updatePushRuleActions: function(rule, actions, enabled) {
+ const cli = MatrixClientPeg.get();
+
+ return cli.setPushRuleActions(
+ 'global', rule.kind, rule.rule_id, actions
+ ).then( function() {
+ // Then, if requested, enabled or disabled the rule
+ if (undefined != enabled) {
+ return cli.setPushRuleEnabled(
+ 'global', rule.kind, rule.rule_id, enabled
+ );
+ }
+ });
+ },
+
+ renderNotifRulesTableRow: function(title, className, pushRuleVectorState) {
+ return (
+
+ );
+ }
+
+ // When enabled, the master rule inhibits all existing rules
+ // So do not show all notification settings
+ if (this.state.masterPushRule && this.state.masterPushRule.enabled) {
+ return (
+
+ {masterPushRuleDiv}
+
+
+ { _t('All notifications are currently disabled for all targets.') }.
+
+ { _t('Add an email address above to configure email notifications') }
+
;
+ } else {
+ // This only supports the first email address in your profile for now
+ emailNotificationsRow = this.emailNotificationsRow(
+ emailThreepids[0].address,
+ `${_t('Enable email notifications')} (${emailThreepids[0].address})`
+ );
+ }
+
+ // Build external push rules
+ const externalRules = [];
+ for (let i in this.state.externalPushRules) {
+ const rule = this.state.externalPushRules[i];
+ externalRules.push(
{ _t(rule.description) }
);
+ }
+
+ // Show keywords not displayed by the vector UI as a single external push rule
+ let externalKeywords = [];
+ for (let i in this.state.externalContentRules) {
+ const rule = this.state.externalContentRules[i];
+ externalKeywords.push(rule.pattern);
+ }
+ if (externalKeywords.length) {
+ externalKeywords = externalKeywords.join(", ");
+ externalRules.push(
{ _t('Notifications on the following keywords follow rules which can’t be displayed here:') } { externalKeywords }
);
+ }
+
+ let devicesSection;
+ if (this.state.pushers === undefined) {
+ devicesSection =
{ _t('Unable to fetch notification target list') }
+ } else if (this.state.pushers.length == 0) {
+ devicesSection = null;
+ } else {
+ // TODO: It would be great to be able to delete pushers from here too,
+ // and this wouldn't be hard to add.
+ const rows = [];
+ for (let i = 0; i < this.state.pushers.length; ++i) {
+ rows.push(
);
+ }
+
+ let advancedSettings;
+ if (externalRules.length) {
+ advancedSettings = (
+
+
{ _t('Advanced notification settings') }
+ { _t('There are advanced notifications which are not shown here') }.
+ { _t('You might have configured them in a client other than Riot. You cannot tune them in Riot but they still apply') }.
+