Merge branch 'experimental' into travis/develop2

This commit is contained in:
Travis Ralston 2019-01-17 16:03:45 -07:00
commit 7f6ce69c3e
212 changed files with 6552 additions and 3349 deletions

View file

@ -0,0 +1,137 @@
/*
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";
// derived from code from github.com/noeldelgado/gemini-scrollbar
// Copyright (c) Noel Delgado <pixelia.me@gmail.com> (pixelia.me)
function getScrollbarWidth(alternativeOverflow) {
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.top = '-9999px';
div.style.width = '100px';
div.style.height = '100px';
div.style.overflow = "scroll";
if (alternativeOverflow) {
div.style.overflow = alternativeOverflow;
}
div.style.msOverflowStyle = '-ms-autohiding-scrollbar';
document.body.appendChild(div);
const scrollbarWidth = (div.offsetWidth - div.clientWidth);
document.body.removeChild(div);
return scrollbarWidth;
}
function install() {
const scrollbarWidth = getScrollbarWidth();
if (scrollbarWidth !== 0) {
const hasForcedOverlayScrollbar = getScrollbarWidth('overlay') === 0;
// overflow: overlay on webkit doesn't auto hide the scrollbar
if (hasForcedOverlayScrollbar) {
document.body.classList.add("mx_scrollbar_overlay_noautohide");
} else {
document.body.classList.add("mx_scrollbar_nooverlay");
const style = document.createElement('style');
style.type = 'text/css';
style.innerText =
`body.mx_scrollbar_nooverlay { --scrollbar-width: ${scrollbarWidth}px; }`;
document.head.appendChild(style);
}
}
}
const installBodyClassesIfNeeded = (function() {
let installed = false;
return function() {
if (!installed) {
install();
installed = true;
}
};
})();
export default class AutoHideScrollbar extends React.Component {
constructor(props) {
super(props);
this.onOverflow = this.onOverflow.bind(this);
this.onUnderflow = this.onUnderflow.bind(this);
this._collectContainerRef = this._collectContainerRef.bind(this);
this._needsOverflowListener = null;
}
onOverflow() {
this.containerRef.classList.add("mx_AutoHideScrollbar_overflow");
this.containerRef.classList.remove("mx_AutoHideScrollbar_underflow");
}
onUnderflow() {
this.containerRef.classList.remove("mx_AutoHideScrollbar_overflow");
this.containerRef.classList.add("mx_AutoHideScrollbar_underflow");
}
checkOverflow() {
if (!this._needsOverflowListener) {
return;
}
if (this.containerRef.scrollHeight > this.containerRef.clientHeight) {
this.onOverflow();
} else {
this.onUnderflow();
}
}
componentDidUpdate() {
this.checkOverflow();
}
componentDidMount() {
installBodyClassesIfNeeded();
this._needsOverflowListener =
document.body.classList.contains("mx_scrollbar_nooverlay");
if (this._needsOverflowListener) {
this.containerRef.addEventListener("overflow", this.onOverflow);
this.containerRef.addEventListener("underflow", this.onUnderflow);
}
this.checkOverflow();
}
_collectContainerRef(ref) {
if (ref && !this.containerRef) {
this.containerRef = ref;
}
if (this.props.wrappedRef) {
this.props.wrappedRef(ref);
}
}
componentWillUnmount() {
if (this._needsOverflowListener && this.containerRef) {
this.containerRef.removeEventListener("overflow", this.onOverflow);
this.containerRef.removeEventListener("underflow", this.onUnderflow);
}
}
render() {
return (<div
ref={this._collectContainerRef}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
>
<div className="mx_AutoHideScrollbar_offset">
{ this.props.children }
</div>
</div>);
}
}

View file

@ -49,7 +49,7 @@ export default class ContextualMenu extends React.Component {
menuHeight: PropTypes.number,
chevronOffset: PropTypes.number,
menuColour: PropTypes.string,
chevronFace: PropTypes.string, // top, bottom, left, right
chevronFace: PropTypes.string, // top, bottom, left, right or none
// Function to be called on menu close
onFinished: PropTypes.func,
menuPaddingTop: PropTypes.number,
@ -113,7 +113,6 @@ export default class ContextualMenu extends React.Component {
render() {
const position = {};
let chevronFace = null;
const props = this.props;
if (props.top) {
@ -137,6 +136,8 @@ export default class ContextualMenu extends React.Component {
if (props.chevronFace) {
chevronFace = props.chevronFace;
}
const hasChevron = chevronFace && chevronFace !== "none";
if (chevronFace === 'top' || chevronFace === 'bottom') {
chevronOffset.left = props.chevronOffset;
} else {
@ -174,11 +175,14 @@ export default class ContextualMenu extends React.Component {
`;
}
const chevron = <div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} />;
const chevron = hasChevron ?
<div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} /> :
undefined;
const className = 'mx_ContextualMenu_wrapper';
const menuClasses = classNames({
'mx_ContextualMenu': true,
'mx_ContextualMenu_noChevron': chevronFace === 'none',
'mx_ContextualMenu_left': chevronFace === 'left',
'mx_ContextualMenu_right': chevronFace === 'right',
'mx_ContextualMenu_top': chevronFace === 'top',

View file

@ -1,284 +0,0 @@
/*
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 { _t } from '../../languageHandler';
import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
const PresetValues = {
PrivateChat: "private_chat",
PublicChat: "public_chat",
Custom: "custom",
};
module.exports = React.createClass({
displayName: 'CreateRoom',
propTypes: {
onRoomCreated: PropTypes.func,
collapsedRhs: PropTypes.bool,
},
phases: {
CONFIG: "CONFIG", // We're waiting for user to configure and hit create.
CREATING: "CREATING", // We're sending the request.
CREATED: "CREATED", // We successfully created the room.
ERROR: "ERROR", // There was an error while trying to create room.
},
getDefaultProps: function() {
return {
onRoomCreated: function() {},
};
},
getInitialState: function() {
return {
phase: this.phases.CONFIG,
error_string: "",
is_private: true,
share_history: false,
default_preset: PresetValues.PrivateChat,
topic: '',
room_name: '',
invited_users: [],
};
},
onCreateRoom: function() {
const options = {};
if (this.state.room_name) {
options.name = this.state.room_name;
}
if (this.state.topic) {
options.topic = this.state.topic;
}
if (this.state.preset) {
if (this.state.preset != PresetValues.Custom) {
options.preset = this.state.preset;
} else {
options.initial_state = [
{
type: "m.room.join_rules",
content: {
"join_rule": this.state.is_private ? "invite" : "public",
},
},
{
type: "m.room.history_visibility",
content: {
"history_visibility": this.state.share_history ? "shared" : "invited",
},
},
];
}
}
options.invite = this.state.invited_users;
const alias = this.getAliasLocalpart();
if (alias) {
options.room_alias_name = alias;
}
const cli = MatrixClientPeg.get();
if (!cli) {
// TODO: Error.
console.error("Cannot create room: No matrix client.");
return;
}
const deferred = cli.createRoom(options);
if (this.state.encrypt) {
// TODO
}
this.setState({
phase: this.phases.CREATING,
});
const self = this;
deferred.then(function(resp) {
self.setState({
phase: self.phases.CREATED,
});
self.props.onRoomCreated(resp.room_id);
}, function(err) {
self.setState({
phase: self.phases.ERROR,
error_string: err.toString(),
});
});
},
getPreset: function() {
return this.refs.presets.getPreset();
},
getName: function() {
return this.refs.name_textbox.getName();
},
getTopic: function() {
return this.refs.topic.getTopic();
},
getAliasLocalpart: function() {
return this.refs.alias.getAliasLocalpart();
},
getInvitedUsers: function() {
return this.refs.user_selector.getUserIds();
},
onPresetChanged: function(preset) {
switch (preset) {
case PresetValues.PrivateChat:
this.setState({
preset: preset,
is_private: true,
share_history: false,
});
break;
case PresetValues.PublicChat:
this.setState({
preset: preset,
is_private: false,
share_history: true,
});
break;
case PresetValues.Custom:
this.setState({
preset: preset,
});
break;
}
},
onPrivateChanged: function(ev) {
this.setState({
preset: PresetValues.Custom,
is_private: ev.target.checked,
});
},
onShareHistoryChanged: function(ev) {
this.setState({
preset: PresetValues.Custom,
share_history: ev.target.checked,
});
},
onTopicChange: function(ev) {
this.setState({
topic: ev.target.value,
});
},
onNameChange: function(ev) {
this.setState({
room_name: ev.target.value,
});
},
onInviteChanged: function(invited_users) {
this.setState({
invited_users: invited_users,
});
},
onAliasChanged: function(alias) {
this.setState({
alias: alias,
});
},
onEncryptChanged: function(ev) {
this.setState({
encrypt: ev.target.checked,
});
},
render: function() {
const curr_phase = this.state.phase;
if (curr_phase == this.phases.CREATING) {
const Loader = sdk.getComponent("elements.Spinner");
return (
<Loader />
);
} else {
let error_box = "";
if (curr_phase == this.phases.ERROR) {
error_box = (
<div className="mx_Error">
{ _t('An error occurred: %(error_string)s', {error_string: this.state.error_string}) }
</div>
);
}
const CreateRoomButton = sdk.getComponent("create_room.CreateRoomButton");
const RoomAlias = sdk.getComponent("create_room.RoomAlias");
const Presets = sdk.getComponent("create_room.Presets");
const UserSelector = sdk.getComponent("elements.UserSelector");
const SimpleRoomHeader = sdk.getComponent("rooms.SimpleRoomHeader");
const domain = MatrixClientPeg.get().getDomain();
return (
<div className="mx_CreateRoom">
<SimpleRoomHeader title={_t("Create Room")} collapsedRhs={this.props.collapsedRhs} />
<div className="mx_CreateRoom_body">
<input type="text" ref="room_name" value={this.state.room_name} onChange={this.onNameChange} placeholder={_t('Name')} /> <br />
<textarea className="mx_CreateRoom_description" ref="topic" value={this.state.topic} onChange={this.onTopicChange} placeholder={_t('Topic')} /> <br />
<RoomAlias ref="alias" alias={this.state.alias} homeserver={domain} onChange={this.onAliasChanged} /> <br />
<UserSelector ref="user_selector" selected_users={this.state.invited_users} onChange={this.onInviteChanged} /> <br />
<Presets ref="presets" onChange={this.onPresetChanged} preset={this.state.preset} /> <br />
<div>
<label>
<input type="checkbox" ref="is_private" checked={this.state.is_private} onChange={this.onPrivateChanged} />
{ _t('Make this room private') }
</label>
</div>
<div>
<label>
<input type="checkbox" ref="share_history" checked={this.state.share_history} onChange={this.onShareHistoryChanged} />
{ _t('Share message history with new users') }
</label>
</div>
<div className="mx_CreateRoom_encrypt">
<label>
<input type="checkbox" ref="encrypt" checked={this.state.encrypt} onChange={this.onEncryptChanged} />
{ _t('Encrypt room') }
</label>
</div>
<div>
<CreateRoomButton onCreateRoom={this.onCreateRoom} /> <br />
</div>
{ error_box }
</div>
</div>
);
}
},
});

View file

@ -24,6 +24,9 @@ import dis from '../../dispatcher';
import { sanitizedHtmlNode } from '../../HtmlUtils';
import { _t, _td } from '../../languageHandler';
import AccessibleButton from '../views/elements/AccessibleButton';
import GroupHeaderButtons from '../views/right_panel/GroupHeaderButtons';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
import Modal from '../../Modal';
import classnames from 'classnames';
@ -1271,25 +1274,19 @@ export default React.createClass({
<TintableSvg src="img/icons-share.svg" width="16" height="16" />
</AccessibleButton>,
);
if (this.props.collapsedRhs) {
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button"
onClick={this._onShowRhsClick} title={_t('Show panel')} key="_maximiseButton"
>
<TintableSvg src="img/maximise.svg" width="10" height="16" />
</AccessibleButton>,
);
}
}
const rightPanel = !this.props.collapsedRhs ? <RightPanel groupId={this.props.groupId} /> : undefined;
const headerClasses = {
mx_GroupView_header: true,
mx_GroupView_header_view: !this.state.editing,
mx_GroupView_header_isUserMember: this.state.isUserMember,
"mx_GroupView_header": true,
"light-panel": true,
"mx_GroupView_header_view": !this.state.editing,
"mx_GroupView_header_isUserMember": this.state.isUserMember,
};
return (
<div className="mx_GroupView">
<main className="mx_GroupView">
<div className={classnames(headerClasses)}>
<div className="mx_GroupView_header_leftCol">
<div className="mx_GroupView_header_avatar">
@ -1307,12 +1304,15 @@ export default React.createClass({
<div className="mx_GroupView_header_rightCol">
{ rightButtons }
</div>
<GroupHeaderButtons collapsedRhs={this.props.collapsedRhs} />
</div>
<GeminiScrollbarWrapper className="mx_GroupView_body">
{ this._getMembershipSection() }
{ this._getGroupSection() }
</GeminiScrollbarWrapper>
</div>
<MainSplit collapsedRhs={this.props.collapsedRhs} panel={rightPanel}>
<GeminiScrollbarWrapper className="mx_GroupView_body">
{ this._getMembershipSection() }
{ this._getGroupSection() }
</GeminiScrollbarWrapper>
</MainSplit>
</main>
);
} else if (this.state.error) {
if (this.state.error.httpStatus === 404) {

View file

@ -91,11 +91,15 @@ class HomePage extends React.Component {
this._unmounted = true;
}
onLoginClick() {
onLoginClick(ev) {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({ action: 'start_login' });
}
onRegisterClick() {
onRegisterClick(ev) {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({ action: 'start_registration' });
}

View file

@ -0,0 +1,73 @@
/*
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 AutoHideScrollbar from "./AutoHideScrollbar";
export default class IndicatorScrollbar extends React.Component {
constructor(props) {
super(props);
this._collectScroller = this._collectScroller.bind(this);
this._collectScrollerComponent = this._collectScrollerComponent.bind(this);
this.checkOverflow = this.checkOverflow.bind(this);
this._scrollElement = null;
this._autoHideScrollbar = null;
}
_collectScroller(scroller) {
if (scroller && !this._scrollElement) {
this._scrollElement = scroller;
this._scrollElement.addEventListener("scroll", this.checkOverflow);
this.checkOverflow();
}
}
_collectScrollerComponent(autoHideScrollbar) {
this._autoHideScrollbar = autoHideScrollbar;
}
checkOverflow() {
const hasTopOverflow = this._scrollElement.scrollTop > 0;
const hasBottomOverflow = this._scrollElement.scrollHeight >
(this._scrollElement.scrollTop + this._scrollElement.clientHeight);
if (hasTopOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow");
}
if (hasBottomOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow");
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
}
if (this._autoHideScrollbar) {
this._autoHideScrollbar.checkOverflow();
}
}
componentWillUnmount() {
if (this._scrollElement) {
this._scrollElement.removeEventListener("scroll", this.checkOverflow);
}
}
render() {
return (<AutoHideScrollbar ref={this._collectScrollerComponent} wrappedRef={this._collectScroller} {... this.props}>
{ this.props.children }
</AutoHideScrollbar>);
}
}

View file

@ -151,8 +151,7 @@ const LeftPanel = React.createClass({
}
} while (element && !(
classes.contains("mx_RoomTile") ||
classes.contains("mx_SearchBox_search") ||
classes.contains("mx_RoomSubList_ellipsis")));
classes.contains("mx_textinput_search")));
if (element) {
element.focus();
@ -171,6 +170,12 @@ const LeftPanel = React.createClass({
this.setState({ searchFilter: term });
},
onSearchCleared: function(source) {
if (source === "keyboard") {
dis.dispatch({action: 'focus_composer'});
}
},
collectRoomList: function(ref) {
this._roomList = ref;
},
@ -178,18 +183,9 @@ const LeftPanel = React.createClass({
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');
const TopLeftMenuButton = sdk.getComponent('structures.TopLeftMenuButton');
const SearchBox = sdk.getComponent('structures.SearchBox');
const topBox = <SearchBox collapsed={ this.props.collapsed } onSearch={ this.onSearch } />;
const classes = classNames(
"mx_LeftPanel",
{
"collapsed": this.props.collapsed,
},
);
const CallPreview = sdk.getComponent('voip.CallPreview');
const tagPanelEnabled = !SettingsStore.getValue("TagPanel.disableTagPanel");
const tagPanel = tagPanelEnabled ? <TagPanel /> : <div />;
@ -197,27 +193,32 @@ const LeftPanel = React.createClass({
const containerClasses = classNames(
"mx_LeftPanel_container", "mx_fadable",
{
"mx_LeftPanel_container_collapsed": this.props.collapsed,
"collapsed": this.props.collapsed,
"mx_LeftPanel_container_hasTagPanel": tagPanelEnabled,
"mx_fadable_faded": this.props.disabled,
},
);
const searchBox = !this.props.collapsed ?
<SearchBox onSearch={ this.onSearch } onCleared={ this.onSearchCleared } /> :
undefined;
return (
<div className={containerClasses}>
{ tagPanel }
<aside className={classes} onKeyDown={ this._onKeyDown } onFocus={ this._onFocus } onBlur={ this._onBlur }>
{ topBox }
<aside className={"mx_LeftPanel dark-panel"} onKeyDown={ this._onKeyDown } onFocus={ this._onFocus } onBlur={ this._onBlur }>
<TopLeftMenuButton collapsed={ this.props.collapsed } />
{ searchBox }
<CallPreview ConferenceHandler={VectorConferenceHandler} />
<RoomList
ref={this.collectRoomList}
collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter}
ConferenceHandler={VectorConferenceHandler} />
<BottomLeftMenu collapsed={this.props.collapsed} />
</aside>
</div>
);
// <BottomLeftMenu collapsed={this.props.collapsed}/>
},
});

View file

@ -34,7 +34,8 @@ import RoomListStore from "../../stores/RoomListStore";
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, CollapseDistributor} from '../../resizer'
// We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity.
// NB. this is just for server notices rather than pinned messages in general.
@ -61,7 +62,7 @@ const LoggedInView = React.createClass({
// Called with the credentials of a registered user (if they were a ROU that
// transitioned to PWLU)
onRegistered: PropTypes.func,
collapsedRhs: PropTypes.bool,
teamToken: PropTypes.string,
// Used by the RoomView to handle joining rooms
@ -94,6 +95,12 @@ const LoggedInView = React.createClass({
};
},
componentDidMount: function() {
this.resizer = this._createResizer();
this.resizer.attach();
this._loadResizerPreferences();
},
componentWillMount: function() {
// stash the MatrixClient in case we log out before we are unmounted
this._matrixClient = this.props.matrixClient;
@ -123,6 +130,7 @@ const LoggedInView = React.createClass({
if (this._sessionStoreToken) {
this._sessionStoreToken.remove();
}
this.resizer.detach();
},
// Child components assume that the client peg will not be null, so give them some
@ -148,6 +156,39 @@ const LoggedInView = React.createClass({
});
},
_createResizer() {
const classNames = {
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse"
};
const collapseConfig = {
toggleSize: 260 - 50,
onCollapsed: (collapsed) => {
this.setState({collapseLhs: collapsed});
if (collapsed) {
window.localStorage.setItem("mx_lhs_size", '0');
}
},
onResized: (size) => {
window.localStorage.setItem("mx_lhs_size", '' + size);
},
};
const resizer = new Resizer(
this.resizeContainer,
CollapseDistributor,
collapseConfig);
resizer.setClassNames(classNames);
return resizer;
},
_loadResizerPreferences() {
const lhsSize = window.localStorage.getItem("mx_lhs_size");
if (lhsSize !== null) {
this.resizer.forHandleAt(0).resize(parseInt(lhsSize, 10));
}
},
onAccountData: function(event) {
if (event.getType() === "im.vector.web.settings") {
this.setState({
@ -364,12 +405,14 @@ const LoggedInView = React.createClass({
this.setState({mouseDown: null});
},
_setResizeContainerRef(div) {
this.resizeContainer = div;
},
render: function() {
const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RightPanel = sdk.getComponent('structures.RightPanel');
const RoomView = sdk.getComponent('structures.RoomView');
const UserSettings = sdk.getComponent('structures.UserSettings');
const CreateRoom = sdk.getComponent('structures.CreateRoom');
const RoomDirectory = sdk.getComponent('structures.RoomDirectory');
const HomePage = sdk.getComponent('structures.HomePage');
const GroupView = sdk.getComponent('structures.GroupView');
@ -382,7 +425,6 @@ const LoggedInView = React.createClass({
const ServerLimitBar = sdk.getComponent('globals.ServerLimitBar');
let page_element;
let right_panel = '';
switch (this.props.page_type) {
case PageTypes.RoomView:
@ -396,12 +438,9 @@ const LoggedInView = React.createClass({
eventPixelOffset={this.props.initialEventPixelOffset}
key={this.props.currentRoomId || 'roomview'}
disabled={this.props.middleDisabled}
collapsedRhs={this.props.collapseRhs}
collapsedRhs={this.props.collapsedRhs}
ConferenceHandler={this.props.ConferenceHandler}
/>;
if (!this.props.collapseRhs) {
right_panel = <RightPanel roomId={this.props.currentRoomId} disabled={this.props.rightDisabled} />;
}
break;
case PageTypes.UserSettings:
@ -411,21 +450,12 @@ const LoggedInView = React.createClass({
referralBaseUrl={this.props.config.referralBaseUrl}
teamToken={this.props.teamToken}
/>;
if (!this.props.collapseRhs) right_panel = <RightPanel disabled={this.props.rightDisabled} />;
break;
case PageTypes.MyGroups:
page_element = <MyGroups />;
break;
case PageTypes.CreateRoom:
page_element = <CreateRoom
onRoomCreated={this.props.onRoomCreated}
collapsedRhs={this.props.collapseRhs}
/>;
if (!this.props.collapseRhs) right_panel = <RightPanel disabled={this.props.rightDisabled} />;
break;
case PageTypes.RoomDirectory:
page_element = <RoomDirectory
ref="roomDirectory"
@ -451,15 +481,15 @@ const LoggedInView = React.createClass({
case PageTypes.UserView:
page_element = null; // deliberately null for now
right_panel = <RightPanel disabled={this.props.rightDisabled} />;
// TODO: fix/remove UserView
// right_panel = <RightPanel disabled={this.props.rightDisabled} />;
break;
case PageTypes.GroupView:
page_element = <GroupView
groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew}
collapsedRhs={this.props.collapseRhs}
collapsedRhs={this.props.collapsedRhs}
/>;
if (!this.props.collapseRhs) right_panel = <RightPanel groupId={this.props.currentGroupId} disabled={this.props.rightDisabled} />;
break;
}
@ -511,15 +541,13 @@ const LoggedInView = React.createClass({
<div className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} onMouseDown={this._onMouseDown} onMouseUp={this._onMouseUp}>
{ topBar }
<DragDropContext onDragEnd={this._onDragEnd}>
<div className={bodyClasses}>
<div ref={this._setResizeContainerRef} className={bodyClasses}>
<LeftPanel
collapsed={this.props.collapseLhs || false}
collapsed={this.props.collapseLhs || this.state.collapseLhs || false}
disabled={this.props.leftDisabled}
/>
<main className='mx_MatrixChat_middlePanel'>
{ page_element }
</main>
{ right_panel }
<ResizeHandle/>
{ page_element }
</div>
</DragDropContext>
</div>

View file

@ -0,0 +1,101 @@
/*
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 ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, FixedDistributor} from '../../resizer';
export default class MainSplit extends React.Component {
constructor(props) {
super(props);
this._setResizeContainerRef = this._setResizeContainerRef.bind(this);
this._onResized = this._onResized.bind(this);
}
_onResized(size) {
window.localStorage.setItem("mx_rhs_size", size);
}
_createResizer() {
const classNames = {
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse",
};
const resizer = new Resizer(
this.resizeContainer,
FixedDistributor,
{onResized: this._onResized},
);
resizer.setClassNames(classNames);
let rhsSize = window.localStorage.getItem("mx_rhs_size");
if (rhsSize !== null) {
rhsSize = parseInt(rhsSize, 10);
} else {
rhsSize = 350;
}
resizer.forHandleAt(0).resize(rhsSize);
resizer.attach();
this.resizer = resizer;
}
_setResizeContainerRef(div) {
this.resizeContainer = div;
}
componentDidMount() {
if (this.props.panel && !this.props.collapsedRhs) {
this._createResizer();
}
}
componentWillUnmount() {
if (this.resizer) {
this.resizer.detach();
this.resizer = null;
}
}
componentDidUpdate(prevProps) {
const wasExpanded = !this.props.collapsedRhs && prevProps.collapsedRhs;
const wasCollapsed = this.props.collapsedRhs && !prevProps.collapsedRhs;
const wasPanelSet = this.props.panel && !prevProps.panel;
const wasPanelCleared = !this.props.panel && prevProps.panel;
if (wasExpanded || wasPanelSet) {
this._createResizer();
} else if (wasCollapsed || wasPanelCleared) {
this.resizer.detach();
this.resizer = null;
}
}
render() {
const bodyView = React.Children.only(this.props.children);
const panelView = this.props.panel;
if (this.props.collapsedRhs || !panelView) {
return bodyView;
} else {
return <div className="mx_MainSplit" ref={this._setResizeContainerRef}>
{ bodyView }
<ResizeHandle reverse={true} />
{ panelView }
</div>;
}
}
}

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2017-2019 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.
@ -163,7 +163,7 @@ export default React.createClass({
viewUserId: null,
collapseLhs: false,
collapseRhs: false,
collapsedRhs: window.localStorage.getItem("mx_rhs_collapsed") === "true",
leftDisabled: false,
middleDisabled: false,
rightDisabled: false,
@ -579,7 +579,7 @@ export default React.createClass({
break;
case 'view_user':
// FIXME: ugly hack to expand the RightPanel and then re-dispatch.
if (this.state.collapseRhs) {
if (this.state.collapsedRhs) {
setTimeout(()=>{
dis.dispatch({
action: 'show_right_panel',
@ -680,13 +680,15 @@ export default React.createClass({
});
break;
case 'hide_right_panel':
window.localStorage.setItem("mx_rhs_collapsed", true);
this.setState({
collapseRhs: true,
collapsedRhs: true,
});
break;
case 'show_right_panel':
window.localStorage.setItem("mx_rhs_collapsed", false);
this.setState({
collapseRhs: false,
collapsedRhs: false,
});
break;
case 'panel_disable': {
@ -697,9 +699,11 @@ export default React.createClass({
});
break;
}
case 'set_theme':
this._onSetTheme(payload.value);
break;
// case 'set_theme':
// disable changing the theme for now
// as other themes are not compatible with dharma
// this._onSetTheme(payload.value);
// break;
case 'on_logging_in':
// We are now logging in, so set the state to reflect that
// NB. This does not touch 'ready' since if our dispatches
@ -923,6 +927,10 @@ export default React.createClass({
},
_viewHome: function() {
// The home page requires the "logged in" view, so we'll set that.
this.setStateForNewView({
view: VIEWS.LOGGED_IN,
});
this._setPage(PageTypes.HomePage);
this.notifyNewScreen('home');
},
@ -1179,10 +1187,7 @@ export default React.createClass({
* @param {string} teamToken
*/
_onLoggedIn: async function(teamToken) {
this.setState({
view: VIEWS.LOGGED_IN,
});
this.setStateForNewView({view: VIEWS.LOGGED_IN});
if (teamToken) {
// A team member has logged in, not a guest
this._teamToken = teamToken;
@ -1239,7 +1244,7 @@ export default React.createClass({
view: VIEWS.LOGIN,
ready: false,
collapseLhs: false,
collapseRhs: false,
collapsedRhs: false,
currentRoomId: null,
page_type: PageTypes.RoomDirectory,
});
@ -1430,10 +1435,33 @@ export default React.createClass({
break;
}
});
cli.on("crypto.keyBackupFailed", () => {
Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method',
import('../../async-components/views/dialogs/keybackup/NewRecoveryMethodDialog'),
);
cli.on("crypto.keyBackupFailed", async (errcode) => {
let haveNewVersion;
let newVersionInfo;
// if key backup is still enabled, there must be a new backup in place
if (MatrixClientPeg.get().getKeyBackupEnabled()) {
haveNewVersion = true;
} else {
// otherwise check the server to see if there's a new one
try {
newVersionInfo = await MatrixClientPeg.get().getKeyBackupVersion();
if (newVersionInfo !== null) haveNewVersion = true;
} catch (e) {
console.error("Saw key backup error but failed to check backup version!", e);
return;
}
}
if (haveNewVersion) {
Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method',
import('../../async-components/views/dialogs/keybackup/NewRecoveryMethodDialog'),
{ newVersionInfo },
);
} else {
Modal.createTrackedDialogAsync('Recovery Method Removed', 'Recovery Method Removed',
import('../../async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog'),
);
}
});
// Fire the tinter right on startup to ensure the default theme is applied

View file

@ -631,12 +631,20 @@ module.exports = React.createClass({
}
},
_scrollDownIfAtBottom: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.checkScroll();
}
},
onResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true);
},
render: function() {
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile");
const Spinner = sdk.getComponent("elements.Spinner");
let topSpinner;
let bottomSpinner;
@ -656,6 +664,11 @@ module.exports = React.createClass({
},
);
let whoIsTyping;
if (this.props.room) {
whoIsTyping = (<WhoIsTypingTile room={this.props.room} onVisible={this._scrollDownIfAtBottom} />);
}
return (
<ScrollPanel ref="scrollPanel" className={className}
onScroll={this.props.onScroll}
@ -666,6 +679,7 @@ module.exports = React.createClass({
stickyBottom={this.props.stickyBottom}>
{ topSpinner }
{ this._getEventTiles() }
{ whoIsTyping }
{ bottomSpinner }
</ScrollPanel>
);

View file

@ -60,7 +60,6 @@ export default withMatrixClient(React.createClass({
render: function() {
const Loader = sdk.getComponent("elements.Spinner");
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const GroupTile = sdk.getComponent("groups.GroupTile");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
@ -112,7 +111,6 @@ export default withMatrixClient(React.createClass({
<div className='mx_MyGroups_header'>
<div className="mx_MyGroups_headerCard">
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="50" height="50" />
</AccessibleButton>
<div className="mx_MyGroups_headerCard_content">
<div className="mx_MyGroups_headerCard_header">

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector 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.
@ -19,97 +20,28 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { _t } from '../../languageHandler';
import sdk from '../../index';
import dis from '../../dispatcher';
import { MatrixClient } from 'matrix-js-sdk';
import Analytics from '../../Analytics';
import RateLimitedFunc from '../../ratelimitedfunc';
import AccessibleButton from '../../components/views/elements/AccessibleButton';
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
import GroupStore from '../../stores/GroupStore';
import { formatCount } from '../../utils/FormattingUtils';
import MatrixClientPeg from "../../MatrixClientPeg";
class HeaderButton extends React.Component {
constructor() {
super();
this.onClick = this.onClick.bind(this);
export default class RightPanel extends React.Component {
static get propTypes() {
return {
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
};
}
onClick(ev) {
Analytics.trackEvent(...this.props.analytics);
dis.dispatch({
action: 'view_right_panel_phase',
phase: this.props.clickPhase,
});
static get contextTypes() {
return {
matrixClient: PropTypes.instanceOf(MatrixClient),
};
}
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
// XXX: We really shouldn't be hardcoding colors here, but the way TintableSvg
// works kinda prevents us from using normal CSS tactics. We use $warning-color
// here.
// Note: This array gets passed along to the Tinter's forceColors eventually.
const tintableColors = this.props.badgeHighlight ? ["#ff0064"] : null;
const classNames = ["mx_RightPanel_headerButton"];
if (this.props.badgeHighlight) classNames.push("mx_RightPanel_headerButton_badgeHighlight");
return <AccessibleButton
aria-label={this.props.title}
aria-expanded={this.props.isHighlighted}
title={this.props.title}
className={classNames.join(" ")}
onClick={this.onClick} >
<div className="mx_RightPanel_headerButton_badge">
{ this.props.badge ? this.props.badge : <span>&nbsp;</span> }
</div>
<TintableSvg src={this.props.iconSrc} width="25" height="25" forceColors={tintableColors} />
{ this.props.isHighlighted ? <div className="mx_RightPanel_headerButton_highlight" /> : <div /> }
</AccessibleButton>;
}
}
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,
badgeHighlight: PropTypes.bool,
// 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: {
static Phase = Object.freeze({
RoomMemberList: 'RoomMemberList',
GroupMemberList: 'GroupMemberList',
GroupRoomList: 'GroupRoomList',
@ -118,163 +50,102 @@ module.exports = React.createClass({
NotificationPanel: 'NotificationPanel',
RoomMemberInfo: 'RoomMemberInfo',
GroupMemberInfo: 'GroupMemberInfo',
},
});
componentWillMount: function() {
constructor(props, context) {
super(props, context);
this.state = {
phase: this.props.groupId ? RightPanel.Phase.GroupMemberList : RightPanel.Phase.RoomMemberList,
isUserPrivilegedInGroup: null,
};
this.onAction = this.onAction.bind(this);
this.onRoomStateMember = this.onRoomStateMember.bind(this);
this.onGroupStoreUpdated = this.onGroupStoreUpdated.bind(this);
this.onInviteToGroupButtonClick = this.onInviteToGroupButtonClick.bind(this);
this.onAddRoomToGroupButtonClick = this.onAddRoomToGroupButtonClick.bind(this);
this._delayedUpdate = new RateLimitedFunc(() => {
this.forceUpdate();
}, 500);
}
componentWillMount() {
this.dispatcherRef = dis.register(this.onAction);
const cli = this.context.matrixClient;
cli.on("RoomState.members", this.onRoomStateMember);
this._initGroupStore(this.props.groupId);
},
}
componentWillUnmount: function() {
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
if (this.context.matrixClient) {
this.context.matrixClient.removeListener("RoomState.members", this.onRoomStateMember);
}
this._unregisterGroupStore(this.props.groupId);
},
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.props.groupId);
this._initGroupStore(newProps.groupId);
}
},
}
_initGroupStore(groupId) {
if (!groupId) return;
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
},
}
_unregisterGroupStore() {
GroupStore.unregisterListener(this.onGroupStoreUpdated);
},
}
onGroupStoreUpdated: function() {
onGroupStoreUpdated() {
this.setState({
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
});
},
}
onCollapseClick: function() {
dis.dispatch({
action: 'hide_right_panel',
});
},
onInviteButtonClick: function() {
if (this.context.matrixClient.isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
// call AddressPickerDialog
dis.dispatch({
action: 'view_invite',
roomId: this.props.roomId,
});
},
onInviteToGroupButtonClick: function() {
onInviteToGroupButtonClick() {
showGroupInviteDialog(this.props.groupId).then(() => {
this.setState({
phase: this.Phase.GroupMemberList,
phase: RightPanel.Phase.GroupMemberList,
});
});
},
}
onAddRoomToGroupButtonClick: function() {
onAddRoomToGroupButtonClick() {
showGroupAddRoomDialog(this.props.groupId).then(() => {
this.forceUpdate();
});
},
}
onRoomStateMember: function(ev, state, member) {
onRoomStateMember(ev, state, member) {
if (member.roomId !== this.props.roomId) {
return;
}
// redraw the badge on the membership list
if (this.state.phase === this.Phase.RoomMemberList && member.roomId === this.props.roomId) {
if (this.state.phase === RightPanel.Phase.RoomMemberList && member.roomId === this.props.roomId) {
this._delayedUpdate();
} else if (this.state.phase === this.Phase.RoomMemberInfo && member.roomId === this.props.roomId &&
} else if (this.state.phase === RightPanel.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 === "event_notification") {
// Try and re-caclulate any badge counts we might have
this.forceUpdate();
} else 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") {
onAction(payload) {
if (payload.action === "view_right_panel_phase") {
this.setState({
phase: payload.phase,
groupRoomId: payload.groupRoomId,
groupId: payload.groupId,
member: payload.member,
});
}
},
}
render: function() {
render() {
const MemberList = sdk.getComponent('rooms.MemberList');
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
@ -285,164 +156,42 @@ module.exports = React.createClass({
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.getJoinedMemberCount();
membersTitle = _t('%(count)s Members', { count: numMembers });
membersBadge = <div title={membersTitle}>{ formatCount(numMembers) }</div>;
isUserInRoom = room.hasMembershipState(this.context.matrixClient.credentials.userId, 'join');
}
if (isUserInRoom) {
inviteGroup =
<AccessibleButton className="mx_RightPanel_invite" onClick={this.onInviteButtonClick}>
<div className="mx_RightPanel_icon" >
<TintableSvg src="img/icon-invite-people.svg" width="35" height="35" />
</div>
<div className="mx_RightPanel_message">{ _t('Invite to this room') }</div>
</AccessibleButton>;
}
}
const isPhaseGroup = [
this.Phase.GroupMemberInfo,
this.Phase.GroupMemberList,
].includes(this.state.phase);
let headerButtons = [];
if (this.props.roomId) {
let notifCountBadge;
let notifCount = 0;
MatrixClientPeg.get().getRooms().forEach(r => notifCount += (r.getUnreadNotificationCount('highlight') || 0));
if (notifCount > 0) {
const title = _t("%(count)s Notifications", {count: formatCount(notifCount)});
notifCountBadge = <div title={title}>{ formatCount(notifCount) }</div>;
}
headerButtons = [
<HeaderButton key="_membersButton" title={membersTitle} iconSrc="img/icons-people.svg"
isHighlighted={[this.Phase.RoomMemberList, this.Phase.RoomMemberInfo].includes(this.state.phase)}
clickPhase={this.Phase.RoomMemberList}
badge={membersBadge}
analytics={['Right Panel', 'Member List Button', 'click']}
/>,
<HeaderButton key="_filesButton" title={_t('Files')} iconSrc="img/icons-files.svg"
isHighlighted={this.state.phase === this.Phase.FilePanel}
clickPhase={this.Phase.FilePanel}
analytics={['Right Panel', 'File List Button', 'click']}
/>,
<HeaderButton key="_notifsButton" title={_t('Notifications')} iconSrc="img/icons-notifications.svg"
isHighlighted={this.state.phase === this.Phase.NotificationPanel}
clickPhase={this.Phase.NotificationPanel}
badge={notifCountBadge} badgeHighlight={notifCount > 0}
analytics={['Right Panel', 'Notification List Button', 'click']}
/>,
];
} else if (this.props.groupId) {
headerButtons = [
<HeaderButton key="_groupMembersButton" title={_t('Members')} iconSrc="img/icons-people.svg"
isHighlighted={isPhaseGroup}
clickPhase={this.Phase.GroupMemberList}
analytics={['Right Panel', 'Group Member List Button', 'click']}
/>,
<HeaderButton key="_roomsButton" title={_t('Rooms')} iconSrc="img/icons-room.svg"
isHighlighted={[this.Phase.GroupRoomList, this.Phase.GroupRoomInfo].includes(this.state.phase)}
clickPhase={this.Phase.GroupRoomList}
analytics={['Right Panel', 'Group Room List Button', 'click']}
/>,
];
}
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(
<AccessibleButton className="mx_RightPanel_headerButton mx_RightPanel_collapsebutton" key="_minimizeButton"
title={_t("Hide panel")} aria-label={_t("Hide panel")} onClick={this.onCollapseClick}
>
<TintableSvg src="img/minimise.svg" width="10" height="16" alt="" />
</AccessibleButton>,
);
}
let panel = <div />;
if (!this.props.collapsed) {
if (this.props.roomId && this.state.phase === this.Phase.RoomMemberList) {
panel = <MemberList roomId={this.props.roomId} key={this.props.roomId} />;
} else if (this.props.groupId && this.state.phase === this.Phase.GroupMemberList) {
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === this.Phase.GroupRoomList) {
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === this.Phase.RoomMemberInfo) {
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
} else if (this.state.phase === this.Phase.GroupMemberInfo) {
panel = <GroupMemberInfo
groupMember={this.state.member}
groupId={this.props.groupId}
key={this.state.member.user_id} />;
} else if (this.state.phase === this.Phase.GroupRoomInfo) {
panel = <GroupRoomInfo
groupRoomId={this.state.groupRoomId}
groupId={this.props.groupId}
key={this.state.groupRoomId} />;
} else if (this.state.phase === this.Phase.NotificationPanel) {
panel = <NotificationPanel />;
} else if (this.state.phase === this.Phase.FilePanel) {
panel = <FilePanel roomId={this.props.roomId} />;
}
}
if (!panel) {
panel = <div className="mx_RightPanel_blank" />;
}
if (this.props.groupId && this.state.isUserPrivilegedInGroup) {
inviteGroup = isPhaseGroup ? (
<AccessibleButton className="mx_RightPanel_invite" onClick={this.onInviteToGroupButtonClick}>
<div className="mx_RightPanel_icon" >
<TintableSvg src="img/icon-invite-people.svg" width="35" height="35" />
</div>
<div className="mx_RightPanel_message">{ _t('Invite to this community') }</div>
</AccessibleButton>
) : (
<AccessibleButton className="mx_RightPanel_invite" onClick={this.onAddRoomToGroupButtonClick}>
<div className="mx_RightPanel_icon" >
<TintableSvg src="img/icons-room-add.svg" width="35" height="35" />
</div>
<div className="mx_RightPanel_message">{ _t('Add rooms to this community') }</div>
</AccessibleButton>
);
if (this.props.roomId && this.state.phase === RightPanel.Phase.RoomMemberList) {
panel = <MemberList roomId={this.props.roomId} key={this.props.roomId} />;
} else if (this.props.groupId && this.state.phase === RightPanel.Phase.GroupMemberList) {
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === RightPanel.Phase.GroupRoomList) {
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === RightPanel.Phase.RoomMemberInfo) {
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
} else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) {
panel = <GroupMemberInfo
groupMember={this.state.member}
groupId={this.props.groupId}
key={this.state.member.user_id} />;
} else if (this.state.phase === RightPanel.Phase.GroupRoomInfo) {
panel = <GroupRoomInfo
groupRoomId={this.state.groupRoomId}
groupId={this.props.groupId}
key={this.state.groupRoomId} />;
} else if (this.state.phase === RightPanel.Phase.NotificationPanel) {
panel = <NotificationPanel />;
} else if (this.state.phase === RightPanel.Phase.FilePanel) {
panel = <FilePanel roomId={this.props.roomId} />;
}
const classes = classNames("mx_RightPanel", "mx_fadable", {
"collapsed": this.props.collapsed,
"mx_fadable_faded": this.props.disabled,
"dark-panel": true,
});
return (
<aside className={classes}>
<div className="mx_RightPanel_header">
<div className="mx_RightPanel_headerButtonGroup">
{ headerButtons }
</div>
</div>
{ panel }
<div className="mx_RightPanel_footer">
{ inviteGroup }
</div>
</aside>
);
},
});
}
}

View file

@ -62,10 +62,6 @@ module.exports = React.createClass({
// more interesting)
hasActiveCall: PropTypes.bool,
// Number of names to display in typing indication. E.g. set to 3, will
// result in "X, Y, Z and 100 others are typing."
whoIsTypingLimit: PropTypes.number,
// true if the room is being peeked at. This affects components that shouldn't
// logically be shown when peeking, such as a prompt to invite people to a room.
isPeeking: PropTypes.bool,
@ -103,24 +99,16 @@ module.exports = React.createClass({
onVisible: PropTypes.func,
},
getDefaultProps: function() {
return {
whoIsTypingLimit: 3,
};
},
getInitialState: function() {
return {
syncState: MatrixClientPeg.get().getSyncState(),
syncStateData: MatrixClientPeg.get().getSyncStateData(),
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
unsentMessages: getUnsentMessages(this.props.room),
};
},
componentWillMount: function() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
this._checkSize();
@ -135,7 +123,6 @@ module.exports = React.createClass({
const client = MatrixClientPeg.get();
if (client) {
client.removeListener("sync", this.onSyncStateChange);
client.removeListener("RoomMember.typing", this.onRoomMemberTyping);
client.removeListener("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
}
},
@ -150,12 +137,6 @@ module.exports = React.createClass({
});
},
onRoomMemberTyping: function(ev, member) {
this.setState({
usersTyping: WhoIsTyping.usersTypingApartFromMeAndIgnored(this.props.room),
});
},
_onSendWithoutVerifyingClick: function() {
cryptodevices.getUnknownDevicesForRoom(MatrixClientPeg.get(), this.props.room).then((devices) => {
cryptodevices.markAllDevicesKnown(MatrixClientPeg.get(), devices);
@ -199,7 +180,6 @@ module.exports = React.createClass({
// indicate other sizes.
_getSize: function() {
if (this._shouldShowConnectionError() ||
(this.state.usersTyping.length > 0) ||
this.props.numUnreadMessages ||
!this.props.atEndOfLiveTimeline ||
this.props.hasActiveCall ||
@ -213,10 +193,7 @@ module.exports = React.createClass({
},
// return suitable content for the image on the left of the status bar.
//
// if wantPlaceholder is true, we include a "..." placeholder if
// there is nothing better to put in.
_getIndicator: function(wantPlaceholder) {
_getIndicator: function() {
if (this.props.numUnreadMessages) {
return (
<div className="mx_RoomStatusBar_scrollDownIndicator"
@ -250,49 +227,9 @@ module.exports = React.createClass({
return null;
}
if (wantPlaceholder) {
return (
<div className="mx_RoomStatusBar_typingIndicatorAvatars">
{ this._renderTypingIndicatorAvatars(this.props.whoIsTypingLimit) }
</div>
);
}
return null;
},
_renderTypingIndicatorAvatars: function(limit) {
let users = this.state.usersTyping;
let othersCount = 0;
if (users.length > limit) {
othersCount = users.length - limit + 1;
users = users.slice(0, limit - 1);
}
const avatars = users.map((u) => {
return (
<MemberAvatar
key={u.userId}
member={u}
width={24}
height={24}
resizeMethod="crop"
/>
);
});
if (othersCount > 0) {
avatars.push(
<span className="mx_RoomStatusBar_typingIndicatorRemaining" key="others">
+{ othersCount }
</span>,
);
}
return avatars;
},
_shouldShowConnectionError: function() {
// no conn bar trumps unread count since you can't get unread messages
// without a connection! (technically may already have some but meh)
@ -440,18 +377,6 @@ module.exports = React.createClass({
);
}
const typingString = WhoIsTyping.whoIsTypingString(
this.state.usersTyping,
this.props.whoIsTypingLimit,
);
if (typingString) {
return (
<div className="mx_RoomStatusBar_typingBar">
<EmojiText>{ typingString }</EmojiText>
</div>
);
}
if (this.props.hasActiveCall) {
return (
<div className="mx_RoomStatusBar_callBar">
@ -483,7 +408,7 @@ module.exports = React.createClass({
render: function() {
const content = this._getContent();
const indicator = this._getIndicator(this.state.usersTyping.length > 0);
const indicator = this._getIndicator();
return (
<div className="mx_RoomStatusBar">

View file

@ -19,12 +19,11 @@ limitations under the License.
import React from 'react';
import classNames from 'classnames';
import sdk from '../../index';
import { Droppable } from 'react-beautiful-dnd';
import { _t } from '../../languageHandler';
import dis from '../../dispatcher';
import Unread from '../../Unread';
import * as RoomNotifs from '../../RoomNotifs';
import * as FormattingUtils from '../../utils/FormattingUtils';
import IndicatorScrollbar from './IndicatorScrollbar';
import { KeyCode } from '../../Keyboard';
import { Group } from 'matrix-js-sdk';
import PropTypes from 'prop-types';
@ -33,8 +32,6 @@ import PropTypes from 'prop-types';
// turn this on for drop & drag console debugging galore
const debug = false;
const TRUNCATE_AT = 10;
const RoomSubList = React.createClass({
displayName: 'RoomSubList',
@ -44,7 +41,6 @@ const RoomSubList = React.createClass({
list: PropTypes.arrayOf(PropTypes.object).isRequired,
label: PropTypes.string.isRequired,
tagName: PropTypes.string,
editable: PropTypes.bool,
order: PropTypes.string.isRequired,
@ -55,21 +51,15 @@ const RoomSubList = React.createClass({
showSpinner: PropTypes.bool, // true to show a spinner if 0 elements when expanded
collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed?
onHeaderClick: PropTypes.func,
alwaysShowHeader: PropTypes.bool,
incomingCall: PropTypes.object,
onShowMoreRooms: PropTypes.func,
searchFilter: PropTypes.string,
emptyContent: PropTypes.node, // content shown if the list is empty
isFiltered: PropTypes.bool,
headerItems: PropTypes.node, // content shown in the sublist header
extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles
showEmpty: PropTypes.bool,
},
getInitialState: function() {
return {
hidden: this.props.startAsHidden || false,
truncateAt: TRUNCATE_AT,
sortedList: [],
};
},
@ -77,18 +67,12 @@ const RoomSubList = React.createClass({
return {
onHeaderClick: function() {
}, // NOP
onShowMoreRooms: function() {
}, // NOP
extraTiles: [],
isInvite: false,
showEmpty: true,
};
},
componentWillMount: function() {
this.setState({
sortedList: this.applySearchFilter(this.props.list, this.props.searchFilter),
});
this.dispatcherRef = dis.register(this.onAction);
},
@ -96,23 +80,6 @@ const RoomSubList = React.createClass({
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;
const lcFilter = filter.toLowerCase();
// case insensitive if room name includes filter,
// or if starts with `#` and one of room's aliases starts with filter
return list.filter((room) => (room.name && room.name.toLowerCase().includes(lcFilter)) ||
(filter[0] === '#' && room.getAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter))));
},
// The header is collapsable if it is hidden or not stuck
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
isCollapsableOnClick: function() {
@ -143,15 +110,9 @@ const RoomSubList = React.createClass({
if (this.isCollapsableOnClick()) {
// The header isCollapsable, so the click is to be interpreted as collapse and truncation logic
const 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);
this.setState({hidden: isHidden}, () => {
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);
@ -178,10 +139,9 @@ const RoomSubList = React.createClass({
/**
* Total up all the notification counts from the rooms
*
* @param {Number} truncateAt 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) {
roomNotificationCount: function() {
const self = this;
if (this.props.isInvite) {
@ -189,20 +149,18 @@ const RoomSubList = React.createClass({
}
return this.props.list.reduce(function(result, room, index) {
if (truncateAt === undefined || index >= truncateAt) {
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && self._shouldShowMentionBadge(roomNotifState);
const badges = notifBadges || mentionBadges;
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;
}
if (badges) {
result[0] += notificationCount;
if (highlight) {
result[1] = true;
}
}
return result;
@ -217,15 +175,9 @@ const RoomSubList = React.createClass({
},
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 <DNDRoomTile /> when editable
const RoomTileComponent = this.props.editable ? DNDRoomTile : RoomTile;
return <RoomTileComponent
index={index} // For DND
return this.props.list.map((room, index) => {
return <RoomTile
room={room}
roomSubList={this}
tagName={this.props.tagName}
@ -246,7 +198,7 @@ const RoomSubList = React.createClass({
e.preventDefault();
e.stopPropagation();
// find first room which has notifications and switch to it
for (const room of this.state.sortedList) {
for (const room of this.props.list) {
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
@ -269,10 +221,10 @@ const RoomSubList = React.createClass({
e.preventDefault();
e.stopPropagation();
// switch to first room in sortedList as that'll be the top of the list for the user
if (this.state.sortedList && this.state.sortedList.length > 0) {
if (this.props.list && this.props.list.length > 0) {
dis.dispatch({
action: 'view_room',
room_id: this.state.sortedList[0].roomId,
room_id: this.props.list[0].roomId,
});
} else if (this.props.extraTiles && this.props.extraTiles.length > 0) {
// Group Invites are different in that they are all extra tiles and not rooms
@ -287,32 +239,25 @@ const RoomSubList = React.createClass({
},
_getHeaderJsx: function() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const subListNotifications = this.roomNotificationCount();
const subListNotifCount = subListNotifications[0];
const subListNotifHighlight = subListNotifications[1];
const totalTiles = this.props.list.length + (this.props.extraTiles || []).length;
const roomCount = totalTiles > 0 ? totalTiles : '';
const chevronClasses = classNames({
'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': this.state.hidden,
'mx_RoomSubList_chevronDown': !this.state.hidden,
});
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
let badge;
if (subListNotifCount > 0) {
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>;
} else if (this.props.isInvite) {
// no notifications but highlight anyway because this is an invite badge
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>!</div>;
if (!this.props.collapsed) {
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
if (subListNotifCount > 0) {
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>;
} else if (this.props.isInvite) {
// no notifications but highlight anyway because this is an invite badge
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>!</div>;
}
}
// When collapsed, allow a long hover on the header to show user
@ -320,9 +265,6 @@ const RoomSubList = React.createClass({
let title;
if (this.props.collapsed) {
title = this.props.label;
if (roomCount !== '') {
title += " [" + roomCount + "]";
}
}
let incomingCall;
@ -333,126 +275,77 @@ const RoomSubList = React.createClass({
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
}
const tabindex = this.props.searchFilter === "" ? "0" : "-1";
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleButton onClick={ this.props.onAddRoom } className="mx_RoomSubList_addRoom" />
);
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const len = this.props.list.length + this.props.extraTiles.length;
let chevron;
if (len) {
const chevronClasses = classNames({
'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': this.state.hidden,
'mx_RoomSubList_chevronDown': !this.state.hidden,
});
chevron = (<div className={chevronClasses}></div>);
}
const tabindex = this.props.isFiltered ? "0" : "-1";
return (
<div className="mx_RoomSubList_labelContainer" title={title} ref="header">
<AccessibleButton onClick={this.onClick} className="mx_RoomSubList_label" tabIndex={tabindex}>
{this.props.collapsed ? '' : this.props.label}
<div className="mx_RoomSubList_roomCount">{roomCount}</div>
<div className={chevronClasses} />
{badge}
{incomingCall}
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header">
<AccessibleButton onClick={ this.onClick } className="mx_RoomSubList_label" tabIndex={tabindex}>
{ chevron }
<span>{this.props.label}</span>
{ incomingCall }
</AccessibleButton>
{ badge }
{ addRoomButton }
</div>
);
},
_createOverflowTile: function(overflowCount, totalCount) {
let content = <div className="mx_RoomSubList_chevronDown" />;
const overflowNotifications = this.roomNotificationCount(TRUNCATE_AT);
const overflowNotifCount = overflowNotifications[0];
const overflowNotifHighlight = overflowNotifications[1];
if (overflowNotifCount && !this.props.collapsed) {
content = FormattingUtils.formatCount(overflowNotifCount);
checkOverflow: function() {
if (this.refs.scroller) {
this.refs.scroller.checkOverflow();
}
const badgeClasses = classNames({
'mx_RoomSubList_moreBadge': true,
'mx_RoomSubList_moreBadgeNotify': overflowNotifCount && !this.props.collapsed,
'mx_RoomSubList_moreBadgeHighlight': overflowNotifHighlight && !this.props.collapsed,
});
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton className="mx_RoomSubList_ellipsis" onClick={this._showFullMemberList}>
<div className="mx_RoomSubList_line" />
<div className="mx_RoomSubList_more">{_t("more")}</div>
<div className={badgeClasses}>{content}</div>
</AccessibleButton>
);
},
_showFullMemberList: function() {
this.setState({
truncateAt: -1,
});
this.props.onShowMoreRooms();
this.props.onHeaderClick(false);
},
render: function() {
const TruncatedList = sdk.getComponent('elements.TruncatedList');
let content;
if (this.props.showEmpty) {
// this is new behaviour with still controversial UX in that in hiding RoomSubLists the drop zones for DnD
// are also gone so when filtering users can't DnD rooms to some tags but is a lot cleaner otherwise.
if (this.state.sortedList.length === 0 && !this.props.searchFilter && this.props.extraTiles.length === 0) {
content = this.props.emptyContent;
const len = this.props.list.length + this.props.extraTiles.length;
if (len) {
const subListClasses = classNames({
"mx_RoomSubList": true,
"mx_RoomSubList_hidden": this.state.hidden,
"mx_RoomSubList_nonEmpty": len && !this.state.hidden,
});
if (this.state.hidden) {
return <div className={subListClasses}>
{this._getHeaderJsx()}
</div>;
} else {
content = this.makeRoomTiles();
content.push(...this.props.extraTiles);
const tiles = this.makeRoomTiles();
tiles.push(...this.props.extraTiles);
return <div className={subListClasses}>
{this._getHeaderJsx()}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll">
{ tiles }
</IndicatorScrollbar>
</div>;
}
} else {
if (this.state.sortedList.length === 0 && this.props.extraTiles.length === 0) {
// if no search filter is applied and there is a placeholder defined then show it, otherwise show nothing
if (!this.props.searchFilter && this.props.emptyContent) {
content = this.props.emptyContent;
} else {
// don't show an empty sublist
return null;
}
} else {
content = this.makeRoomTiles();
content.push(...this.props.extraTiles);
}
}
if (this.state.sortedList.length > 0 || this.props.extraTiles.length > 0 || this.props.editable) {
let subList;
const classes = "mx_RoomSubList";
if (!this.state.hidden) {
subList = <TruncatedList className={classes} truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}>
{content}
</TruncatedList>;
} else {
subList = <TruncatedList className={classes}>
</TruncatedList>;
}
const subListContent = <div>
{this._getHeaderJsx()}
{subList}
</div>;
return this.props.editable ?
<Droppable
droppableId={"room-sub-list-droppable_" + this.props.tagName}
type="draggable-RoomTile"
>
{(provided, snapshot) => (
<div ref={provided.innerRef}>
{subListContent}
</div>
)}
</Droppable> : subListContent;
} else {
const Loader = sdk.getComponent("elements.Spinner");
if (this.props.showSpinner) {
let content;
if (this.props.showSpinner && !this.state.hidden) {
content = <Loader />;
}
return (
<div className="mx_RoomSubList">
{this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined}
{ this.state.hidden ? undefined : content }
{ this._getHeaderJsx() }
{ content }
</div>
);
}

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2018, 2019 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.
@ -44,6 +44,8 @@ const Rooms = require('../../Rooms');
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
@ -609,17 +611,10 @@ module.exports = React.createClass({
}
},
async onRoomRecoveryReminderFinished(backupCreated) {
// If the user cancelled the key backup dialog, it suggests they don't
// want to be reminded anymore.
if (!backupCreated) {
await SettingsStore.setValue(
"showRoomRecoveryReminder",
null,
SettingLevel.ACCOUNT,
false,
);
}
onRoomRecoveryReminderDontAskAgain: function() {
// Called when the option to not ask again is set:
// force an update to hide the recovery reminder
this.forceUpdate();
},
onKeyBackupStatus() {
@ -1141,11 +1136,6 @@ module.exports = React.createClass({
// XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work?
if (this.state.searchResults.results === undefined) {
// awaiting results
return [];
}
const ret = [];
if (this.state.searchInProgress) {
@ -1202,7 +1192,7 @@ module.exports = React.createClass({
const roomName = room ? room.name : _t("Unknown room %(roomId)s", { roomId: roomId });
ret.push(<li key={mxEv.getId() + "-room"}>
<h1>{ _t("Room") }: { roomName }</h1>
<h2>{ _t("Room") }: { roomName }</h2>
</li>);
lastRoomId = roomId;
}
@ -1571,18 +1561,20 @@ module.exports = React.createClass({
oobData={this.props.oobData}
collapsedRhs={this.props.collapsedRhs}
/>
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false} error={this.state.roomLoadError}
roomAlias={roomAlias}
spinner={this.state.joining}
spinnerState="joining"
inviterName={inviterName}
invitedEmail={invitedEmail}
room={this.state.room}
/>
<div className="mx_RoomView_body">
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false} error={this.state.roomLoadError}
roomAlias={roomAlias}
spinner={this.state.joining}
spinnerState="joining"
inviterName={inviterName}
invitedEmail={invitedEmail}
room={this.state.room}
/>
</div>
</div>
<div className="mx_RoomView_messagePanel"></div>
</div>
@ -1616,16 +1608,18 @@ module.exports = React.createClass({
room={this.state.room}
collapsedRhs={this.props.collapsedRhs}
/>
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
inviterName={inviterName}
canPreview={false}
spinner={this.state.joining}
spinnerState="joining"
room={this.state.room}
/>
<div className="mx_RoomView_body">
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
inviterName={inviterName}
canPreview={false}
spinner={this.state.joining}
spinnerState="joining"
room={this.state.room}
/>
</div>
</div>
<div className="mx_RoomView_messagePanel"></div>
</div>
@ -1668,7 +1662,6 @@ module.exports = React.createClass({
onResize={this.onChildResize}
onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden}
whoIsTypingLimit={3}
/>;
}
@ -1699,7 +1692,7 @@ module.exports = React.createClass({
aux = <RoomUpgradeWarningBar room={this.state.room} />;
hideCancel = true;
} else if (showRoomRecoveryReminder) {
aux = <RoomRecoveryReminder onFinished={this.onRoomRecoveryReminderFinished} />;
aux = <RoomRecoveryReminder onDontAskAgainSet={this.onRoomRecoveryReminderDontAskAgain} />;
hideCancel = true;
} else if (this.state.showingPinned) {
hideCancel = true; // has own cancel
@ -1732,6 +1725,7 @@ module.exports = React.createClass({
const auxPanel = (
<AuxPanel ref="auxPanel" room={this.state.room}
fullHeight={this.state.editingRoomSettings}
userId={MatrixClientPeg.get().credentials.userId}
conferenceHandler={this.props.ConferenceHandler}
draggingFile={this.state.draggingFile}
@ -1818,16 +1812,21 @@ module.exports = React.createClass({
let hideMessagePanel = false;
if (this.state.searchResults) {
searchResultsPanel = (
<ScrollPanel ref="searchResultsPanel"
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
onFillRequest={this.onSearchResultsFillRequest}
onResize={this.onSearchResultsResize}
>
<li className={scrollheader_classes}></li>
{ this.getSearchResultTiles() }
</ScrollPanel>
);
// show searching spinner
if (this.state.searchResults.results === undefined) {
searchResultsPanel = (<div className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner" />);
} else {
searchResultsPanel = (
<ScrollPanel ref="searchResultsPanel"
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
onFillRequest={this.onSearchResultsFillRequest}
onResize={this.onSearchResultsResize}
>
<li className={scrollheader_classes}></li>
{ this.getSearchResultTiles() }
</ScrollPanel>
);
}
hideMessagePanel = true;
}
@ -1860,14 +1859,10 @@ module.exports = React.createClass({
let topUnreadMessagesBar = null;
if (this.state.showTopUnreadMessagesBar) {
const TopUnreadMessagesBar = sdk.getComponent('rooms.TopUnreadMessagesBar');
topUnreadMessagesBar = (
<div className="mx_RoomView_topUnreadMessagesBar">
<TopUnreadMessagesBar
onScrollUpClick={this.jumpToReadMarker}
onCloseClick={this.forgetReadMarker}
/>
</div>
);
topUnreadMessagesBar = (<TopUnreadMessagesBar
onScrollUpClick={this.jumpToReadMarker}
onCloseClick={this.forgetReadMarker}
/>);
}
const statusBarAreaClass = classNames(
"mx_RoomView_statusArea",
@ -1883,8 +1878,10 @@ module.exports = React.createClass({
},
);
const rightPanel = this.state.room ? <RightPanel roomId={this.state.room.roomId} /> : undefined;
return (
<div className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView">
<main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView">
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
oobData={this.props.oobData}
editing={this.state.editingRoomSettings}
@ -1899,20 +1896,24 @@ module.exports = React.createClass({
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
/>
{ auxPanel }
<div className={fadableSectionClasses}>
{ topUnreadMessagesBar }
{ messagePanel }
{ searchResultsPanel }
<div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line"></div>
{ statusBar }
<MainSplit panel={rightPanel} collapsedRhs={this.props.collapsedRhs}>
<div className={fadableSectionClasses}>
{ auxPanel }
<div className="mx_RoomView_timeline">
{ topUnreadMessagesBar }
{ messagePanel }
{ searchResultsPanel }
</div>
<div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line"></div>
{ statusBar }
</div>
</div>
{ messageComposer }
</div>
{ messageComposer }
</div>
</div>
</MainSplit>
</main>
);
},
});

View file

@ -28,8 +28,8 @@ module.exports = React.createClass({
displayName: 'SearchBox',
propTypes: {
collapsed: React.PropTypes.bool,
onSearch: React.PropTypes.func,
onCleared: React.PropTypes.func,
},
getInitialState: function() {
@ -56,7 +56,6 @@ module.exports = React.createClass({
case 'focus_room_filter':
if (this.refs.search) {
this.refs.search.focus();
this.refs.search.select();
}
break;
}
@ -75,86 +74,49 @@ module.exports = React.createClass({
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'});
this._clearSearch("keyboard");
break;
}
},
_clearSearch: function() {
_onFocus: function(ev) {
ev.target.select();
},
_clearSearch: function(source) {
this.refs.search.value = "";
this.onChange();
if (this.props.onCleared) {
this.props.onCleared(source);
}
},
render: function() {
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const collapseTabIndex = this.refs.search && this.refs.search.value !== "" ? "-1" : "0";
const clearButton = this.state.searchTerm.length > 0 ?
(<AccessibleButton key="button"
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button")} }>
</AccessibleButton>) : undefined;
let toggleCollapse;
if (this.props.collapsed) {
toggleCollapse =
<AccessibleButton className="mx_SearchBox_maximise" tabIndex={collapseTabIndex} onClick={ this.onToggleCollapse.bind(this, true) }>
<TintableSvg src="img/maximise.svg" width="10" height="16" alt={ _t("Expand panel") } />
</AccessibleButton>;
} else {
toggleCollapse =
<AccessibleButton className="mx_SearchBox_minimise" tabIndex={collapseTabIndex} onClick={ this.onToggleCollapse.bind(this, false) }>
<TintableSvg src="img/minimise.svg" width="10" height="16" alt={ _t("Collapse panel") } />
</AccessibleButton>;
}
let searchControls;
if (!this.props.collapsed) {
searchControls = [
this.state.searchTerm.length > 0 ?
<AccessibleButton key="button"
className="mx_SearchBox_closeButton"
onClick={ ()=>{ this._clearSearch(); } }>
<TintableSvg
className="mx_SearchBox_searchButton"
src="img/icons-close.svg" width="24" height="24"
/>
</AccessibleButton>
:
<TintableSvg
key="button"
className="mx_SearchBox_searchButton"
src="img/icons-search-copy.svg" width="13" height="13"
/>,
<input
key="searchfield"
type="text"
ref="search"
className="mx_SearchBox_search"
value={ this.state.searchTerm }
onChange={ this.onChange }
onKeyDown={ this._onKeyDown }
placeholder={ _t('Filter room names') }
/>,
];
}
const self = this;
return (
<div className="mx_SearchBox">
{ searchControls }
{ toggleCollapse }
<div className="mx_SearchBox mx_textinput">
<input
key="searchfield"
type="text"
ref="search"
className="mx_textinput_icon mx_textinput_search"
value={ this.state.searchTerm }
onFocus={ this._onFocus }
onChange={ this.onChange }
onKeyDown={ this._onKeyDown }
placeholder={ _t('Filter room names') }
/>
{ clearButton }
</div>
);
},

View file

@ -23,6 +23,7 @@ import GroupActions from '../../actions/GroupActions';
import sdk from '../../index';
import dis from '../../dispatcher';
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
@ -47,6 +48,8 @@ const TagPanel = React.createClass({
this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership);
this.context.matrixClient.on("sync", this._onClientSync);
this._dispatcherRef = dis.register(this._onAction);
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
if (this.unmounted) {
return;
@ -67,6 +70,9 @@ const TagPanel = React.createClass({
if (this._filterStoreToken) {
this._filterStoreToken.remove();
}
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
}
},
_onGroupMyMembership() {
@ -100,13 +106,21 @@ const TagPanel = React.createClass({
dis.dispatch({action: 'deselect_tags'});
},
_onAction(payload) {
if (payload.action === "show_redesign_feedback_dialog") {
const RedesignFeedbackDialog =
sdk.getComponent("views.dialogs.RedesignFeedbackDialog");
Modal.createDialog(RedesignFeedbackDialog);
}
},
render() {
const GroupsButton = sdk.getComponent('elements.GroupsButton');
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const ActionButton = sdk.getComponent("elements.ActionButton");
const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile
@ -162,7 +176,10 @@ const TagPanel = React.createClass({
</GeminiScrollbarWrapper>
<div className="mx_TagPanel_divider" />
<div className="mx_TagPanel_groupsButton">
<GroupsButton tooltip={true} />
<GroupsButton />
<ActionButton
className="mx_TagPanel_report" action="show_redesign_feedback_dialog"
label={_t("Report bugs & give feedback")} tooltip={true} />
</div>
</div>;
},

View file

@ -33,9 +33,13 @@ const ObjectUtils = require('../../ObjectUtils');
const Modal = require("../../Modal");
const UserActivity = require("../../UserActivity");
import { KeyCode } from '../../Keyboard';
import Timer from '../../utils/Timer';
const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20;
const READ_MARKER_INVIEW_THRESHOLD_MS = 1 * 1000;
const READ_MARKER_OUTOFVIEW_THRESHOLD_MS = 30 * 1000;
const READ_RECEIPT_INTERVAL_MS = 500;
const DEBUG = false;
@ -188,6 +192,14 @@ var TimelinePanel = React.createClass({
this.lastRRSentEventId = undefined;
this.lastRMSentEventId = undefined;
if (this.props.manageReadReceipts) {
this.updateReadReceiptOnUserActivity();
}
if (this.props.manageReadMarkers) {
this.updateReadMarkerOnUserActivity();
}
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset);
@ -254,6 +266,14 @@ var TimelinePanel = React.createClass({
//
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;
if (this._readReceiptActivityTimer) {
this._readReceiptActivityTimer.abort();
this._readReceiptActivityTimer = null;
}
if (this._readMarkerActivityTimer) {
this._readMarkerActivityTimer.abort();
this._readMarkerActivityTimer = null;
}
dis.unregister(this.dispatcherRef);
@ -362,30 +382,25 @@ var TimelinePanel = React.createClass({
}
if (this.props.manageReadMarkers) {
const rmPosition = this.getReadMarkerPosition();
// we hide the read marker when it first comes onto the screen, but if
// it goes back off the top of the screen (presumably because the user
// clicks on the 'jump to bottom' button), we need to re-enable it.
if (this.getReadMarkerPosition() < 0) {
if (rmPosition < 0) {
this.setState({readMarkerVisible: true});
}
// if read marker position goes between 0 and -1/1,
// (and user is active), switch timeout
const timeout = this._readMarkerTimeout(rmPosition);
// NO-OP when timeout already has set to the given value
this._readMarkerActivityTimer.changeTimeout(timeout);
}
},
onAction: function(payload) {
switch (payload.action) {
case 'user_activity':
case 'user_activity_end':
// we could treat user_activity_end differently and not
// send receipts for messages that have arrived between
// the actual user activity and the time they stopped
// being active, but let's see if this is actually
// necessary.
this.sendReadReceipt();
this.updateReadMarker();
break;
case 'ignore_state_changed':
this.forceUpdate();
break;
if (payload.action === 'ignore_state_changed') {
this.forceUpdate();
}
},
@ -531,6 +546,38 @@ var TimelinePanel = React.createClass({
this.setState({clientSyncState: state});
},
_readMarkerTimeout(readMarkerPosition) {
return readMarkerPosition === 0 ?
READ_MARKER_INVIEW_THRESHOLD_MS :
READ_MARKER_OUTOFVIEW_THRESHOLD_MS;
},
updateReadMarkerOnUserActivity: async function() {
const initialTimeout = this._readMarkerTimeout(this.getReadMarkerPosition());
this._readMarkerActivityTimer = new Timer(initialTimeout);
while (this._readMarkerActivityTimer) { //unset on unmount
UserActivity.timeWhileActive(this._readMarkerActivityTimer);
try {
await this._readMarkerActivityTimer.finished();
} catch(e) { continue; /* aborted */ }
// outside of try/catch to not swallow errors
this.updateReadMarker();
}
},
updateReadReceiptOnUserActivity: async function() {
this._readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS);
while (this._readReceiptActivityTimer) { //unset on unmount
UserActivity.timeWhileActive(this._readReceiptActivityTimer);
try {
await this._readReceiptActivityTimer.finished();
} catch(e) { continue; /* aborted */ }
// outside of try/catch to not swallow errors
this.sendReadReceipt();
}
},
sendReadReceipt: function() {
if (!this.refs.messagePanel) return;
if (!this.props.manageReadReceipts) return;
@ -634,10 +681,11 @@ var TimelinePanel = React.createClass({
// of the screen, so move the marker down to the bottom of the screen.
updateReadMarker: function() {
if (!this.props.manageReadMarkers) return;
if (this.getReadMarkerPosition() !== 0) {
if (this.getReadMarkerPosition() === 1) {
// the read marker is at an event below the viewport,
// we don't want to rewind it.
return;
}
// move the RM to *after* the message at the bottom of the screen. This
// avoids a problem whereby we never advance the RM if there is a huge
// message which doesn't fit on the screen.
@ -654,7 +702,6 @@ var TimelinePanel = React.createClass({
if (lastDisplayedIndex === null) {
return;
}
const lastDisplayedEvent = this.state.events[lastDisplayedIndex];
this._setReadMarker(lastDisplayedEvent.getId(),
lastDisplayedEvent.getTs());
@ -749,7 +796,6 @@ var TimelinePanel = React.createClass({
this._loadTimeline(this.state.readMarkerEventId, 0, 1/3);
},
/* update the read-up-to marker to match the read receipt
*/
forgetReadMarker: function() {
@ -822,15 +868,12 @@ var TimelinePanel = React.createClass({
canJumpToReadMarker: function() {
// 1. Do not show jump bar if neither the RM nor the RR are set.
// 2. Only show jump bar if RR !== RM. If they are the same, there are only fully
// read messages and unread messages. We already have a badge count and the bottom
// bar to jump to "live" when we have unread messages.
// 3. We want to show the bar if the read-marker is off the top of the screen.
// 4. Also, if pos === null, the event might not be paginated - show the unread bar
const pos = this.getReadMarkerPosition();
return this.state.readMarkerEventId !== null && // 1.
this.state.readMarkerEventId !== this._getCurrentReadReceipt() && // 2.
const ret = this.state.readMarkerEventId !== null && // 1.
(pos < 0 || pos === null); // 3., 4.
return ret;
},
/**
@ -917,7 +960,6 @@ var TimelinePanel = React.createClass({
}
this.sendReadReceipt();
this.updateReadMarker();
});
};
@ -1154,6 +1196,7 @@ var TimelinePanel = React.createClass({
);
return (
<MessagePanel ref="messagePanel"
room={this.props.timelineSet.room}
hidden={this.props.hidden}
backPaginating={this.state.backPaginating}
forwardPaginating={forwardPaginating}

View file

@ -0,0 +1,115 @@
/*
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 * as ContextualMenu from './ContextualMenu';
import {TopLeftMenu} from '../views/context_menus/TopLeftMenu';
import AccessibleButton from '../views/elements/AccessibleButton';
import BaseAvatar from '../views/avatars/BaseAvatar';
import MatrixClientPeg from '../../MatrixClientPeg';
import Avatar from '../../Avatar';
const AVATAR_SIZE = 28;
export default class TopLeftMenuButton extends React.Component {
static propTypes = {
collapsed: PropTypes.bool.isRequired,
};
static displayName = 'TopLeftMenuButton';
constructor() {
super();
this.state = {
menuDisplayed: false,
profileInfo: null,
};
this.onToggleMenu = this.onToggleMenu.bind(this);
}
async _getProfileInfo() {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
const profileInfo = await cli.getProfileInfo(userId);
const avatarUrl = Avatar.avatarUrlForUser(
{avatarUrl: profileInfo.avatar_url},
AVATAR_SIZE, AVATAR_SIZE, "crop");
return {
userId,
name: profileInfo.displayname,
avatarUrl,
};
}
async componentDidMount() {
try {
const profileInfo = await this._getProfileInfo();
this.setState({profileInfo});
} catch (ex) {
console.log("could not fetch profile");
console.error(ex);
}
}
render() {
const fallbackUserId = MatrixClientPeg.get().getUserId();
const profileInfo = this.state.profileInfo;
const name = profileInfo ? profileInfo.name : fallbackUserId;
let nameElement;
if (!this.props.collapsed) {
nameElement = <div className="mx_TopLeftMenuButton_name">
{ name }
</div>;
}
return (
<AccessibleButton className="mx_TopLeftMenuButton" onClick={this.onToggleMenu}>
<BaseAvatar
idName={fallbackUserId}
name={name}
url={profileInfo && profileInfo.avatarUrl}
width={AVATAR_SIZE}
height={AVATAR_SIZE}
resizeMethod="crop"
/>
{ nameElement }
<img className="mx_TopLeftMenuButton_chevron" src="img/topleft-chevron.svg" width="11" height="6" />
</AccessibleButton>
);
}
onToggleMenu(e) {
e.preventDefault();
e.stopPropagation();
const elementRect = e.currentTarget.getBoundingClientRect();
const x = elementRect.left;
const y = elementRect.top + elementRect.height;
ContextualMenu.createMenu(TopLeftMenu, {
chevronFace: "none",
left: x,
top: y,
onFinished: () => {
this.setState({ menuDisplayed: false });
},
});
this.setState({ menuDisplayed: true });
}
}

View file

@ -82,7 +82,6 @@ const SIMPLE_SETTINGS = [
{ id: "VideoView.flipVideoHorizontally" },
{ id: "TagPanel.disableTagPanel" },
{ id: "enableWidgetScreenshots" },
{ id: "RoomSubList.showEmpty" },
{ id: "pinMentionedRooms" },
{ id: "pinUnreadRooms" },
{ id: "showDeveloperTools" },
@ -129,6 +128,7 @@ const CRYPTO_SETTINGS = [
const THEMES = [
{ label: _td('Light theme'), value: 'light' },
{ label: _td('Dark theme'), value: 'dark' },
{ label: _td('2018 theme'), value: 'dharma' },
{ label: _td('Status.im theme'), value: 'status' },
];
@ -384,32 +384,8 @@ module.exports = React.createClass({
},
onLogoutClicked: function(ev) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Logout E2E Export', '', QuestionDialog, {
title: _t("Sign out"),
description:
<div>
{ _t("For security, logging out will delete any end-to-end " +
"encryption keys from this browser. If you want to be able " +
"to decrypt your conversation history from future Riot sessions, " +
"please export your room keys for safe-keeping.") }
</div>,
button: _t("Sign out"),
extraButtons: [
<button key="export" className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
{ _t("Export E2E room keys") }
</button>,
],
onFinished: (confirmed) => {
if (confirmed) {
dis.dispatch({action: 'logout'});
if (this.props.onFinished) {
this.props.onFinished();
}
}
},
});
const LogoutDialog = sdk.getComponent("dialogs.LogoutDialog");
Modal.createTrackedDialog('Logout E2E Export', '', LogoutDialog);
},
onPasswordChangeError: function(err) {
@ -859,7 +835,7 @@ module.exports = React.createClass({
SettingsStore.getLabsFeatures().forEach((featureId) => {
// TODO: this ought to be a separate component so that we don't need
// to rebind the onChange each time we render
const onChange = async(e) => {
const onChange = async (e) => {
const checked = e.target.checked;
if (featureId === "feature_lazyloading") {
const confirmed = await this._onLazyLoadChanging(checked);

View file

@ -162,6 +162,18 @@ module.exports = React.createClass({
this.setState(newState);
},
onLoginClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
},
onRegisterClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
},
showErrorDialog: function(body, title) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, {
@ -253,10 +265,10 @@ module.exports = React.createClass({
</form>
{ serverConfigSection }
{ errorText }
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
<a className="mx_Login_create" onClick={this.onLoginClick} href="#">
{ _t('Return to login screen') }
</a>
<a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#">
<a className="mx_Login_create" onClick={this.onRegisterClick} href="#">
{ _t('Create an account') }
</a>
<LanguageSelector />

View file

@ -214,7 +214,10 @@ module.exports = React.createClass({
}).done();
},
_onLoginAsGuestClick: function() {
_onLoginAsGuestClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
const self = this;
self.setState({
busy: true,
@ -297,6 +300,12 @@ module.exports = React.createClass({
});
},
onRegisterClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
},
_tryWellKnownDiscovery: async function(serverName) {
if (!serverName.trim()) {
// Nothing to discover
@ -567,7 +576,7 @@ module.exports = React.createClass({
{ errorTextSection }
{ this.componentForStep(this.state.currentFlow) }
{ serverConfig }
<a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#">
<a className="mx_Login_create" onClick={this.onRegisterClick} href="#">
{ _t('Create an account') }
</a>
{ loginAsGuestJsx }

View file

@ -363,6 +363,12 @@ module.exports = React.createClass({
}
},
onLoginClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
},
_makeRegisterRequest: function(auth) {
// Only send the bind params if we're sending username / pw params
// (Since we need to send no params at all to use the ones saved in the
@ -468,7 +474,7 @@ module.exports = React.createClass({
let signIn;
if (!this.state.doingUIAuth) {
signIn = (
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
<a className="mx_Login_create" onClick={this.onLoginClick} href="#">
{ theme === 'status' ? _t('Sign in') : _t('I already have an account') }
</a>
);

View file

@ -40,38 +40,50 @@ export default class MemberStatusMessageAvatar extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
hasStatus: this.hasStatus,
};
}
componentWillMount() {
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
}
}
componentDidMount() {
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
if (this.props.member.user) {
this.setState({message: this.props.member.user._unstable_statusMessage});
} else {
this.setState({message: ""});
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return;
}
}
componentWillUnmount() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents);
const { user } = this.props.member;
if (!user) {
return;
}
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
}
_onRoomStateEvents = (ev, state) => {
if (ev.getStateKey() !== MatrixClientPeg.get().getUserId()) return;
if (ev.getType() !== "im.vector.user_status") return;
// TODO: We should be relying on `this.props.member.user._unstable_statusMessage`
// We don't currently because the js-sdk doesn't emit a specific event for this
// change, and we don't want to race it. This should be improved when we rip out
// the im.vector.user_status stuff and replace it with a complete solution.
this.setState({message: ev.getContent()["status"]});
componentWillUmount() {
const { user } = this.props.member;
if (!user) {
return;
}
user.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
}
get hasStatus() {
const { user } = this.props.member;
if (!user) {
return false;
}
return !!user._unstable_statusMessage;
}
_onStatusMessageCommitted = () => {
// The `User` object has observed a status message change.
this.setState({
hasStatus: this.hasStatus,
});
};
_onClick = (e) => {
@ -79,42 +91,43 @@ export default class MemberStatusMessageAvatar extends React.Component {
const elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = (elementRect.left + window.pageXOffset) - (elementRect.width / 2) + 3;
const chevronOffset = 12;
let y = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
y = y - (chevronOffset + 4); // where 4 is 1/4 the height of the chevron
const x = (elementRect.left + window.pageXOffset);
const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom
const chevronOffset = (elementRect.width - chevronWidth) / 2;
const chevronMargin = 1; // Add some spacing away from target
const y = elementRect.top + window.pageYOffset - chevronMargin;
ContextualMenu.createMenu(StatusMessageContextMenu, {
chevronOffset: chevronOffset,
chevronFace: 'bottom',
left: x,
top: y,
menuWidth: 190,
menuWidth: 226,
user: this.props.member.user,
});
};
render() {
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return <MemberAvatar member={this.props.member}
width={this.props.width}
height={this.props.height}
resizeMethod={this.props.resizeMethod} />;
}
const avatar = <MemberAvatar
member={this.props.member}
width={this.props.width}
height={this.props.height}
resizeMethod={this.props.resizeMethod}
/>;
const hasStatus = this.props.member.user ? !!this.props.member.user._unstable_statusMessage : false;
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return avatar;
}
const classes = classNames({
"mx_MemberStatusMessageAvatar": true,
"mx_MemberStatusMessageAvatar_hasStatus": hasStatus,
"mx_MemberStatusMessageAvatar_hasStatus": this.state.hasStatus,
});
return <AccessibleButton onClick={this._onClick} className={classes} element="div">
<MemberAvatar member={this.props.member}
width={this.props.width}
height={this.props.height}
resizeMethod={this.props.resizeMethod} />
return <AccessibleButton className={classes}
element="div" onClick={this._onClick}
>
{avatar}
</AccessibleButton>;
}
}

View file

@ -333,7 +333,7 @@ module.exports = React.createClass({
}
return (
<div>
<div className="mx_MessageContextMenu">
{ resendButton }
{ redactButton }
{ cancelButton }

View file

@ -243,7 +243,7 @@ module.exports = React.createClass({
});
return (
<div>
<div className="mx_RoomTileContextMenu">
<div className="mx_RoomTileContextMenu_notif_picker" >
<img src="img/notif-slider.svg" width="20" height="107" />
</div>

View file

@ -18,69 +18,125 @@ import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import AccessibleButton from '../elements/AccessibleButton';
import classNames from 'classnames';
export default class StatusMessageContextMenu extends React.Component {
static propTypes = {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
// True when waiting for status change to complete.
waiting: false,
};
constructor(props, context) {
super(props, context);
this.state = {
message: props.user ? props.user._unstable_statusMessage : "",
message: this.comittedStatusMessage,
};
}
_onClearClick = async (e) => {
await MatrixClientPeg.get()._unstable_setStatusMessage("");
this.setState({message: ""});
componentWillMount() {
const { user } = this.props;
if (!user) {
return;
}
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
}
componentWillUmount() {
const { user } = this.props;
if (!user) {
return;
}
user.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
}
get comittedStatusMessage() {
return this.props.user ? this.props.user._unstable_statusMessage : "";
}
_onStatusMessageCommitted = () => {
// The `User` object has observed a status message change.
this.setState({
message: this.comittedStatusMessage,
waiting: false,
});
};
_onClearClick = (e) => {
MatrixClientPeg.get()._unstable_setStatusMessage("");
this.setState({
waiting: true,
});
};
_onSubmit = (e) => {
e.preventDefault();
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
this.setState({
waiting: true,
});
};
_onStatusChange = (e) => {
this.setState({message: e.target.value});
// The input field's value was changed.
this.setState({
message: e.target.value,
});
};
render() {
const formSubmitClasses = classNames({
"mx_StatusMessageContextMenu_submit": true,
"mx_StatusMessageContextMenu_submitFaded": !this.state.message, // no message == faded
});
const Spinner = sdk.getComponent('views.elements.Spinner');
const form = <form className="mx_StatusMessageContextMenu_form" onSubmit={this._onSubmit} autoComplete="off">
<input type="text" key="message" placeholder={_t("Set a new status...")} autoFocus={true}
className="mx_StatusMessageContextMenu_message"
value={this.state.message} onChange={this._onStatusChange} maxLength="60" />
<AccessibleButton onClick={this._onSubmit} element="div" className={formSubmitClasses}>
<img src="img/icons-checkmark.svg" width="22" height="22" />
</AccessibleButton>
let actionButton;
if (this.comittedStatusMessage) {
if (this.state.message === this.comittedStatusMessage) {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
onClick={this._onClearClick}
>
<span>{_t("Clear status")}</span>
</AccessibleButton>;
} else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
onClick={this._onSubmit}
>
<span>{_t("Update status")}</span>
</AccessibleButton>;
}
} else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message} onClick={this._onSubmit}
>
<span>{_t("Set status")}</span>
</AccessibleButton>;
}
let spinner = null;
if (this.state.waiting) {
spinner = <Spinner w="24" h="24" />;
}
const form = <form className="mx_StatusMessageContextMenu_form"
autoComplete="off" onSubmit={this._onSubmit}
>
<input type="text" className="mx_StatusMessageContextMenu_message"
key="message" placeholder={_t("Set a new status...")}
autoFocus={true} maxLength="60" value={this.state.message}
onChange={this._onStatusChange}
/>
<div className="mx_StatusMessageContextMenu_actionContainer">
{actionButton}
{spinner}
</div>
</form>;
const clearIcon = this.state.message ? "img/cancel-red.svg" : "img/cancel.svg";
const clearButton = <AccessibleButton onClick={this._onClearClick} disabled={!this.state.message}
className="mx_StatusMessageContextMenu_clear">
<img src={clearIcon} alt={_t('Clear status')} width="12" height="12"
className="mx_filterFlipColor mx_StatusMessageContextMenu_clearIcon" />
<span>{_t("Clear status")}</span>
</AccessibleButton>;
const menuClasses = classNames({
"mx_StatusMessageContextMenu": true,
"mx_StatusMessageContextMenu_hasStatus": this.state.message,
});
return <div className={menuClasses}>
return <div className="mx_StatusMessageContextMenu">
{ form }
<hr />
{ clearButton }
</div>;
}
}

View file

@ -0,0 +1,54 @@
/*
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 dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import LogoutDialog from "../dialogs/LogoutDialog";
import Modal from "../../../Modal";
export class TopLeftMenu extends React.Component {
constructor() {
super();
this.openSettings = this.openSettings.bind(this);
this.signOut = this.signOut.bind(this);
}
render() {
return <div className="mx_TopLeftMenu">
<ul className="mx_TopLeftMenu_section">
<li onClick={this.openSettings}>{_t("Settings")}</li>
</ul>
<ul className="mx_TopLeftMenu_section">
<li onClick={this.signOut}>{_t("Sign out")}</li>
</ul>
</div>;
}
openSettings() {
dis.dispatch({action: 'view_user_settings'});
this.closeMenu();
}
signOut() {
Modal.createTrackedDialog('Logout E2E Export', '', LogoutDialog);
this.closeMenu();
}
closeMenu() {
if (this.props.onFinished) this.props.onFinished();
}
}

View file

@ -389,6 +389,17 @@ module.exports = React.createClass({
const suggestedList = [];
results.forEach((result) => {
if (result.room_id) {
const client = MatrixClientPeg.get();
const room = client.getRoom(result.room_id);
if (room) {
const tombstone = room.currentState.getStateEvents('m.room.tombstone', '');
if (tombstone && tombstone.getContent() && tombstone.getContent()["replacement_room"]) {
const replacementRoom = client.getRoom(tombstone.getContent()["replacement_room"]);
// Skip rooms with tombstones where we are also aware of the replacement room.
if (replacementRoom) return;
}
}
suggestedList.push({
addressType: 'mx-room-id',
address: result.room_id,

View file

@ -36,7 +36,7 @@ export default class ChangelogDialog extends React.Component {
for (let i=0; i<REPOS.length; i++) {
const oldVersion = version2[2*i];
const newVersion = version[2*i];
const url = `https://api.github.com/repos/${REPOS[i]}/compare/${oldVersion}...${newVersion}`;
const url = `https://riot.im/github/repos/${REPOS[i]}/compare/${oldVersion}...${newVersion}`;
request(url, (err, response, body) => {
if (response.statusCode < 200 || response.statusCode >= 300) {
this.setState({ [REPOS[i]]: response.statusText });

View file

@ -0,0 +1,164 @@
/*
Copyright 2018, 2019 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 Modal from '../../../Modal';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import SettingsStore from "../../../settings/SettingsStore";
export default class LogoutDialog extends React.Component {
constructor() {
super();
this._onSettingsLinkClick = this._onSettingsLinkClick.bind(this);
this._onExportE2eKeysClicked = this._onExportE2eKeysClicked.bind(this);
this._onFinished = this._onFinished.bind(this);
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
}
_onSettingsLinkClick() {
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
}
_onExportE2eKeysClicked() {
Modal.createTrackedDialogAsync('Export E2E Keys', '',
import('../../../async-components/views/dialogs/ExportE2eKeysDialog'),
{
matrixClient: MatrixClientPeg.get(),
},
);
}
_onFinished(confirmed) {
if (confirmed) {
dis.dispatch({action: 'logout'});
}
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
}
_onSetRecoveryMethodClick() {
Modal.createTrackedDialogAsync('Key Backup', 'Key Backup',
import('../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog'),
);
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
}
_onLogoutConfirm() {
dis.dispatch({action: 'logout'});
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
}
render() {
let description;
if (SettingsStore.isFeatureEnabled("feature_keybackup")) {
description = <div>
<p>{_t(
"When you log out, you'll lose your secure message history. To prevent " +
"this, set up a recovery method.",
)}</p>
<p>{_t(
"Alternatively, advanced users can also manually export encryption keys in " +
"<a>Settings</a> before logging out.", {},
{
a: sub => <a href='#/settings' onClick={this._onSettingsLinkClick}>{sub}</a>,
},
)}</p>
</div>;
} else {
description = <div>{_t(
"For security, logging out will delete any end-to-end " +
"encryption keys from this browser. If you want to be able " +
"to decrypt your conversation history from future Riot sessions, " +
"please export your room keys for safe-keeping.",
)}</div>;
}
if (SettingsStore.isFeatureEnabled("feature_keybackup")) {
if (!MatrixClientPeg.get().getKeyBackupEnabled()) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
// Not quite a standard question dialog as the primary button cancels
// the action and does something else instead, whilst non-default button
// confirms the action.
return (<BaseDialog
title={_t("Warning!")}
contentId='mx_Dialog_content'
hasCancel={false}
onFinsihed={this._onFinished}
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
{ description }
</div>
<DialogButtons primaryButton={_t('Set a Recovery Method')}
hasCancel={false}
onPrimaryButtonClick={this._onSetRecoveryMethodClick}
focus={true}
>
<button onClick={this._onLogoutConfirm}>
{_t("I understand, log out without")}
</button>
</DialogButtons>
</BaseDialog>);
} else {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
return (<QuestionDialog
hasCancelButton={true}
title={_t("Sign out")}
// TODO: This is made up by me and would need to also mention verifying
// once you can restorew a backup by verifying a device
description={_t(
"When signing in again, you can access encrypted chat history by " +
"restoring your key backup. You'll need your recovery key.",
)}
button={_t("Sign out")}
onFinished={this._onFinished}
/>);
}
} else {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
return (<QuestionDialog
hasCancelButton={true}
title={_t("Sign out")}
description={description}
button={_t("Sign out")}
extraButtons={[
(<button key="export" className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
{ _t("Export E2E room keys") }
</button>),
]}
onFinished={this._onFinished}
/>);
}
}
}

View file

@ -0,0 +1,51 @@
/*
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 QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
export default (props) => {
const existingIssuesUrl = "https://github.com/vector-im/riot-web/issues" +
"?q=is%3Aopen+is%3Aissue+label%3Aredesign+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/riot-web/issues/new" +
"?assignees=&labels=redesign&template=redesign_issue.md&title=";
const description1 =
_t("Thanks for testing the Riot Redesign. " +
"If you run into any bugs or visual issues, " +
"please let us know on GitHub.");
const description2 = _t("To help avoid duplicate issues, " +
"please <existingIssuesLink>view existing issues</existingIssuesLink> " +
"first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> " +
"if you can't find it.", {},
{
existingIssuesLink: (sub) => {
return <a target="_blank" rel="noreferrer noopener" href={existingIssuesUrl}>{ sub }</a>;
},
newIssueLink: (sub) => {
return <a target="_blank" rel="noreferrer noopener" href={newIssueUrl}>{ sub }</a>;
},
});
return (<QuestionDialog
hasCancelButton={false}
title={_t("Report bugs & give feedback")}
description={<div><p>{description1}</p><p>{description2}</p></div>}
button={_t("Go back")}
onFinished={props.onFinished}
/>);
};

View file

@ -30,7 +30,8 @@ export default React.createClass({
action: PropTypes.string.isRequired,
mouseOverAction: PropTypes.string,
label: PropTypes.string.isRequired,
iconPath: PropTypes.string.isRequired,
iconPath: PropTypes.string,
className: PropTypes.string,
},
getDefaultProps: function() {
@ -72,14 +73,23 @@ export default React.createClass({
tooltip = <RoomTooltip className="mx_RoleButton_tooltip" label={this.props.label} />;
}
const icon = this.props.iconPath ?
(<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) :
undefined;
const classNames = ["mx_RoleButton"];
if (this.props.className) {
classNames.push(this.props.className);
}
return (
<AccessibleButton className="mx_RoleButton"
<AccessibleButton className={classNames.join(" ")}
onClick={this._onClick}
onMouseEnter={this._onMouseEnter}
onMouseLeave={this._onMouseLeave}
aria-label={this.props.label}
>
<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />
{ icon }
{ tooltip }
</AccessibleButton>
);

View file

@ -22,18 +22,16 @@ import { _t } from '../../../languageHandler';
const GroupsButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton');
return (
<ActionButton action="view_my_groups"
<ActionButton className="mx_GroupsButton" action="view_my_groups"
label={_t("Communities")}
iconPath="img/icons-groups.svg"
size={props.size}
tooltip={props.tooltip}
tooltip={true}
/>
);
};
GroupsButton.propTypes = {
size: PropTypes.string,
tooltip: PropTypes.bool,
};
export default GroupsButton;

View file

@ -91,7 +91,7 @@ export default class ManageIntegsButton extends React.Component {
integrationsButton = (
<AccessibleButton className={integrationsButtonClasses} onClick={this.onManageIntegrations} title={_t('Manage Integrations')}>
<TintableSvg src="img/icons-apps.svg" width="35" height="35" />
<TintableSvg src="img/feather-icons/grid.svg" width="20" height="20" />
{ integrationsWarningTriangle }
{ integrationsErrorPopup }
</AccessibleButton>

View file

@ -271,7 +271,7 @@ const Pill = React.createClass({
break;
}
const classes = classNames(pillClass, {
const classes = classNames("mx_Pill", pillClass, {
"mx_UserPill_me": userId === MatrixClientPeg.get().credentials.userId,
"mx_UserPill_selected": this.props.isSelected,
});

View file

@ -0,0 +1,27 @@
import React from 'react'; // eslint-disable-line no-unused-vars
import PropTypes from 'prop-types';
//see src/resizer for the actual resizing code, this is just the DOM for the resize handle
const ResizeHandle = (props) => {
const classNames = ['mx_ResizeHandle'];
if (props.vertical) {
classNames.push('mx_ResizeHandle_vertical');
} else {
classNames.push('mx_ResizeHandle_horizontal');
}
if (props.reverse) {
classNames.push('mx_ResizeHandle_reverse');
}
return (
<div className={classNames.join(' ')} data-id={props.id}><div /></div>
);
};
ResizeHandle.propTypes = {
vertical: PropTypes.bool,
reverse: PropTypes.bool,
id: PropTypes.string,
};
export default ResizeHandle;

View file

@ -157,7 +157,7 @@ export default React.createClass({
const RoomTooltip = sdk.getComponent('rooms.RoomTooltip');
const profile = this.state.profile || {};
const name = profile.name || this.props.tag;
const avatarHeight = 35;
const avatarHeight = 40;
const httpUrl = profile.avatarUrl ? this.context.matrixClient.mxcUrlToHttp(
profile.avatarUrl, avatarHeight, avatarHeight, "crop",

View file

@ -29,7 +29,6 @@ var TintableSvg = React.createClass({
width: PropTypes.string.isRequired,
height: PropTypes.string.isRequired,
className: PropTypes.string,
forceColors: PropTypes.arrayOf(PropTypes.string),
},
statics: {
@ -51,12 +50,6 @@ var TintableSvg = React.createClass({
delete TintableSvg.mounts[this.id];
},
componentDidUpdate: function(prevProps, prevState) {
if (prevProps.forceColors !== this.props.forceColors) {
this.calcAndApplyFixups(this.refs.svgContainer);
}
},
tint: function() {
// TODO: only bother running this if the global tint settings have changed
// since we loaded!
@ -64,13 +57,8 @@ var TintableSvg = React.createClass({
},
onLoad: function(event) {
this.calcAndApplyFixups(event.target);
},
calcAndApplyFixups: function(target) {
if (!target) return;
// console.log("TintableSvg.calcAndApplyFixups for " + this.props.src);
this.fixups = Tinter.calcSvgFixups([target], this.props.forceColors);
// console.log("TintableSvg.onLoad for " + this.props.src);
this.fixups = Tinter.calcSvgFixups([event.target]);
Tinter.applySvgFixups(this.fixups);
},
@ -83,7 +71,6 @@ var TintableSvg = React.createClass({
height={this.props.height}
onLoad={this.onLoad}
tabIndex="-1"
ref="svgContainer"
/>
);
},

View file

@ -17,8 +17,13 @@ limitations under the License.
import React from 'react';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import GroupStore from '../../../stores/GroupStore';
import PropTypes from 'prop-types';
import { showGroupInviteDialog } from '../../../GroupAddressPicker';
import AccessibleButton from '../elements/AccessibleButton';
import TintableSvg from '../elements/TintableSvg';
import RightPanel from '../../structures/RightPanel';
const INITIAL_LOAD_NUM_MEMBERS = 30;
@ -154,6 +159,16 @@ export default React.createClass({
</TruncatedList>;
},
onInviteToGroupButtonClick() {
showGroupInviteDialog(this.props.groupId).then(() => {
dis.dispatch({
action: 'view_right_panel_phase',
phase: RightPanel.Phase.GroupMemberList,
groupId: this.props.groupId,
});
});
},
render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.state.fetching || this.state.fetchingInvitedMembers) {
@ -164,11 +179,9 @@ export default React.createClass({
}
const inputBox = (
<form autoComplete="off">
<input className="mx_GroupMemberList_query" id="mx_GroupMemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter community members')} />
</form>
<input className="mx_GroupMemberList_query mx_textinput" id="mx_GroupMemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter community members')} autoComplete="off" />
);
const joined = this.state.members ? <div className="mx_MemberList_joined">
@ -192,13 +205,29 @@ export default React.createClass({
)
}
</div> : <div />;
let inviteButton;
if (GroupStore.isUserPrivileged(this.props.groupId)) {
inviteButton = (
<AccessibleButton
className="mx_RightPanel_invite"
onClick={this.onInviteToGroupButtonClick}
>
<div className="mx_RightPanel_icon" >
<TintableSvg src="img/icon-invite-people.svg" width="18" height="14" />
</div>
<div className="mx_RightPanel_message">{ _t('Invite to this community') }</div>
</AccessibleButton>);
}
return (
<div className="mx_MemberList">
{ inputBox }
{ inviteButton }
<GeminiScrollbarWrapper autoshow={true}>
{ joined }
{ invited }
</GeminiScrollbarWrapper>
{ inputBox }
</div>
);
},

View file

@ -18,6 +18,9 @@ import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import GroupStore from '../../../stores/GroupStore';
import PropTypes from 'prop-types';
import { showGroupAddRoomDialog } from '../../../GroupAddressPicker';
import AccessibleButton from '../elements/AccessibleButton';
import TintableSvg from '../elements/TintableSvg';
const INITIAL_LOAD_NUM_ROOMS = 30;
@ -90,6 +93,12 @@ export default React.createClass({
this.setState({ searchQuery: ev.target.value });
},
onAddRoomToGroupButtonClick() {
showGroupAddRoomDialog(this.props.groupId).then(() => {
this.forceUpdate();
});
},
makeGroupRoomTiles: function(query) {
const GroupRoomTile = sdk.getComponent("groups.GroupRoomTile");
query = (query || "").toLowerCase();
@ -120,25 +129,38 @@ export default React.createClass({
return null;
}
let inviteButton;
if (GroupStore.isUserPrivileged(this.props.groupId)) {
inviteButton = (
<AccessibleButton
className="mx_RightPanel_invite"
onClick={this.onAddRoomToGroupButtonClick}
>
<div className="mx_RightPanel_icon" >
<TintableSvg src="img/icons-room-add.svg" width="18" height="14" />
</div>
<div className="mx_RightPanel_message">{ _t('Add rooms to this community') }</div>
</AccessibleButton>
);
}
const inputBox = (
<form autoComplete="off">
<input className="mx_GroupRoomList_query" id="mx_GroupRoomList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter community rooms')} />
</form>
<input className="mx_GroupRoomList_query mx_textinput" id="mx_GroupRoomList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter community rooms')} autoComplete="off" />
);
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const TruncatedList = sdk.getComponent("elements.TruncatedList");
return (
<div className="mx_GroupRoomList">
{ inputBox }
{ inviteButton }
<GeminiScrollbarWrapper autoshow={true} className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
<TruncatedList className="mx_GroupRoomList_wrapper" truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}>
{ this.makeGroupRoomTiles(this.state.searchQuery) }
</TruncatedList>
</GeminiScrollbarWrapper>
{ inputBox }
</div>
);
},

View file

@ -56,6 +56,6 @@ export default class DateSeparator extends React.Component {
}
render() {
return <h2 className="mx_DateSeparator">{ this.getLabel() }</h2>;
return <h2 className="mx_DateSeparator"><hr /><date>{ this.getLabel() }</date><hr /></h2>;
}
}

View file

@ -23,6 +23,7 @@ import sdk from '../../../index';
import Flair from '../elements/Flair.js';
import FlairStore from '../../../stores/FlairStore';
import { _t } from '../../../languageHandler';
import {hashCode} from '../../../utils/FormattingUtils';
export default React.createClass({
displayName: 'SenderProfile',
@ -96,6 +97,7 @@ export default React.createClass({
render() {
const EmojiText = sdk.getComponent('elements.EmojiText');
const {mxEvent} = this.props;
const colorNumber = hashCode(mxEvent.getSender()) % 8;
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
const {msgtype} = mxEvent.getContent();
@ -119,7 +121,7 @@ export default React.createClass({
// Name + flair
const nameFlair = <span>
<span className="mx_SenderProfile_name">
<span className={`mx_SenderProfile_name mx_SenderProfile_color${colorNumber}`}>
{ nameElem }
</span>
{ flair }

View file

@ -0,0 +1,80 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector 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.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher';
import HeaderButton from './HeaderButton';
import HeaderButtons from './HeaderButtons';
import RightPanel from '../../structures/RightPanel';
export default class GroupHeaderButtons extends HeaderButtons {
constructor(props) {
super(props, RightPanel.Phase.GroupMemberList);
}
onAction(payload) {
super.onAction(payload);
if (payload.action === "view_user") {
dis.dispatch({
action: 'show_right_panel',
});
if (payload.member) {
this.setPhase(RightPanel.Phase.RoomMemberInfo, {member: payload.member});
} else {
this.setPhase(RightPanel.Phase.GroupMemberList);
}
} else if (payload.action === "view_group") {
this.setPhase(RightPanel.Phase.GroupMemberList);
} else if (payload.action === "view_group_room") {
this.setPhase(RightPanel.Phase.GroupRoomInfo, {groupRoomId: payload.groupRoomId, groupId: payload.groupId});
} else if (payload.action === "view_group_room_list") {
this.setPhase(RightPanel.Phase.GroupRoomList);
} else if (payload.action === "view_group_member_list") {
this.setPhase(RightPanel.Phase.GroupMemberList);
} else if (payload.action === "view_group_user") {
this.setPhase(RightPanel.Phase.GroupMemberInfo, {member: payload.member});
}
}
renderButtons() {
const groupPhases = [
RightPanel.Phase.GroupMemberInfo,
RightPanel.Phase.GroupMemberList,
];
const roomPhases = [
RightPanel.Phase.GroupRoomList,
RightPanel.Phase.GroupRoomInfo,
];
return [
<HeaderButton key="_groupMembersButton" title={_t('Members')} iconSrc="img/icons-people.svg"
isHighlighted={this.isPhase(groupPhases)}
clickPhase={RightPanel.Phase.GroupMemberList}
analytics={['Right Panel', 'Group Member List Button', 'click']}
/>,
<HeaderButton key="_roomsButton" title={_t('Rooms')} iconSrc="img/icons-room-nobg.svg"
isHighlighted={this.isPhase(roomPhases)}
clickPhase={RightPanel.Phase.GroupRoomList}
analytics={['Right Panel', 'Group Room List Button', 'click']}
/>,
];
}
}

View file

@ -0,0 +1,75 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector 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.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import dis from '../../../dispatcher';
import Analytics from '../../../Analytics';
import AccessibleButton from '../elements/AccessibleButton';
import TintableSvg from '../elements/TintableSvg';
export default 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,
fromHeader: true,
});
}
render() {
const classes = classNames({
mx_RightPanel_headerButton: true,
mx_RightPanel_headerButton_highlight: this.props.isHighlighted,
});
return <AccessibleButton
aria-label={this.props.title}
aria-expanded={this.props.isHighlighted}
title={this.props.title}
className={classes}
onClick={this.onClick} >
<TintableSvg src={this.props.iconSrc} width="20" height="20" />
</AccessibleButton>;
}
}
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,
};

View file

@ -0,0 +1,100 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector 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.
*/
import React from 'react';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
export default class HeaderButtons extends React.Component {
constructor(props, initialPhase) {
super(props);
this.state = {
phase: props.collapsedRhs ? null : initialPhase,
isUserPrivilegedInGroup: null,
};
this.onAction = this.onAction.bind(this);
}
componentWillMount() {
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
}
setPhase(phase, extras) {
// TODO: delay?
dis.dispatch(Object.assign({
action: 'view_right_panel_phase',
phase: phase,
}, extras));
}
isPhase(phases) {
if (this.props.collapsedRhs) {
return false;
}
if (Array.isArray(phases)) {
return phases.includes(this.state.phase);
} else {
return phases === this.state.phase;
}
}
onAction(payload) {
if (payload.action === "view_right_panel_phase") {
// only actions coming from header buttons should collapse the right panel
if (this.state.phase === payload.phase && payload.fromHeader) {
dis.dispatch({
action: 'hide_right_panel',
});
this.setState({
phase: null,
});
} else {
if (this.props.collapsedRhs && payload.fromHeader) {
dis.dispatch({
action: 'show_right_panel',
});
// emit payload again as the RightPanel didn't exist up
// till show_right_panel, just without the fromHeader flag
// as that would hide the right panel again
dis.dispatch(Object.assign({}, payload, {fromHeader: false}));
}
this.setState({
phase: payload.phase,
});
}
}
}
render() {
// inline style as this will be swapped around in future commits
return <div style={{display: 'flex'}}>
{ this.renderButtons() }
</div>;
}
}
HeaderButtons.propTypes = {
collapsedRhs: PropTypes.bool,
};

View file

@ -0,0 +1,72 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector 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.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher';
import HeaderButton from './HeaderButton';
import HeaderButtons from './HeaderButtons';
import RightPanel from '../../structures/RightPanel';
export default class RoomHeaderButtons extends HeaderButtons {
constructor(props) {
super(props, RightPanel.Phase.RoomMemberList);
}
onAction(payload) {
super.onAction(payload);
if (payload.action === "view_user") {
dis.dispatch({
action: 'show_right_panel',
});
if (payload.member) {
this.setPhase(RightPanel.Phase.RoomMemberInfo, {member: payload.member});
} else {
this.setPhase(RightPanel.Phase.RoomMemberList);
}
} else if (payload.action === "view_room") {
this.setPhase(RightPanel.Phase.RoomMemberList);
}
}
renderButtons() {
const membersPhases = [
RightPanel.Phase.RoomMemberList,
RightPanel.Phase.RoomMemberInfo,
];
return [
<HeaderButton key="_membersButton" title={_t('Members')} iconSrc="img/feather-icons/user.svg"
isHighlighted={this.isPhase(membersPhases)}
clickPhase={RightPanel.Phase.RoomMemberList}
analytics={['Right Panel', 'Member List Button', 'click']}
/>,
<HeaderButton key="_filesButton" title={_t('Files')} iconSrc="img/feather-icons/files.svg"
isHighlighted={this.isPhase(RightPanel.Phase.FilePanel)}
clickPhase={RightPanel.Phase.FilePanel}
analytics={['Right Panel', 'File List Button', 'click']}
/>,
<HeaderButton key="_notifsButton" title={_t('Notifications')} iconSrc="img/feather-icons/notifications.svg"
isHighlighted={this.isPhase(RightPanel.Phase.NotificationPanel)}
clickPhase={RightPanel.Phase.NotificationPanel}
analytics={['Right Panel', 'Notification List Button', 'click']}
/>,
];
}
}

View file

@ -190,6 +190,10 @@ module.exports = React.createClass({
/>);
});
if (apps.length == 0) {
return <div></div>;
}
let addWidget;
if (this.props.showApps &&
this._canUserModify()

View file

@ -23,6 +23,9 @@ import dis from "../../../dispatcher";
import ObjectUtils from '../../../ObjectUtils';
import AppsDrawer from './AppsDrawer';
import { _t } from '../../../languageHandler';
import classNames from 'classnames';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore";
module.exports = React.createClass({
@ -51,6 +54,7 @@ module.exports = React.createClass({
// a callback which is called when the content of the aux panel changes
// content in a way that is likely to make it change size.
onResize: PropTypes.func,
fullHeight: PropTypes.bool,
},
defaultProps: {
@ -58,6 +62,22 @@ module.exports = React.createClass({
hideAppsDrawer: false,
},
getInitialState: function() {
return { counters: this._computeCounters() };
},
componentDidMount: function() {
const cli = MatrixClientPeg.get();
cli.on("RoomState.events", this._rateLimitedUpdate);
},
componentWillUnmount: function() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this._rateLimitedUpdate);
}
},
shouldComponentUpdate: function(nextProps, nextState) {
return (!ObjectUtils.shallowEqual(this.props, nextProps) ||
!ObjectUtils.shallowEqual(this.state, nextState));
@ -80,6 +100,43 @@ module.exports = React.createClass({
ev.preventDefault();
},
_rateLimitedUpdate: new RateLimitedFunc(function() {
if (SettingsStore.isFeatureEnabled("feature_state_counters")) {
this.setState({counters: this._computeCounters()});
}
}, 500),
_computeCounters: function() {
let counters = [];
if (this.props.room && SettingsStore.isFeatureEnabled("feature_state_counters")) {
const stateEvs = this.props.room.currentState.getStateEvents('re.jki.counter');
stateEvs.sort((a, b) => {
return a.getStateKey() < b.getStateKey();
});
stateEvs.forEach((ev, idx) => {
const title = ev.getContent().title;
const value = ev.getContent().value;
const link = ev.getContent().link;
const severity = ev.getContent().severity || "normal";
const stateKey = ev.getStateKey();
if (title && value && severity) {
counters.push({
"title": title,
"value": value,
"link": link,
"severity": severity,
"stateKey": stateKey
})
}
});
}
return counters;
},
render: function() {
const CallView = sdk.getComponent("voip.CallView");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
@ -143,8 +200,70 @@ module.exports = React.createClass({
hide={this.props.hideAppsDrawer}
/>;
let stateViews = null;
if (this.state.counters && SettingsStore.isFeatureEnabled("feature_state_counters")) {
let counters = [];
this.state.counters.forEach((counter, idx) => {
const title = counter.title;
const value = counter.value;
const link = counter.link;
const severity = counter.severity;
const stateKey = counter.stateKey;
if (title && value && severity) {
let span = <span>{ title }: { value }</span>
if (link) {
span = (
<a href={link} target="_blank" rel="noopener">
{ span }
</a>
);
}
span = (
<span
className="m_RoomView_auxPanel_stateViews_span"
data-severity={severity}
key={ "x-" + stateKey }
>
{span}
</span>
);
counters.push(span);
counters.push(
<span
className="m_RoomView_auxPanel_stateViews_delim"
key={"delim" + idx}
> </span>
);
}
});
if (counters.length > 0) {
counters.pop(); // remove last deliminator
stateViews = (
<div className="m_RoomView_auxPanel_stateViews">
{ counters }
</div>
);
}
}
const classes = classNames({
"mx_RoomView_auxPanel": true,
"mx_RoomView_auxPanel_fullHeight": this.props.fullHeight,
});
const style = {};
if (!this.props.fullHeight) {
style.maxHeight = this.props.maxHeight;
}
return (
<div className="mx_RoomView_auxPanel" style={{maxHeight: this.props.maxHeight}} >
<div className={classes} style={style} >
{ stateViews }
{ appsDrawer }
{ fileDropTarget }
{ callView }

View file

@ -1,65 +0,0 @@
/*
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 '../../../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 <div>
<Draggable
key={props.room.roomId}
draggableId={props.tagName + '_' + props.room.roomId}
index={props.index}
type="draggable-RoomTile"
>
{ (provided, snapshot) => {
return (
<div>
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className={this.getClassName(snapshot.isDragging)}>
<RoomTile {...props} />
</div>
</div>
{ provided.placeholder }
</div>
);
} }
</Draggable>
</div>;
}
}

View file

@ -135,7 +135,6 @@ const EntityTile = React.createClass({
}
nameEl = (
<div className="mx_EntityTile_details">
<img className="mx_EntityTile_chevron" src="img/member_chevron.png" width="8" height="12" />
<EmojiText element="div" className={nameClasses} dir="auto">
{ name }
</EmojiText>

View file

@ -62,6 +62,7 @@ const stateEventTileTypes = {
'm.room.pinned_events': 'messages.TextualEvent',
'm.room.server_acl': 'messages.TextualEvent',
'im.vector.modular.widgets': 'messages.TextualEvent',
'm.room.tombstone': 'messages.TextualEvent',
};
function getHandlerTile(ev) {

View file

@ -714,7 +714,7 @@ module.exports = withMatrixClient(React.createClass({
if (!member || !member.membership || member.membership === 'leave') {
const roomId = member && member.roomId ? member.roomId : RoomViewStore.getRoomId();
const onInviteUserButton = async() => {
const onInviteUserButton = async () => {
try {
// We use a MultiInviter to re-use the invite logic, even though
// we're only inviting one user.
@ -947,38 +947,49 @@ module.exports = withMatrixClient(React.createClass({
</div>;
}
const avatarUrl = this.props.member.getMxcAvatarUrl();
let avatarElement;
if (avatarUrl) {
const httpUrl = this.props.matrixClient.mxcUrlToHttp(avatarUrl, 800, 800);
avatarElement = <div className="mx_MemberInfo_avatar">
<img src={httpUrl} />
</div>
}
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const EmojiText = sdk.getComponent('elements.EmojiText');
return (
<div className="mx_MemberInfo">
<GeminiScrollbarWrapper autoshow={true}>
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this.onCancel}>
<img src="img/cancel.svg" width="18" height="18" className="mx_filterFlipColor" alt={_t('Close')} />
</AccessibleButton>
<div className="mx_MemberInfo_avatar">
<MemberAvatar onClick={this.onMemberAvatarClick} member={this.props.member} width={48} height={48} />
<div className="mx_MemberInfo_name">
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this.onCancel}>
<img src="img/minimise.svg" width="10" height="16" className="mx_filterFlipColor" alt={_t('Close')} />
</AccessibleButton>
<EmojiText element="h2">{ memberName }</EmojiText>
</div>
{ avatarElement }
<div className="mx_MemberInfo_container">
<EmojiText element="h2">{ memberName }</EmojiText>
<div className="mx_MemberInfo_profile">
<div className="mx_MemberInfo_profileField">
{ this.props.member.userId }
<div className="mx_MemberInfo_profile">
<div className="mx_MemberInfo_profileField">
{ this.props.member.userId }
</div>
{ roomMemberDetails }
</div>
{ roomMemberDetails }
</div>
<GeminiScrollbarWrapper autoshow={true} className="mx_MemberInfo_scrollContainer">
<div className="mx_MemberInfo_container">
{ this._renderUserOptions() }
{ this._renderUserOptions() }
{ adminTools }
{ adminTools }
{ startChat }
{ startChat }
{ this._renderDevices() }
{ this._renderDevices() }
{ spinner }
</GeminiScrollbarWrapper>
{ spinner }
</div>
</GeminiScrollbarWrapper>
</div>
);
},

View file

@ -19,6 +19,7 @@ limitations under the License.
import React from 'react';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher';
const MatrixClientPeg = require("../../../MatrixClientPeg");
const sdk = require('../../../index');
const rate_limited_func = require('../../../ratelimitedfunc');
@ -67,7 +68,9 @@ module.exports = React.createClass({
// We listen for changes to the lastPresenceTs which is essentially
// listening for all presence events (we display most of not all of
// the information contained in presence events).
cli.on("User.lastPresenceTs", this.onUserLastPresenceTs);
cli.on("User.lastPresenceTs", this.onUserPresenceChange);
cli.on("User.presence", this.onUserPresenceChange);
cli.on("User.currentlyActive", this.onUserPresenceChange);
// cli.on("Room.timeline", this.onRoomTimeline);
},
@ -80,7 +83,9 @@ module.exports = React.createClass({
cli.removeListener("Room.myMembership", this.onMyMembership);
cli.removeListener("RoomState.events", this.onRoomStateEvent);
cli.removeListener("Room", this.onRoom);
cli.removeListener("User.lastPresenceTs", this.onUserLastPresenceTs);
cli.removeListener("User.lastPresenceTs", this.onUserPresenceChange);
cli.removeListener("User.presence", this.onUserPresenceChange);
cli.removeListener("User.currentlyActive", this.onUserPresenceChange);
}
// cancel any pending calls to the rate_limited_funcs
@ -131,12 +136,12 @@ module.exports = React.createClass({
};
},
onUserLastPresenceTs(event, user) {
onUserPresenceChange(event, user) {
// Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile
// evar attaching their own listener.
// console.log("explicit presence from " + user.userId);
// ever attaching their own listener.
const tile = this.refs[user.userId];
// console.log(`Got presence update for ${user.userId}. hasTile=${!!tile}`);
if (tile) {
this._updateList(); // reorder the membership list
}
@ -180,6 +185,10 @@ module.exports = React.createClass({
},
_updateList: new rate_limited_func(function() {
this._updateListNow();
}, 500),
_updateListNow: function() {
// console.log("Updating memberlist");
const newState = {
loading: false,
@ -188,7 +197,7 @@ module.exports = React.createClass({
newState.filteredJoinedMembers = this._filterMembers(newState.members, 'join', this.state.searchQuery);
newState.filteredInvitedMembers = this._filterMembers(newState.members, 'invite', this.state.searchQuery);
this.setState(newState);
}, 500),
},
getMembersWithUser: function() {
if (!this.props.roomId) return [];
@ -266,7 +275,8 @@ module.exports = React.createClass({
if (!member) {
return "(null)";
} else {
return "(" + member.name + ", " + member.powerLevel + ", " + member.user.lastActiveAgo + ", " + member.user.currentlyActive + ")";
const u = member.user;
return "(" + member.name + ", " + member.powerLevel + ", " + (u ? u.lastActiveAgo : "<null>") + ", " + (u ? u.getLastActiveTs() : "<null>") + ", " + (u ? u.currentlyActive : "<null>") + ", " + (u ? u.presence : "<null>") + ")";
}
},
@ -274,48 +284,59 @@ module.exports = React.createClass({
// returns 0 if a and b are equivalent in ordering
// returns positive if a comes after b.
memberSort: function(memberA, memberB) {
// order by last active, with "active now" first.
// ...and then by power
// ...and then alphabetically.
// We could tiebreak instead by "last recently spoken in this room" if we wanted to.
// order by presence, with "active now" first.
// ...and then by power level
// ...and then by last active
// ...and then alphabetically.
// We could tiebreak instead by "last recently spoken in this room" if we wanted to.
const userA = memberA.user;
const userB = memberB.user;
// console.log(`Comparing userA=${this.memberString(memberA)} userB=${this.memberString(memberB)}`);
// if (!userA || !userB) {
// console.log("comparing " + memberA.name + " user=" + memberA.user + " with " + memberB.name + " user=" + memberB.user);
// }
const userA = memberA.user;
const userB = memberB.user;
if (!userA && !userB) return 0;
if (userA && !userB) return -1;
if (!userA && userB) return 1;
// if (!userA) console.log("!! MISSING USER FOR A-SIDE: " + memberA.name + " !!");
// if (!userB) console.log("!! MISSING USER FOR B-SIDE: " + memberB.name + " !!");
// console.log("comparing " + this.memberString(memberA) + " and " + this.memberString(memberB));
if (!userA && !userB) return 0;
if (userA && !userB) return -1;
if (!userA && userB) return 1;
if ((userA.currentlyActive && userB.currentlyActive) || !this._showPresence) {
// console.log(memberA.name + " and " + memberB.name + " are both active");
if (memberA.powerLevel === memberB.powerLevel) {
// console.log(memberA + " and " + memberB + " have same power level");
if (memberA.name && memberB.name) {
// console.log("comparing names: " + memberA.name + " and " + memberB.name);
const nameA = memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name;
const nameB = memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name;
return nameA.localeCompare(nameB);
} else {
return 0;
}
} else {
// console.log("comparing power: " + memberA.powerLevel + " and " + memberB.powerLevel);
return memberB.powerLevel - memberA.powerLevel;
}
// First by presence
if (this._showPresence) {
const convertPresence = (p) => p === 'unavailable' ? 'online' : p;
const presenceIndex = p => {
const order = ['active', 'online', 'offline'];
const idx = order.indexOf(convertPresence(p));
return idx === -1 ? order.length : idx; // unknown states at the end
};
const idxA = presenceIndex(userA.currentlyActive ? 'active' : userA.presence);
const idxB = presenceIndex(userB.currentlyActive ? 'active' : userB.presence);
// console.log(`userA_presenceGroup=${idxA} userB_presenceGroup=${idxB}`);
if (idxA !== idxB) {
// console.log("Comparing on presence group - returning");
return idxA - idxB;
}
}
if (userA.currentlyActive && !userB.currentlyActive) return -1;
if (!userA.currentlyActive && userB.currentlyActive) return 1;
// Second by power level
if (memberA.powerLevel !== memberB.powerLevel) {
// console.log("Comparing on power level - returning");
return memberB.powerLevel - memberA.powerLevel;
}
// For now, let's just order things by timestamp. It's really annoying
// that a user disappears from sight just because they temporarily go offline
// Third by last active
if (this._showPresence && userA.getLastActiveTs() !== userB.getLastActiveTs()) {
// console.log("Comparing on last active timestamp - returning");
return userB.getLastActiveTs() - userA.getLastActiveTs();
}
// Fourth by name (alphabetical)
const nameA = memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name;
const nameB = memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name;
// console.log(`Comparing userA_name=${nameA} against userB_name=${nameB} - returning`);
return nameA.localeCompare(nameB);
},
onSearchQueryChanged: function(ev) {
@ -420,42 +441,59 @@ module.exports = React.createClass({
const TruncatedList = sdk.getComponent("elements.TruncatedList");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
let invitedSection = null;
if (this._getChildCountInvited() > 0) {
invitedSection = (
<div className="mx_MemberList_invited">
<h2>{ _t("Invited") }</h2>
<div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAtInvited}
createOverflowElement={this._createOverflowTileInvited}
getChildren={this._getChildrenInvited}
getChildCount={this._getChildCountInvited}
/>
</div>
</div>
);
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
let inviteButton;
if (room && room.getMyMembership() === 'join') {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
inviteButton =
<AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick}>
<span>{ _t('Invite to this room') }</span>
</AccessibleButton>;
}
const inputBox = (
<form autoComplete="off">
<input className="mx_MemberList_query" id="mx_MemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter room members')} />
</form>
);
let invitedHeader;
let invitedSection;
if (this._getChildCountInvited() > 0) {
invitedHeader = <h2>{ _t("Invited") }</h2>;
invitedSection = <TruncatedList className="mx_MemberList_section mx_MemberList_invited" truncateAt={this.state.truncateAtInvited}
createOverflowElement={this._createOverflowTileInvited}
getChildren={this._getChildrenInvited}
getChildCount={this._getChildCountInvited}
/>;
}
return (
<div className="mx_MemberList">
{ inputBox }
<GeminiScrollbarWrapper autoshow={true} className="mx_MemberList_joined">
<TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAtJoined}
{ inviteButton }
<GeminiScrollbarWrapper autoshow={true}>
<div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined}
createOverflowElement={this._createOverflowTileJoined}
getChildren={this._getChildrenJoined}
getChildCount={this._getChildCountJoined}
/>
{ invitedSection }
getChildCount={this._getChildCountJoined} />
{ invitedHeader }
{ invitedSection }
</div>
</GeminiScrollbarWrapper>
<input className="mx_MemberList_query mx_textinput_icon mx_textinput_search" id="mx_MemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter room members')} />
</div>
);
},
onInviteButtonClick: function() {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
// call AddressPickerDialog
dis.dispatch({
action: 'view_invite',
roomId: this.props.roomId,
});
},
});

View file

@ -21,10 +21,8 @@ import SettingsStore from "../../../settings/SettingsStore";
const React = require('react');
import PropTypes from 'prop-types';
const MatrixClientPeg = require('../../../MatrixClientPeg');
const sdk = require('../../../index');
const dis = require('../../../dispatcher');
const Modal = require("../../../Modal");
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
@ -42,7 +40,46 @@ module.exports = React.createClass({
},
getInitialState: function() {
return {};
return {
statusMessage: this.getStatusMessage(),
};
},
componentDidMount() {
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return;
}
const { user } = this.props.member;
if (!user) {
return;
}
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
},
componentWillUmount() {
const { user } = this.props.member;
if (!user) {
return;
}
user.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
},
getStatusMessage() {
const { user } = this.props.member;
if (!user) {
return "";
}
return user._unstable_statusMessage;
},
_onStatusMessageCommitted() {
// The `User` object has observed a status message change.
this.setState({
statusMessage: this.getStatusMessage(),
});
},
shouldComponentUpdate: function(nextProps, nextState) {
@ -74,22 +111,23 @@ module.exports = React.createClass({
},
getPowerLabel: function() {
return _t("%(userName)s (power %(powerLevelNumber)s)", {userName: this.props.member.userId, powerLevelNumber: this.props.member.powerLevel});
return _t("%(userName)s (power %(powerLevelNumber)s)", {
userName: this.props.member.userId,
powerLevelNumber: this.props.member.powerLevel,
});
},
render: function() {
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const EntityTile = sdk.getComponent('rooms.EntityTile');
const member = this.props.member;
const name = this._getDisplayName();
const active = -1;
const presenceState = member.user ? member.user.presence : null;
let statusMessage = null;
if (member.user && SettingsStore.isFeatureEnabled("feature_custom_status")) {
statusMessage = member.user._unstable_statusMessage;
statusMessage = this.state.statusMessage;
}
const av = (

View file

@ -282,10 +282,21 @@ export default class MessageComposer extends React.Component {
ev.preventDefault();
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
const replacementRoom = MatrixClientPeg.get().getRoom(replacementRoomId);
let createEventId = null;
if (replacementRoom) {
const createEvent = replacementRoom.currentState.getStateEvents('m.room.create', '');
if (createEvent && createEvent.getId()) createEventId = createEvent.getId();
}
dis.dispatch({
action: 'view_room',
highlighted: true,
event_id: createEventId,
room_id: replacementRoomId,
// Try to join via the server that sent the event. This converts $something:example.org
// into a server domain by splitting on colons and ignoring the first entry ("$something").
via_servers: [this.state.tombstone.getId().split(':').splice(1).join(':')],
});
}
@ -333,16 +344,16 @@ export default class MessageComposer extends React.Component {
if (this.props.callState && this.props.callState !== 'ended') {
hangupButton =
<AccessibleButton key="controls_hangup" className="mx_MessageComposer_hangup" onClick={this.onHangupClick}>
<img src="img/hangup.svg" alt={_t('Hangup')} title={_t('Hangup')} width="25" height="26" />
<img src="img/hangup.svg" alt={_t('Hangup')} title={_t('Hangup')} width="25" height="25" />
</AccessibleButton>;
} else {
callButton =
<AccessibleButton key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title={_t('Voice call')}>
<TintableSvg src="img/icon-call.svg" width="35" height="35" />
<TintableSvg src="img/feather-icons/phone.svg" width="20" height="20" />
</AccessibleButton>;
videoCallButton =
<AccessibleButton key="controls_videocall" className="mx_MessageComposer_videocall" onClick={this.onCallClick} title={_t('Video call')}>
<TintableSvg src="img/icons-video.svg" width="35" height="35" />
<TintableSvg src="img/feather-icons/video.svg" width="20" height="20" />
</AccessibleButton>;
}
@ -384,7 +395,7 @@ export default class MessageComposer extends React.Component {
const uploadButton = (
<AccessibleButton key="controls_upload" className="mx_MessageComposer_upload"
onClick={this.onUploadClick} title={_t('Upload file')}>
<TintableSvg src="img/icons-upload.svg" width="35" height="35" />
<TintableSvg src="img/feather-icons/paperclip.svg" width="20" height="20" />
<input ref="uploadInput" type="file"
style={uploadInputStyle}
multiple

View file

@ -15,13 +15,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent';
import { Editor } from 'slate-react';
import { getEventTransfer } from 'slate-react';
import { Value, Document, Block, Inline, Text, Range, Node } from 'slate';
import { Value, Block, Inline, Range } from 'slate';
import type { Change } from 'slate';
import Html from 'slate-html-serializer';
@ -30,7 +28,6 @@ import Plain from 'slate-plain-serializer';
import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer";
import classNames from 'classnames';
import Promise from 'bluebird';
import MatrixClientPeg from '../../../MatrixClientPeg';
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
@ -38,7 +35,7 @@ import {processCommandInput} from '../../../SlashCommands';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../../Keyboard';
import Modal from '../../../Modal';
import sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
import { _t } from '../../../languageHandler';
import Analytics from '../../../Analytics';
import dis from '../../../dispatcher';
@ -51,10 +48,12 @@ import Markdown from '../../../Markdown';
import ComposerHistoryManager from '../../../ComposerHistoryManager';
import MessageComposerStore from '../../../stores/MessageComposerStore';
import {MATRIXTO_MD_LINK_PATTERN, MATRIXTO_URL_PATTERN} from '../../../linkify-matrix';
const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g');
import {MATRIXTO_URL_PATTERN} from '../../../linkify-matrix';
import {asciiRegexp, unicodeRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort, toShort} from 'emojione';
import {
asciiRegexp, unicodeRegexp, shortnameToUnicode,
asciiList, mapUnicodeToShort, toShort,
} from 'emojione';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import {makeUserPermalink} from "../../../matrix-to";
import ReplyPreview from "./ReplyPreview";
@ -62,17 +61,12 @@ import RoomViewStore from '../../../stores/RoomViewStore';
import ReplyThread from "../elements/ReplyThread";
import {ContentHelpers} from 'matrix-js-sdk';
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort();
const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$');
const EMOJI_REGEX = new RegExp(unicodeRegexp, 'g');
const TYPING_USER_TIMEOUT = 10000; const TYPING_SERVER_TIMEOUT = 30000;
const ENTITY_TYPES = {
AT_ROOM_PILL: 'ATROOMPILL',
};
// the Slate node type to default to for unstyled text
const DEFAULT_NODE = 'paragraph';
@ -357,7 +351,6 @@ export default class MessageComposerInput extends React.Component {
}
onAction = (payload) => {
const editor = this._editor;
const editorState = this.state.editorState;
switch (payload.action) {
@ -854,7 +847,7 @@ export default class MessageComposerInput extends React.Component {
return true;
}
const newState: ?Value = null;
//const newState: ?Value = null;
// Draft handles rich text mode commands by default but we need to do it ourselves for Markdown.
if (this.state.isRichTextEnabled) {
@ -1105,7 +1098,9 @@ export default class MessageComposerInput extends React.Component {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Server error', '', ErrorDialog, {
title: _t("Server error"),
description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")),
description: ((err && err.message) ? err.message : _t(
"Server unavailable, overloaded, or something else went wrong.",
)),
});
});
} else if (cmd.error) {
@ -1260,7 +1255,7 @@ export default class MessageComposerInput extends React.Component {
}
};
selectHistory = async(up) => {
selectHistory = async (up) => {
const delta = up ? -1 : 1;
// True if we are not currently selecting history, but composing a message
@ -1308,7 +1303,7 @@ export default class MessageComposerInput extends React.Component {
return true;
};
onTab = async(e) => {
onTab = async (e) => {
this.setState({
someCompletions: null,
});
@ -1330,7 +1325,7 @@ export default class MessageComposerInput extends React.Component {
up ? this.autocomplete.onUpArrow() : this.autocomplete.onDownArrow();
};
onEscape = async(e) => {
onEscape = async (e) => {
e.preventDefault();
if (this.autocomplete) {
this.autocomplete.onEscape(e);
@ -1349,7 +1344,7 @@ export default class MessageComposerInput extends React.Component {
/* If passed null, restores the original editor content from state.originalEditorState.
* If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState.
*/
setDisplayedCompletion = async(displayedCompletion: ?Completion): boolean => {
setDisplayedCompletion = async (displayedCompletion: ?Completion): boolean => {
const activeEditorState = this.state.originalEditorState || this.state.editorState;
if (displayedCompletion == null) {
@ -1484,7 +1479,9 @@ export default class MessageComposerInput extends React.Component {
});
const style = {};
if (props.selected) style.border = '1px solid blue';
return <img className={ className } src={ uri } title={ shortname } alt={ emojiUnicode } style={style} />;
return <img className={ className } src={ uri }
title={ shortname } alt={ emojiUnicode } style={style}
/>;
}
}
};
@ -1538,7 +1535,6 @@ export default class MessageComposerInput extends React.Component {
getSelectionRange(editorState: Value) {
let beginning = false;
const query = this.getAutocompleteQuery(editorState);
const firstChild = editorState.document.nodes.get(0);
const firstGrandChild = firstChild && firstChild.nodes.get(0);
beginning = (firstChild && firstGrandChild &&

View file

@ -23,7 +23,6 @@ import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from "../../../Modal";
import dis from "../../../dispatcher";
import RateLimitedFunc from '../../../ratelimitedfunc';
import * as linkify from 'linkifyjs';
@ -33,6 +32,7 @@ import AccessibleButton from '../elements/AccessibleButton';
import ManageIntegsButton from '../elements/ManageIntegsButton';
import {CancelButton} from './SimpleRoomHeader';
import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
linkifyMatrix(linkify);
@ -145,10 +145,6 @@ module.exports = React.createClass({
MatrixClientPeg.get().sendStateEvent(this.props.room.roomId, 'm.room.avatar', {url: null}, '');
},
onShowRhsClick: function(ev) {
dis.dispatch({ action: 'show_right_panel' });
},
onShareRoomClick: function(ev) {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room dialog', '', ShareDialog, {
@ -302,18 +298,17 @@ module.exports = React.createClass({
topic = ev.getContent().topic;
}
}
if (topic) {
topicElement =
<div className="mx_RoomHeader_topic" ref="topic" title={topic} dir="auto">{ topic }</div>;
}
topicElement =
<div className="mx_RoomHeader_topic" ref="topic" title={topic} dir="auto">{ topic }</div>;
}
let roomAvatar = null;
const avatarSize = 28;
if (canSetRoomAvatar) {
roomAvatar = (
<div className="mx_RoomHeader_avatarPicker">
<div onClick={this.onAvatarPickerClick}>
<ChangeAvatar ref="changeAvatar" room={this.props.room} showUploadSection={false} width={48} height={48} />
<ChangeAvatar ref="changeAvatar" room={this.props.room} showUploadSection={false} width={avatarSize} height={avatarSize} />
</div>
<div className="mx_RoomHeader_avatarPicker_edit">
<label htmlFor="avatarInput" ref="file_label">
@ -334,7 +329,7 @@ module.exports = React.createClass({
);
} else if (this.props.room || (this.props.oobData && this.props.oobData.name)) {
roomAvatar = (
<RoomAvatar room={this.props.room} width={48} height={48} oobData={this.props.oobData}
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize} oobData={this.props.oobData}
viewAvatarOnClick={true} />
);
}
@ -342,7 +337,7 @@ module.exports = React.createClass({
if (this.props.onSettingsClick) {
settingsButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSettingsClick} title={_t("Settings")}>
<TintableSvg src="img/icons-settings-room.svg" width="16" height="16" />
<TintableSvg src="img/feather-icons/settings.svg" width="20" height="20" />
</AccessibleButton>;
}
@ -382,7 +377,7 @@ module.exports = React.createClass({
if (this.props.onSearchClick && this.props.inRoom) {
searchButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSearchClick} title={_t("Search")}>
<TintableSvg src="img/icons-search.svg" width="35" height="35" />
<TintableSvg src="img/feather-icons/search.svg" width="20" height="20" />
</AccessibleButton>;
}
@ -390,15 +385,7 @@ module.exports = React.createClass({
if (this.props.inRoom) {
shareRoomButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.onShareRoomClick} title={_t('Share room')}>
<TintableSvg src="img/icons-share.svg" width="16" height="16" />
</AccessibleButton>;
}
let rightPanelButtons;
if (this.props.collapsedRhs) {
rightPanelButtons =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.onShowRhsClick} title={_t('Show panel')}>
<TintableSvg src="img/maximise.svg" width="10" height="16" />
<TintableSvg src="img/feather-icons/share.svg" width="20" height="20" />
</AccessibleButton>;
}
@ -412,33 +399,27 @@ module.exports = React.createClass({
if (!this.props.editing) {
rightRow =
<div className="mx_RoomHeader_rightRow">
<div className="mx_RoomHeader_buttons">
{ settingsButton }
{ pinnedEventsButton }
{ shareRoomButton }
{ manageIntegsButton }
{ forgetButton }
{ searchButton }
{ rightPanelButtons }
</div>;
}
return (
<div className={"mx_RoomHeader " + (this.props.editing ? "mx_RoomHeader_editing" : "")}>
<div className={"mx_RoomHeader light-panel " + (this.props.editing ? "mx_RoomHeader_editing" : "")}>
<div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_leftRow">
<div className="mx_RoomHeader_avatar">
{ roomAvatar }
</div>
<div className="mx_RoomHeader_info">
{ name }
{ topicElement }
</div>
</div>
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
{ name }
{ topicElement }
{ spinner }
{ saveButton }
{ cancelButton }
{ rightRow }
<RoomHeaderButtons collapsedRhs={this.props.collapsedRhs} />
</div>
</div>
);

View file

@ -33,7 +33,10 @@ const Receipt = require('../../../utils/Receipt');
import TagOrderStore from '../../../stores/TagOrderStore';
import RoomListStore from '../../../stores/RoomListStore';
import GroupStore from '../../../stores/GroupStore';
import RoomSubList from '../../structures/RoomSubList';
import ResizeHandle from '../elements/ResizeHandle';
import {Resizer, RoomSubListDistributor} from '../../../resizer'
const HIDE_CONFERENCE_CHANS = true;
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
@ -67,6 +70,15 @@ module.exports = React.createClass({
},
getInitialState: function() {
this._subListRefs = {
// key => RoomSubList ref
};
const sizesJson = window.localStorage.getItem("mx_roomlist_sizes");
const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed");
this.subListSizes = sizesJson ? JSON.parse(sizesJson) : {};
this.collapsedState = collapsedJson ? JSON.parse(collapsedJson) : {};
return {
isLoadingLeftRooms: false,
totalRoomCount: null,
@ -74,6 +86,7 @@ module.exports = React.createClass({
incomingCallTag: null,
incomingCall: null,
selectedTags: [],
hover: false,
};
},
@ -89,6 +102,7 @@ module.exports = React.createClass({
cli.on("Event.decrypted", this.onEventDecrypted);
cli.on("accountData", this.onAccountData);
cli.on("Group.myMembership", this._onGroupMyMembership);
cli.on("RoomState.events", this.onRoomStateEvents);
const dmRoomMap = DMRoomMap.shared();
// A map between tags which are group IDs and the room IDs of rooms that should be kept
@ -132,18 +146,54 @@ module.exports = React.createClass({
this._delayedRefreshRoomListLoopCount = 0;
},
_onSubListResize: function(newSize, id) {
if (!id) {
return;
}
if (typeof newSize === "string") {
newSize = Number.MAX_SAFE_INTEGER;
}
if (newSize === null) {
delete this.subListSizes[id];
} else {
this.subListSizes[id] = newSize;
}
window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.subListSizes));
// update overflow indicators
this._checkSubListsOverflow();
},
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
// Initialise the stickyHeaders when the component is created
this._updateStickyHeaders(true);
const cfg = {
onResized: this._onSubListResize,
};
this.resizer = new Resizer(this.resizeContainer, RoomSubListDistributor, cfg);
this.resizer.setClassNames({
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse"
});
// load stored sizes
Object.keys(this.subListSizes).forEach((key) => {
this._restoreSubListSize(key);
});
this._checkSubListsOverflow();
this.resizer.attach();
this.mounted = true;
},
componentDidUpdate: function() {
// Reinitialise the stickyHeaders when the component is updated
this._updateStickyHeaders(true);
componentDidUpdate: function(prevProps) {
this._repositionIncomingCallBox(undefined, false);
if (this.props.searchFilter !== prevProps.searchFilter) {
// restore sizes
Object.keys(this.subListSizes).forEach((key) => {
this._restoreSubListSize(key);
});
this._checkSubListsOverflow();
}
},
onAction: function(payload) {
@ -181,6 +231,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Event.decrypted", this.onEventDecrypted);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("Group.myMembership", this._onGroupMyMembership);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
}
if (this._tagStoreToken) {
@ -204,6 +255,12 @@ module.exports = React.createClass({
this.updateVisibleRooms();
},
onRoomStateEvents: function(ev, state) {
if (ev.getType() === "m.room.create" || ev.getType() === "m.room.tombstone") {
this.updateVisibleRooms();
}
},
onDeleteRoom: function(roomId) {
this.updateVisibleRooms();
},
@ -212,10 +269,6 @@ module.exports = React.createClass({
if (!isHidden) {
const self = this;
this.setState({ isLoadingLeftRooms: true });
// Try scrolling to position
this._updateStickyHeaders(true, scrollToPosition);
// we don't care about the response since it comes down via "Room"
// events.
MatrixClientPeg.get().syncLeftRooms().catch(function(err) {
@ -227,11 +280,6 @@ module.exports = React.createClass({
}
},
onSubListHeaderClick: function(isHidden, scrollToPosition) {
// The scroll area has expanded or contracted, so re-calculate sticky headers positions
this._updateStickyHeaders(true, scrollToPosition);
},
onRoomReceipt: function(receiptEvent, room) {
// because if we read a notification, it will affect notification count
// only bother updating if there's a receipt from us
@ -259,6 +307,17 @@ module.exports = React.createClass({
this.forceUpdate();
},
onMouseEnter: function(ev) {
this.setState({hover: true});
},
onMouseLeave: function(ev) {
this.setState({hover: false});
// Refresh the room list just in case the user missed something.
this._delayedRefreshRoomList();
},
_delayedRefreshRoomList: new rate_limited_func(function() {
this.refreshRoomList();
}, 500),
@ -311,6 +370,11 @@ module.exports = React.createClass({
},
refreshRoomList: function() {
if (this.state.hover) {
// Don't re-sort the list if we're hovering over the list
return;
}
// TODO: ideally we'd calculate this once at start, and then maintain
// any changes to it incrementally, updating the appropriate sublists
// as needed.
@ -326,6 +390,11 @@ module.exports = React.createClass({
// Do this here so as to not render every time the selected tags
// themselves change.
selectedTags: TagOrderStore.getSelectedTags(),
}, () => {
// we don't need to restore any size here, do we?
// i guess we could have triggered a new group to appear
// that already an explicit size the last time it appeared ...
this._checkSubListsOverflow();
});
// this._lastRefreshRoomListTs = Date.now();
@ -401,7 +470,6 @@ module.exports = React.createClass({
_whenScrolling: function(e) {
this._hideTooltip(e);
this._repositionIncomingCallBox(e, false);
this._updateStickyHeaders(false);
},
_hideTooltip: function(e) {
@ -435,169 +503,6 @@ module.exports = React.createClass({
}
},
// Doing the sticky headers as raw DOM, for speed, as it gets very stuttery if done
// properly through React
_initAndPositionStickyHeaders: function(initialise, scrollToPosition) {
const scrollArea = this._getScrollNode();
if (!scrollArea) return;
// Use the offset of the top of the scroll area from the window
// as this is used to calculate the CSS fixed top position for the stickies
const scrollAreaOffset = scrollArea.getBoundingClientRect().top + window.pageYOffset;
// Use the offset of the top of the componet from the window
// as this is used to calculate the CSS fixed top position for the stickies
const scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height;
if (initialise) {
// Get a collection of sticky header containers references
this.stickies = document.getElementsByClassName("mx_RoomSubList_labelContainer");
if (!this.stickies.length) return;
// Make sure there is sufficient space to do sticky headers: 120px plus all the sticky headers
this.scrollAreaSufficient = (120 + (this.stickies[0].getBoundingClientRect().height * this.stickies.length)) < scrollAreaHeight;
// Initialise the sticky headers
if (typeof this.stickies === "object" && this.stickies.length > 0) {
// Initialise the sticky headers
Array.prototype.forEach.call(this.stickies, function(sticky, i) {
// Save the positions of all the stickies within scroll area.
// These positions are relative to the LHS Panel top
sticky.dataset.originalPosition = sticky.offsetTop - scrollArea.offsetTop;
// Save and set the sticky heights
const originalHeight = sticky.getBoundingClientRect().height;
sticky.dataset.originalHeight = originalHeight;
sticky.style.height = originalHeight;
return sticky;
});
}
}
if (!this.stickies) return;
const self = this;
let scrollStuckOffset = 0;
// Scroll to the passed in position, i.e. a header was clicked and in a scroll to state
// rather than a collapsable one (see RoomSubList.isCollapsableOnClick method for details)
if (scrollToPosition !== undefined) {
scrollArea.scrollTop = scrollToPosition;
}
// Stick headers to top and bottom, or free them
Array.prototype.forEach.call(this.stickies, function(sticky, i, stickyWrappers) {
const stickyPosition = sticky.dataset.originalPosition;
const stickyHeight = sticky.dataset.originalHeight;
const stickyHeader = sticky.childNodes[0];
const topStuckHeight = stickyHeight * i;
const bottomStuckHeight = stickyHeight * (stickyWrappers.length - i);
if (self.scrollAreaSufficient && stickyPosition < (scrollArea.scrollTop + topStuckHeight)) {
// Top stickies
sticky.dataset.stuck = "top";
stickyHeader.classList.add("mx_RoomSubList_fixed");
stickyHeader.style.top = scrollAreaOffset + topStuckHeight + "px";
// If stuck at top adjust the scroll back down to take account of all the stuck headers
if (scrollToPosition !== undefined && stickyPosition === scrollToPosition) {
scrollStuckOffset = topStuckHeight;
}
} else if (self.scrollAreaSufficient && stickyPosition > ((scrollArea.scrollTop + scrollAreaHeight) - bottomStuckHeight)) {
/// Bottom stickies
sticky.dataset.stuck = "bottom";
stickyHeader.classList.add("mx_RoomSubList_fixed");
stickyHeader.style.top = (scrollAreaOffset + scrollAreaHeight) - bottomStuckHeight + "px";
} else {
// Not sticky
sticky.dataset.stuck = "none";
stickyHeader.classList.remove("mx_RoomSubList_fixed");
stickyHeader.style.top = null;
}
});
// Adjust the scroll to take account of top stuck headers
if (scrollToPosition !== undefined) {
scrollArea.scrollTop -= scrollStuckOffset;
}
},
_updateStickyHeaders: function(initialise, scrollToPosition) {
const self = this;
if (initialise) {
// Useing setTimeout to ensure that the code is run after the painting
// of the newly rendered object as using requestAnimationFrame caused
// artefacts to appear on screen briefly
window.setTimeout(function() {
self._initAndPositionStickyHeaders(initialise, scrollToPosition);
});
} else {
this._initAndPositionStickyHeaders(initialise, scrollToPosition);
}
},
onShowMoreRooms: function() {
// kick gemini in the balls to get it to wake up
// XXX: uuuuuuugh.
if (!this._gemScroll) return;
this._gemScroll.forceUpdate();
},
_getEmptyContent: function(section) {
if (this.state.selectedTags.length > 0) {
return null;
}
const RoomDropTarget = sdk.getComponent('rooms.RoomDropTarget');
if (this.props.collapsed) {
return <RoomDropTarget label="" />;
}
const StartChatButton = sdk.getComponent('elements.StartChatButton');
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton');
let tip = null;
switch (section) {
case 'im.vector.fake.direct':
tip = <div className="mx_RoomList_emptySubListTip">
{ _t(
"Press <StartChatButton> to start a chat with someone",
{},
{ 'StartChatButton': <StartChatButton size="16" callout={true} /> },
) }
</div>;
break;
case 'im.vector.fake.recent':
tip = <div className="mx_RoomList_emptySubListTip">
{ _t(
"You're not in any rooms yet! Press <CreateRoomButton> to make a room or"+
" <RoomDirectoryButton> to browse the directory",
{},
{
'CreateRoomButton': <CreateRoomButton size="16" callout={true} />,
'RoomDirectoryButton': <RoomDirectoryButton size="16" callout={true} />,
},
) }
</div>;
break;
}
if (tip) {
return <div className="mx_RoomList_emptySubListTip_container">
{ tip }
</div>;
}
// We don't want to display drop targets if there are no room tiles to drag'n'drop
if (this.state.totalRoomCount === 0) {
return null;
}
const labelText = phraseForSection(section);
return <RoomDropTarget label={labelText} />;
},
_getHeaderItems: function(section) {
const StartChatButton = sdk.getComponent('elements.StartChatButton');
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
@ -632,161 +537,195 @@ module.exports = React.createClass({
return ret;
},
_collectGemini(gemScroll) {
this._gemScroll = gemScroll;
_applySearchFilter: function(list, filter) {
if (filter === "") return list;
const lcFilter = filter.toLowerCase();
// case insensitive if room name includes filter,
// or if starts with `#` and one of room's aliases starts with filter
return list.filter((room) => (room.name && room.name.toLowerCase().includes(lcFilter)) ||
(filter[0] === '#' && room.getAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter))));
},
_handleCollapsedState: function(key, collapsed) {
// persist collapsed state
this.collapsedState[key] = collapsed;
window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.collapsedState));
// load the persisted size configuration of the expanded sub list
if (!collapsed) {
this._restoreSubListSize(key);
}
// check overflow, as sub lists sizes have changed
// important this happens after calling resize above
this._checkSubListsOverflow();
},
_restoreSubListSize(key) {
const size = this.subListSizes[key];
const handle = this.resizer.forHandleWithId(key);
if (handle) {
handle.resize(size);
}
},
// check overflow for scroll indicator gradient
_checkSubListsOverflow() {
Object.values(this._subListRefs).forEach(l => l.checkOverflow());
},
_subListRef: function(key, ref) {
if (!ref) {
delete this._subListRefs[key];
} else {
this._subListRefs[key] = ref;
}
},
_mapSubListProps: function(subListsProps) {
const defaultProps = {
collapsed: this.props.collapsed,
isFiltered: !!this.props.searchFilter,
incomingCall: this.state.incomingCall,
};
subListsProps.forEach((p) => {
p.list = this._applySearchFilter(p.list, this.props.searchFilter);
});
subListsProps = subListsProps.filter((props => {
const len = props.list.length + (props.extraTiles ? props.extraTiles.length : 0);
return len !== 0 || (props.onAddRoom && !this.props.searchFilter);
}));
return subListsProps.reduce((components, props, i) => {
props = Object.assign({}, defaultProps, props);
const isLast = i === subListsProps.length - 1;
const {key, label, onHeaderClick, ... otherProps} = props;
const chosenKey = key || label;
const onSubListHeaderClick = (collapsed) => {
this._handleCollapsedState(chosenKey, collapsed);
if (onHeaderClick) {
onHeaderClick(collapsed);
}
};
const startAsHidden = props.startAsHidden || this.collapsedState[chosenKey];
let subList = (<RoomSubList
ref={this._subListRef.bind(this, chosenKey)}
startAsHidden={startAsHidden}
onHeaderClick={onSubListHeaderClick}
key={chosenKey}
label={label}
{...otherProps} />);
if (!isLast) {
return components.concat(
subList,
<ResizeHandle key={chosenKey+"-resizer"} vertical={true} id={chosenKey} />
);
} else {
return components.concat(subList);
}
}, []);
},
_collectResizeContainer: function(el) {
this.resizeContainer = el;
},
render: function() {
const RoomSubList = sdk.getComponent('structures.RoomSubList');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
// XXX: we can't detect device-level (localStorage) settings onChange as the SettingsStore does not notify
// so checking on every render is the sanest thing at this time.
const showEmpty = SettingsStore.getValue('RoomSubList.showEmpty');
const incomingCallIfTaggedAs = (tagName) => {
if (!this.state.incomingCall) return null;
if (this.state.incomingCallTag !== tagName) return null;
return this.state.incomingCall;
};
const self = this;
let subLists = [
{
list: [],
extraTiles: this._makeGroupInviteTiles(this.props.searchFilter),
label: _t('Community Invites'),
order: "recent",
isInvite: true,
},
{
list: this.state.lists['im.vector.fake.invite'],
label: _t('Invites'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.invite'),
isInvite: true,
},
{
list: this.state.lists['m.favourite'],
label: _t('Favourites'),
tagName: "m.favourite",
order: "manual",
incomingCall: incomingCallIfTaggedAs('m.favourite'),
},
{
list: this.state.lists['im.vector.fake.direct'],
label: _t('People'),
tagName: "im.vector.fake.direct",
headerItems: this._getHeaderItems('im.vector.fake.direct'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'),
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'})},
},
{
list: this.state.lists['im.vector.fake.recent'],
label: _t('Rooms'),
headerItems: this._getHeaderItems('im.vector.fake.recent'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'),
onAddRoom: () => {dis.dispatch({action: 'view_create_room'})},
},
];
const tagSubLists = Object.keys(this.state.lists)
.filter((tagName) => {
return !tagName.match(STANDARD_TAGS_REGEX);
}).map((tagName) => {
return {
list: this.state.lists[tagName],
key: tagName,
label: labelForTagName(tagName),
tagName: tagName,
order: "manual",
incomingCall: incomingCallIfTaggedAs(tagName),
};
});
subLists = subLists.concat(tagSubLists);
subLists = subLists.concat([
{
list: this.state.lists['m.lowpriority'],
label: _t('Low priority'),
tagName: "m.lowpriority",
order: "recent",
incomingCall: incomingCallIfTaggedAs('m.lowpriority'),
},
{
list: this.state.lists['im.vector.fake.archived'],
label: _t('Historical'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.archived'),
startAsHidden: true,
showSpinner: this.state.isLoadingLeftRooms,
onHeaderClick: this.onArchivedHeaderClick,
},
{
list: this.state.lists['m.server_notice'],
label: _t('System Alerts'),
tagName: "m.lowpriority",
order: "recent",
incomingCall: incomingCallIfTaggedAs('m.server_notice'),
},
]);
const subListComponents = this._mapSubListProps(subLists);
return (
<GeminiScrollbarWrapper className="mx_RoomList_scrollbar"
autoshow={true} onScroll={self._whenScrolling} onResize={self._whenScrolling} wrappedRef={this._collectGemini}>
<div className="mx_RoomList">
<RoomSubList list={[]}
extraTiles={this._makeGroupInviteTiles(self.props.searchFilter)}
label={_t('Community Invites')}
editable={false}
order="recent"
isInvite={true}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty}
/>
<RoomSubList list={self.state.lists['im.vector.fake.invite']}
label={_t('Invites')}
editable={false}
order="recent"
isInvite={true}
incomingCall={incomingCallIfTaggedAs('im.vector.fake.invite')}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty}
/>
<RoomSubList list={self.state.lists['m.favourite']}
label={_t('Favourites')}
tagName="m.favourite"
emptyContent={this._getEmptyContent('m.favourite')}
editable={true}
order="manual"
incomingCall={incomingCallIfTaggedAs('m.favourite')}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
<RoomSubList list={self.state.lists['im.vector.fake.direct']}
label={_t('People')}
tagName="im.vector.fake.direct"
emptyContent={this._getEmptyContent('im.vector.fake.direct')}
headerItems={this._getHeaderItems('im.vector.fake.direct')}
editable={true}
order="recent"
incomingCall={incomingCallIfTaggedAs('im.vector.fake.direct')}
collapsed={self.props.collapsed}
alwaysShowHeader={true}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
<RoomSubList list={self.state.lists['im.vector.fake.recent']}
label={_t('Rooms')}
editable={true}
emptyContent={this._getEmptyContent('im.vector.fake.recent')}
headerItems={this._getHeaderItems('im.vector.fake.recent')}
order="recent"
incomingCall={incomingCallIfTaggedAs('im.vector.fake.recent')}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
{ Object.keys(self.state.lists).map((tagName) => {
if (!tagName.match(STANDARD_TAGS_REGEX)) {
return <RoomSubList list={self.state.lists[tagName]}
key={tagName}
label={labelForTagName(tagName)}
tagName={tagName}
emptyContent={this._getEmptyContent(tagName)}
editable={true}
order="manual"
incomingCall={incomingCallIfTaggedAs(tagName)}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />;
}
}) }
<RoomSubList list={self.state.lists['m.lowpriority']}
label={_t('Low priority')}
tagName="m.lowpriority"
emptyContent={this._getEmptyContent('m.lowpriority')}
editable={true}
order="recent"
incomingCall={incomingCallIfTaggedAs('m.lowpriority')}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
<RoomSubList list={self.state.lists['im.vector.fake.archived']}
emptyContent={self.props.collapsed ? null :
<div className="mx_RoomList_emptySubListTip_container">
<div className="mx_RoomList_emptySubListTip">
{ _t('You have no historical rooms') }
</div>
</div>
}
label={_t('Historical')}
editable={false}
order="recent"
collapsed={self.props.collapsed}
alwaysShowHeader={true}
startAsHidden={true}
showSpinner={self.state.isLoadingLeftRooms}
onHeaderClick={self.onArchivedHeaderClick}
incomingCall={incomingCallIfTaggedAs('im.vector.fake.archived')}
searchFilter={self.props.searchFilter}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
<RoomSubList list={self.state.lists['m.server_notice']}
label={_t('System Alerts')}
tagName="m.lowpriority"
editable={false}
order="recent"
incomingCall={incomingCallIfTaggedAs('m.server_notice')}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={false} />
<div ref={this._collectResizeContainer} className="mx_RoomList"
onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
{ subListComponents }
</div>
</GeminiScrollbarWrapper>
);
},
});

View file

@ -1,5 +1,5 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2018, 2019 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.
@ -20,10 +20,16 @@ import sdk from "../../../index";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import MatrixClientPeg from "../../../MatrixClientPeg";
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
export default class RoomRecoveryReminder extends React.PureComponent {
static propTypes = {
onFinished: PropTypes.func.isRequired,
// called if the user sets the option to suppress this reminder in the future
onDontAskAgainSet: PropTypes.func,
}
static defaultProps = {
onDontAskAgainSet: function() {},
}
constructor(props) {
@ -82,7 +88,6 @@ export default class RoomRecoveryReminder extends React.PureComponent {
Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, {
userId: MatrixClientPeg.get().credentials.userId,
device: this.state.unverifiedDevice,
onFinished: this.props.onFinished,
});
return;
}
@ -91,9 +96,6 @@ export default class RoomRecoveryReminder extends React.PureComponent {
// we'll show the create key backup flow.
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"),
{
onFinished: this.props.onFinished,
},
);
}
@ -103,10 +105,14 @@ export default class RoomRecoveryReminder extends React.PureComponent {
Modal.createTrackedDialogAsync("Ignore Recovery Reminder", "Ignore Recovery Reminder",
import("../../../async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog"),
{
onDontAskAgain: () => {
// Report false to the caller, who should prevent the
// reminder from appearing in the future.
this.props.onFinished(false);
onDontAskAgain: async () => {
await SettingsStore.setValue(
"showRoomRecoveryReminder",
null,
SettingLevel.ACCOUNT,
false,
);
this.props.onDontAskAgainSet();
},
onSetup: () => {
this.showSetupDialog();

View file

@ -63,6 +63,7 @@ module.exports = React.createClass({
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
notificationCount: this.props.room.getUnreadNotificationCount(),
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
statusMessage: this._getStatusMessage(),
});
},
@ -80,6 +81,33 @@ module.exports = React.createClass({
return Boolean(dmRooms);
},
_shouldShowStatusMessage() {
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return false;
}
const isInvite = this.props.room.getMyMembership() === "invite";
const isJoined = this.props.room.getMyMembership() === "join";
const looksLikeDm = this.props.room.getInvitedAndJoinedMemberCount() === 2;
return !isInvite && isJoined && looksLikeDm;
},
_getStatusMessageUser() {
const selfId = MatrixClientPeg.get().getUserId();
const otherMember = this.props.room.currentState.getMembersExcept([selfId])[0];
if (!otherMember) {
return null;
}
return otherMember.user;
},
_getStatusMessage() {
const statusUser = this._getStatusMessageUser();
if (!statusUser) {
return "";
}
return statusUser._unstable_statusMessage;
},
onRoomTimeline: function(ev, room) {
if (room !== this.props.room) return;
this.setState({
@ -113,7 +141,13 @@ module.exports = React.createClass({
this.setState({
notificationCount: this.props.room.getUnreadNotificationCount(),
});
break;
break;
// RoomTiles are one of the few components that may show custom status and
// also remain on screen while in Settings toggling the feature. This ensures
// you can clearly see the status hide and show when toggling the feature.
case 'feature_custom_status_changed':
this.forceUpdate();
break;
}
},
@ -129,6 +163,16 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("Room.name", this.onRoomName);
ActiveRoomObserver.addListener(this.props.room.roomId, this._onActiveRoomChange);
this.dispatcherRef = dis.register(this.onAction);
if (this._shouldShowStatusMessage()) {
const statusUser = this._getStatusMessageUser();
if (statusUser) {
statusUser.on(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
}
}
},
componentWillUnmount: function() {
@ -140,6 +184,16 @@ module.exports = React.createClass({
}
ActiveRoomObserver.removeListener(this.props.room.roomId, this._onActiveRoomChange);
dis.unregister(this.dispatcherRef);
if (this._shouldShowStatusMessage()) {
const statusUser = this._getStatusMessageUser();
if (statusUser) {
statusUser.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
}
}
},
componentWillReceiveProps: function(props) {
@ -167,6 +221,13 @@ module.exports = React.createClass({
return false;
},
_onStatusMessageCommitted() {
// The status message `User` object has observed a message change.
this.setState({
statusMessage: this._getStatusMessage(),
});
},
onClick: function(ev) {
if (this.props.onClick) {
this.props.onClick(this.props.room.roomId, ev);
@ -221,7 +282,7 @@ module.exports = React.createClass({
this.setState( { badgeHover: false } );
},
onBadgeClicked: function(e) {
onOpenMenu: function(e) {
// Prevent the RoomTile onClick event firing as well
e.stopPropagation();
// Only allow non-guests to access the context menu
@ -252,15 +313,9 @@ module.exports = React.createClass({
const mentionBadges = this.props.highlight && this._shouldShowMentionBadge();
const badges = notifBadges || mentionBadges;
const isJoined = this.props.room.getMyMembership() === "join";
const looksLikeDm = this.props.room.getInvitedAndJoinedMemberCount() === 2;
let subtext = null;
if (!isInvite && isJoined && looksLikeDm && SettingsStore.isFeatureEnabled("feature_custom_status")) {
const selfId = MatrixClientPeg.get().getUserId();
const otherMember = this.props.room.currentState.getMembersExcept([selfId])[0];
if (otherMember && otherMember.user && otherMember.user._unstable_statusMessage) {
subtext = otherMember.user._unstable_statusMessage;
}
if (this._shouldShowStatusMessage()) {
subtext = this.state.statusMessage;
}
const classes = classNames({
@ -289,19 +344,14 @@ module.exports = React.createClass({
if (name == undefined || name == null) name = '';
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
let badgeContent;
if (this.state.badgeHover || this.state.menuDisplayed) {
badgeContent = "\u00B7\u00B7\u00B7";
} else if (badges) {
let badge;
if (badges) {
const limitedCount = FormattingUtils.formatCount(notificationCount);
badgeContent = notificationCount ? limitedCount : '!';
} else {
badgeContent = '\u200B';
const badgeContent = notificationCount ? limitedCount : '!';
badge = <div className={badgeClasses}>{ badgeContent }</div>;
}
const badge = <div className={badgeClasses} onClick={this.onBadgeClicked}>{ badgeContent }</div>;
const EmojiText = sdk.getComponent('elements.EmojiText');
let label;
let subtextLabel;
@ -333,6 +383,11 @@ module.exports = React.createClass({
// incomingCallBox = <IncomingCallBox incomingCall={ this.props.incomingCall }/>;
//}
let contextMenuButton;
if (!MatrixClientPeg.get().isGuest()) {
contextMenuButton = <AccessibleButton className="mx_RoomTile_menuButton" onClick={this.onOpenMenu} />;
}
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
let dmIndicator;
@ -354,8 +409,11 @@ module.exports = React.createClass({
</div>
</div>
<div className="mx_RoomTile_nameContainer">
{ label }
{ subtextLabel }
<div className="mx_RoomTile_labelContainer">
{ label }
{ subtextLabel }
</div>
{ contextMenuButton }
{ badge }
</div>
{ /* { incomingCallBox } */ }

View file

@ -33,11 +33,11 @@ module.exports = React.createClass({
},
onThisRoomClick: function() {
this.setState({ scope: 'Room' });
this.setState({ scope: 'Room' }, () => this._searchIfQuery());
},
onAllRoomsClick: function() {
this.setState({ scope: 'All' });
this.setState({ scope: 'All' }, () => this._searchIfQuery());
},
onSearchChange: function(e) {
@ -49,6 +49,12 @@ module.exports = React.createClass({
}
},
_searchIfQuery: function() {
if (this.refs.search_term.value) {
this.onSearch();
}
},
onSearch: function() {
this.props.onSearch(this.refs.search_term.value, this.state.scope);
},
@ -60,11 +66,13 @@ module.exports = React.createClass({
return (
<div className="mx_SearchBar">
<input ref="search_term" className="mx_SearchBar_input" type="text" autoFocus={true} placeholder={_t("Search…")} onKeyDown={this.onSearchChange} />
<AccessibleButton className={ searchButtonClasses } onClick={this.onSearch}><img src="img/search-button.svg" width="37" height="37" alt={_t("Search")} /></AccessibleButton>
<AccessibleButton className={ thisRoomClasses } onClick={this.onThisRoomClick}>{_t("This Room")}</AccessibleButton>
<AccessibleButton className={ allRoomsClasses } onClick={this.onAllRoomsClick}>{_t("All Rooms")}</AccessibleButton>
<AccessibleButton className="mx_SearchBar_cancel" onClick={this.props.onCancelClick}><img src="img/cancel.svg" width="18" height="18" /></AccessibleButton>
<div className="mx_SearchBar_input mx_textinput">
<input ref="search_term" type="text" autoFocus={true} placeholder={_t("Search…")} onKeyDown={this.onSearchChange} />
<AccessibleButton className={ searchButtonClasses } onClick={this.onSearch}></AccessibleButton>
</div>
<AccessibleButton className="mx_SearchBar_cancel" onClick={this.props.onCancelClick}></AccessibleButton>
</div>
);
},

View file

@ -351,7 +351,7 @@ export default class Stickerpicker extends React.Component {
onClick={this._onHideStickersClick}
ref='target'
title={_t("Hide Stickers")}>
<TintableSvg src="img/icons-hide-stickers.svg" width="35" height="35" />
<TintableSvg src="img/feather-icons/face.svg" width="20" height="20" />
</AccessibleButton>;
} else {
// Show show-stickers button
@ -362,7 +362,7 @@ export default class Stickerpicker extends React.Component {
className="mx_MessageComposer_stickers"
onClick={this._onShowStickersClick}
title={_t("Show Stickers")}>
<TintableSvg src="img/icons-show-stickers.svg" width="35" height="35" />
<TintableSvg src="img/feather-icons/face.svg" width="20" height="20" />
</AccessibleButton>;
}
return <div>

View file

@ -21,6 +21,8 @@ const React = require('react');
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import {formatCount} from '../../../utils/FormattingUtils';
const sdk = require('../../../index');
module.exports = React.createClass({
@ -28,28 +30,15 @@ module.exports = React.createClass({
propTypes: {
onScrollUpClick: PropTypes.func,
onCloseClick: PropTypes.func,
},
render: function() {
return (
<div className="mx_TopUnreadMessagesBar">
<AccessibleButton className="mx_TopUnreadMessagesBar_scrollUp"
onClick={this.props.onScrollUpClick}>
<img src="img/scrollto.svg" width="24" height="24"
// No point on setting up non empty alt on this image
// as it only complements the text which follows it.
alt=""
title={_t('Scroll to unread messages')}
// In order not to use this title attribute for accessible name
// calculation of the parent button set the role presentation
role="presentation" />
{ _t("Jump to first unread message.") }
title={_t('Jump to first unread message.')}
onClick={this.props.onScrollUpClick}>
</AccessibleButton>
<AccessibleButton element='img' className="mx_TopUnreadMessagesBar_close mx_filterFlipColor"
src="img/cancel.svg" width="18" height="18"
alt={_t("Close")} title={_t("Close")}
onClick={this.props.onCloseClick} />
</div>
);
},

View file

@ -0,0 +1,130 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 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 sdk from '../../../index';
import WhoIsTyping from '../../../WhoIsTyping';
import MatrixClientPeg from '../../../MatrixClientPeg';
import MemberAvatar from '../avatars/MemberAvatar';
module.exports = React.createClass({
displayName: 'WhoIsTypingTile',
propTypes: {
// the room this statusbar is representing.
room: PropTypes.object.isRequired,
onVisible: PropTypes.func,
// Number of names to display in typing indication. E.g. set to 3, will
// result in "X, Y, Z and 100 others are typing."
whoIsTypingLimit: PropTypes.number,
},
getDefaultProps: function() {
return {
whoIsTypingLimit: 3,
};
},
getInitialState: function() {
return {
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
};
},
componentWillMount: function() {
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
},
componentDidUpdate: function(_, prevState) {
if (this.props.onVisible &&
!prevState.usersTyping.length &&
this.state.usersTyping.length
) {
this.props.onVisible();
}
},
componentWillUnmount: function() {
// we may have entirely lost our client as we're logging out before clicking login on the guest bar...
const client = MatrixClientPeg.get();
if (client) {
client.removeListener("RoomMember.typing", this.onRoomMemberTyping);
}
},
onRoomMemberTyping: function(ev, member) {
this.setState({
usersTyping: WhoIsTyping.usersTypingApartFromMeAndIgnored(this.props.room),
});
},
_renderTypingIndicatorAvatars: function(limit) {
let users = this.state.usersTyping;
let othersCount = 0;
if (users.length > limit) {
othersCount = users.length - limit + 1;
users = users.slice(0, limit - 1);
}
const avatars = users.map((u) => {
return (
<MemberAvatar
key={u.userId}
member={u}
width={24}
height={24}
resizeMethod="crop"
/>
);
});
if (othersCount > 0) {
avatars.push(
<span className="mx_WhoIsTypingTile_remainingAvatarPlaceholder" key="others">
+{ othersCount }
</span>,
);
}
return avatars;
},
render: function() {
const typingString = WhoIsTyping.whoIsTypingString(
this.state.usersTyping,
this.props.whoIsTypingLimit,
);
if (!typingString) {
return (<div className="mx_WhoIsTypingTile_empty" />);
}
const EmojiText = sdk.getComponent('elements.EmojiText');
return (
<li className="mx_WhoIsTypingTile">
<div className="mx_WhoIsTypingTile_avatars">
{ this._renderTypingIndicatorAvatars(this.props.whoIsTypingLimit) }
</div>
<div className="mx_WhoIsTypingTile_label">
<EmojiText>{ typingString }</EmojiText>
</div>
</li>
);
},
});

View file

@ -21,13 +21,15 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
export default class KeyBackupPanel extends React.Component {
export default class KeyBackupPanel extends React.PureComponent {
constructor(props) {
super(props);
this._startNewBackup = this._startNewBackup.bind(this);
this._deleteBackup = this._deleteBackup.bind(this);
this._verifyDevice = this._verifyDevice.bind(this);
this._onKeyBackupSessionsRemaining =
this._onKeyBackupSessionsRemaining.bind(this);
this._onKeyBackupStatus = this._onKeyBackupStatus.bind(this);
this._restoreBackup = this._restoreBackup.bind(this);
@ -36,6 +38,7 @@ export default class KeyBackupPanel extends React.Component {
loading: true,
error: null,
backupInfo: null,
sessionsRemaining: 0,
};
}
@ -43,6 +46,10 @@ export default class KeyBackupPanel extends React.Component {
this._loadBackupStatus();
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatus);
MatrixClientPeg.get().on(
'crypto.keyBackupSessionsRemaining',
this._onKeyBackupSessionsRemaining,
);
}
componentWillUnmount() {
@ -50,9 +57,19 @@ export default class KeyBackupPanel extends React.Component {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatus);
MatrixClientPeg.get().removeListener(
'crypto.keyBackupSessionsRemaining',
this._onKeyBackupSessionsRemaining,
);
}
}
_onKeyBackupSessionsRemaining(sessionsRemaining) {
this.setState({
sessionsRemaining,
});
}
_onKeyBackupStatus() {
this._loadBackupStatus();
}
@ -144,57 +161,70 @@ export default class KeyBackupPanel extends React.Component {
} else if (this.state.backupInfo) {
let clientBackupStatus;
if (MatrixClientPeg.get().getKeyBackupEnabled()) {
clientBackupStatus = _t("This device is uploading keys to this backup");
clientBackupStatus = _t("This device is using key backup");
} else {
// XXX: display why and how to fix it
clientBackupStatus = _t(
"This device is <b>not</b> uploading keys to this backup", {},
"This device is <b>not</b> using key backup", {},
{b: x => <b>{x}</b>},
);
}
let uploadStatus;
const { sessionsRemaining } = this.state;
if (!MatrixClientPeg.get().getKeyBackupEnabled()) {
// No upload status to show when backup disabled.
uploadStatus = "";
} else if (sessionsRemaining > 0) {
uploadStatus = <div>
{_t("Backing up %(sessionsRemaining)s keys...", { sessionsRemaining })} <br />
</div>;
} else {
uploadStatus = <div>
{_t("All keys backed up")} <br />
</div>;
}
let backupSigStatuses = this.state.backupSigStatus.sigs.map((sig, i) => {
const deviceName = sig.device.getDisplayName() || sig.device.deviceId;
const sigStatusSubstitutions = {
validity: sub =>
<span className={sig.valid ? 'mx_KeyBackupPanel_sigValid' : 'mx_KeyBackupPanel_sigInvalid'}>
{sub}
</span>,
verify: sub =>
<span className={sig.device.isVerified() ? 'mx_KeyBackupPanel_deviceVerified' : 'mx_KeyBackupPanel_deviceNotVerified'}>
{sub}
</span>,
device: sub => <span className="mx_KeyBackupPanel_deviceName">{deviceName}</span>,
};
const validity = sub =>
<span className={sig.valid ? 'mx_KeyBackupPanel_sigValid' : 'mx_KeyBackupPanel_sigInvalid'}>
{sub}
</span>;
const verify = sub =>
<span className={sig.device.isVerified() ? 'mx_KeyBackupPanel_deviceVerified' : 'mx_KeyBackupPanel_deviceNotVerified'}>
{sub}
</span>;
const device = sub => <span className="mx_KeyBackupPanel_deviceName">{deviceName}</span>;
let sigStatus;
if (sig.device.getFingerprint() === MatrixClientPeg.get().getDeviceEd25519Key()) {
sigStatus = _t(
"Backup has a <validity>valid</validity> signature from this device",
{}, sigStatusSubstitutions,
{}, { validity },
);
} else if (sig.valid && sig.device.isVerified()) {
sigStatus = _t(
"Backup has a <validity>valid</validity> signature from " +
"<verify>verified</verify> device <device></device>",
{}, sigStatusSubstitutions,
{}, { validity, verify, device },
);
} else if (sig.valid && !sig.device.isVerified()) {
sigStatus = _t(
"Backup has a <validity>valid</validity> signature from " +
"<verify>unverified</verify> device <device></device>",
{}, sigStatusSubstitutions,
{}, { validity, verify, device },
);
} else if (!sig.valid && sig.device.isVerified()) {
sigStatus = _t(
"Backup has an <validity>invalid</validity> signature from " +
"<verify>verified</verify> device <device></device>",
{}, sigStatusSubstitutions,
{}, { validity, verify, device },
);
} else if (!sig.valid && !sig.device.isVerified()) {
sigStatus = _t(
"Backup has an <validity>invalid</validity> signature from " +
"<verify>unverified</verify> device <device></device>",
{}, sigStatusSubstitutions,
{}, { validity, verify, device },
);
}
@ -219,6 +249,7 @@ export default class KeyBackupPanel extends React.Component {
{_t("Backup version: ")}{this.state.backupInfo.version}<br />
{_t("Algorithm: ")}{this.state.backupInfo.algorithm}<br />
{clientBackupStatus}<br />
{uploadStatus}
<div>{backupSigStatuses}</div><br />
<br />
<AccessibleButton className="mx_UserSettings_button"