You've already forked matrix-react-sdk
mirror of
https://github.com/matrix-org/matrix-react-sdk.git
synced 2025-11-20 16:22:28 +03:00
Merge branches 'develop' and 'devtools_serverlist' of github.com:matrix-org/matrix-react-sdk into devtools_serverlist
This commit is contained in:
@@ -121,8 +121,10 @@ export default class AutoHideScrollbar extends React.Component {
|
||||
render() {
|
||||
return (<div
|
||||
ref={this._collectContainerRef}
|
||||
style={this.props.style}
|
||||
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
|
||||
onScroll={this.props.onScroll}
|
||||
onWheel={this.props.onWheel}
|
||||
>
|
||||
<div className="mx_AutoHideScrollbar_offset">
|
||||
{ this.props.children }
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import Velocity from 'velocity-vector';
|
||||
import 'velocity-vector/velocity.ui';
|
||||
import SettingsStore from '../../settings/SettingsStore';
|
||||
|
||||
const CALLOUT_ANIM_DURATION = 1000;
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'BottomLeftMenu',
|
||||
|
||||
propTypes: {
|
||||
collapsed: React.PropTypes.bool.isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return ({
|
||||
directoryHover: false,
|
||||
roomsHover: false,
|
||||
homeHover: false,
|
||||
peopleHover: false,
|
||||
settingsHover: false,
|
||||
});
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this._dispatcherRef = dis.register(this.onAction);
|
||||
this._peopleButton = null;
|
||||
this._directoryButton = null;
|
||||
this._createRoomButton = null;
|
||||
this._lastCallouts = {};
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
},
|
||||
|
||||
// Room events
|
||||
onDirectoryClick: function() {
|
||||
dis.dispatch({ action: 'view_room_directory' });
|
||||
},
|
||||
|
||||
onDirectoryMouseEnter: function() {
|
||||
this.setState({ directoryHover: true });
|
||||
},
|
||||
|
||||
onDirectoryMouseLeave: function() {
|
||||
this.setState({ directoryHover: false });
|
||||
},
|
||||
|
||||
onRoomsClick: function() {
|
||||
dis.dispatch({ action: 'view_create_room' });
|
||||
},
|
||||
|
||||
onRoomsMouseEnter: function() {
|
||||
this.setState({ roomsHover: true });
|
||||
},
|
||||
|
||||
onRoomsMouseLeave: function() {
|
||||
this.setState({ roomsHover: false });
|
||||
},
|
||||
|
||||
// Home button events
|
||||
onHomeClick: function() {
|
||||
dis.dispatch({ action: 'view_home_page' });
|
||||
},
|
||||
|
||||
onHomeMouseEnter: function() {
|
||||
this.setState({ homeHover: true });
|
||||
},
|
||||
|
||||
onHomeMouseLeave: function() {
|
||||
this.setState({ homeHover: false });
|
||||
},
|
||||
|
||||
// People events
|
||||
onPeopleClick: function() {
|
||||
dis.dispatch({ action: 'view_create_chat' });
|
||||
},
|
||||
|
||||
onPeopleMouseEnter: function() {
|
||||
this.setState({ peopleHover: true });
|
||||
},
|
||||
|
||||
onPeopleMouseLeave: function() {
|
||||
this.setState({ peopleHover: false });
|
||||
},
|
||||
|
||||
// Settings events
|
||||
onSettingsClick: function() {
|
||||
dis.dispatch({ action: 'view_user_settings' });
|
||||
},
|
||||
|
||||
onSettingsMouseEnter: function() {
|
||||
this.setState({ settingsHover: true });
|
||||
},
|
||||
|
||||
onSettingsMouseLeave: function() {
|
||||
this.setState({ settingsHover: false });
|
||||
},
|
||||
|
||||
onAction: function(payload) {
|
||||
let calloutElement;
|
||||
switch (payload.action) {
|
||||
// Incoming instruction: dance!
|
||||
case 'callout_start_chat':
|
||||
calloutElement = this._peopleButton;
|
||||
break;
|
||||
case 'callout_room_directory':
|
||||
calloutElement = this._directoryButton;
|
||||
break;
|
||||
case 'callout_create_room':
|
||||
calloutElement = this._createRoomButton;
|
||||
break;
|
||||
}
|
||||
if (calloutElement) {
|
||||
const lastCallout = this._lastCallouts[payload.action];
|
||||
const now = Date.now();
|
||||
if (lastCallout == undefined || lastCallout < now - CALLOUT_ANIM_DURATION) {
|
||||
this._lastCallouts[payload.action] = now;
|
||||
Velocity(ReactDOM.findDOMNode(calloutElement), "callout.bounce", CALLOUT_ANIM_DURATION);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Get the label/tooltip to show
|
||||
getLabel: function(label, show) {
|
||||
if (show) {
|
||||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
||||
return <Tooltip className="mx_BottomLeftMenu_tooltip" label={label} />;
|
||||
}
|
||||
},
|
||||
|
||||
_collectPeopleButton: function(e) {
|
||||
this._peopleButton = e;
|
||||
},
|
||||
|
||||
_collectDirectoryButton: function(e) {
|
||||
this._directoryButton = e;
|
||||
},
|
||||
|
||||
_collectCreateRoomButton: function(e) {
|
||||
this._createRoomButton = e;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const HomeButton = sdk.getComponent('elements.HomeButton');
|
||||
const StartChatButton = sdk.getComponent('elements.StartChatButton');
|
||||
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
|
||||
const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton');
|
||||
const SettingsButton = sdk.getComponent('elements.SettingsButton');
|
||||
const GroupsButton = sdk.getComponent('elements.GroupsButton');
|
||||
|
||||
const groupsButton = !SettingsStore.getValue("TagPanel.enableTagPanel") ?
|
||||
<GroupsButton tooltip={true} /> : null;
|
||||
|
||||
return (
|
||||
<div className="mx_BottomLeftMenu">
|
||||
<div className="mx_BottomLeftMenu_options">
|
||||
<HomeButton tooltip={true} />
|
||||
<div ref={this._collectPeopleButton}>
|
||||
<StartChatButton tooltip={true} />
|
||||
</div>
|
||||
<div ref={this._collectDirectoryButton}>
|
||||
<RoomDirectoryButton tooltip={true} />
|
||||
</div>
|
||||
<div ref={this._collectCreateRoomButton}>
|
||||
<CreateRoomButton tooltip={true} />
|
||||
</div>
|
||||
{ groupsButton }
|
||||
<span className="mx_BottomLeftMenu_settings">
|
||||
<SettingsButton tooltip={true} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -56,6 +56,7 @@ export default class ContextualMenu extends React.Component {
|
||||
menuPaddingRight: PropTypes.number,
|
||||
menuPaddingBottom: PropTypes.number,
|
||||
menuPaddingLeft: PropTypes.number,
|
||||
zIndex: PropTypes.number,
|
||||
|
||||
// If true, insert an invisible screen-sized element behind the
|
||||
// menu that when clicked will close it.
|
||||
@@ -215,16 +216,22 @@ export default class ContextualMenu extends React.Component {
|
||||
menuStyle["paddingRight"] = props.menuPaddingRight;
|
||||
}
|
||||
|
||||
const wrapperStyle = {};
|
||||
if (!isNaN(Number(props.zIndex))) {
|
||||
menuStyle["zIndex"] = props.zIndex + 1;
|
||||
wrapperStyle["zIndex"] = props.zIndex;
|
||||
}
|
||||
|
||||
const ElementClass = props.elementClass;
|
||||
|
||||
// FIXME: If a menu uses getDefaultProps it clobbers the onFinished
|
||||
// property set here so you can't close the menu from a button click!
|
||||
return <div className={className} style={position}>
|
||||
return <div className={className} style={{...position, ...wrapperStyle}}>
|
||||
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect}>
|
||||
{ chevron }
|
||||
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
|
||||
</div>
|
||||
{ props.hasBackground && <div className="mx_ContextualMenu_background"
|
||||
{ props.hasBackground && <div className="mx_ContextualMenu_background" style={wrapperStyle}
|
||||
onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
|
||||
<style>{ chevronCSS }</style>
|
||||
</div>;
|
||||
|
||||
@@ -123,6 +123,7 @@ const FilePanel = React.createClass({
|
||||
timelineSet={this.state.timelineSet}
|
||||
showUrlPreview = {false}
|
||||
tileShape="file_grid"
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
empty={_t('There are no visible files in this room')}
|
||||
/>
|
||||
);
|
||||
|
||||
38
src/components/structures/GenericErrorPage.js
Normal file
38
src/components/structures/GenericErrorPage.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright 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 PropTypes from 'prop-types';
|
||||
import {_t} from "../../languageHandler";
|
||||
|
||||
export default class GenericErrorPage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
message: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
return <div className='mx_GenericErrorPage'>
|
||||
<div className='mx_GenericErrorPage_box'>
|
||||
<h1>{_t("Error loading Riot")}</h1>
|
||||
<p>{this.props.message}</p>
|
||||
<p>{_t(
|
||||
"If this is unexpected, please contact your system administrator " +
|
||||
"or technical support representative.",
|
||||
)}</p>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,22 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
|
||||
export default class IndicatorScrollbar extends React.Component {
|
||||
static PropTypes = {
|
||||
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
|
||||
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
|
||||
// by the parent element.
|
||||
trackHorizontalOverflow: PropTypes.bool,
|
||||
|
||||
// If true, when the user tries to use their mouse wheel in the component it will
|
||||
// scroll horizontally rather than vertically. This should only be used on components
|
||||
// with no vertical scroll opportunity.
|
||||
verticalScrollsHorizontally: PropTypes.bool,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._collectScroller = this._collectScroller.bind(this);
|
||||
@@ -25,6 +38,18 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
this.checkOverflow = this.checkOverflow.bind(this);
|
||||
this._scrollElement = null;
|
||||
this._autoHideScrollbar = null;
|
||||
|
||||
this.state = {
|
||||
leftIndicatorOffset: 0,
|
||||
rightIndicatorOffset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
moveToOrigin() {
|
||||
if (!this._scrollElement) return;
|
||||
|
||||
this._scrollElement.scrollLeft = 0;
|
||||
this._scrollElement.scrollTop = 0;
|
||||
}
|
||||
|
||||
_collectScroller(scroller) {
|
||||
@@ -43,6 +68,10 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
const hasTopOverflow = this._scrollElement.scrollTop > 0;
|
||||
const hasBottomOverflow = this._scrollElement.scrollHeight >
|
||||
(this._scrollElement.scrollTop + this._scrollElement.clientHeight);
|
||||
const hasLeftOverflow = this._scrollElement.scrollLeft > 0;
|
||||
const hasRightOverflow = this._scrollElement.scrollWidth >
|
||||
(this._scrollElement.scrollLeft + this._scrollElement.clientWidth);
|
||||
|
||||
if (hasTopOverflow) {
|
||||
this._scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
|
||||
} else {
|
||||
@@ -53,10 +82,30 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
} else {
|
||||
this._scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
|
||||
}
|
||||
if (hasLeftOverflow) {
|
||||
this._scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow");
|
||||
} else {
|
||||
this._scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow");
|
||||
}
|
||||
if (hasRightOverflow) {
|
||||
this._scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow");
|
||||
} else {
|
||||
this._scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
|
||||
}
|
||||
|
||||
if (this._autoHideScrollbar) {
|
||||
this._autoHideScrollbar.checkOverflow();
|
||||
}
|
||||
|
||||
if (this.props.trackHorizontalOverflow) {
|
||||
this.setState({
|
||||
// Offset from absolute position of the container
|
||||
leftIndicatorOffset: hasLeftOverflow ? `${this._scrollElement.scrollLeft}px` : '0',
|
||||
|
||||
// Negative because we're coming from the right
|
||||
rightIndicatorOffset: hasRightOverflow ? `-${this._scrollElement.scrollLeft}px` : '0',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getScrollTop() {
|
||||
@@ -69,13 +118,41 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
onMouseWheel = (e) => {
|
||||
if (this.props.verticalScrollsHorizontally && this._scrollElement) {
|
||||
// xyThreshold is the amount of horizontal motion required for the component to
|
||||
// ignore the vertical delta in a scroll. Used to stop trackpads from acting in
|
||||
// strange ways. Should be positive.
|
||||
const xyThreshold = 0;
|
||||
|
||||
// yRetention is the factor multiplied by the vertical delta to try and reduce
|
||||
// the harshness of the scroll behaviour. Should be a value between 0 and 1.
|
||||
const yRetention = 1.0;
|
||||
|
||||
if (Math.abs(e.deltaX) < xyThreshold) {
|
||||
// noinspection JSSuspiciousNameCombination
|
||||
this._scrollElement.scrollLeft += e.deltaY * yRetention;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const leftIndicatorStyle = {left: this.state.leftIndicatorOffset};
|
||||
const rightIndicatorStyle = {right: this.state.rightIndicatorOffset};
|
||||
const leftOverflowIndicator = this.props.trackHorizontalOverflow
|
||||
? <div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} /> : null;
|
||||
const rightOverflowIndicator = this.props.trackHorizontalOverflow
|
||||
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
|
||||
|
||||
return (<AutoHideScrollbar
|
||||
ref={this._collectScrollerComponent}
|
||||
wrappedRef={this._collectScroller}
|
||||
onWheel={this.onMouseWheel}
|
||||
{... this.props}
|
||||
>
|
||||
{ leftOverflowIndicator }
|
||||
{ this.props.children }
|
||||
{ rightOverflowIndicator }
|
||||
</AutoHideScrollbar>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import VectorConferenceHandler from '../../VectorConferenceHandler';
|
||||
import TagPanelButtons from './TagPanelButtons';
|
||||
import SettingsStore from '../../settings/SettingsStore';
|
||||
import {_t} from "../../languageHandler";
|
||||
import Analytics from "../../Analytics";
|
||||
|
||||
|
||||
const LeftPanel = React.createClass({
|
||||
@@ -45,11 +46,23 @@ const LeftPanel = React.createClass({
|
||||
getInitialState: function() {
|
||||
return {
|
||||
searchFilter: '',
|
||||
breadcrumbs: false,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this.focusedElement = null;
|
||||
|
||||
this._settingWatchRef = SettingsStore.watchSetting(
|
||||
"feature_room_breadcrumbs", null, this._onBreadcrumbsChanged);
|
||||
|
||||
const useBreadcrumbs = SettingsStore.isFeatureEnabled("feature_room_breadcrumbs");
|
||||
Analytics.setBreadcrumbs(useBreadcrumbs);
|
||||
this.setState({breadcrumbs: useBreadcrumbs});
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
SettingsStore.unwatchSetting(this._settingWatchRef);
|
||||
},
|
||||
|
||||
shouldComponentUpdate: function(nextProps, nextState) {
|
||||
@@ -73,6 +86,22 @@ const LeftPanel = React.createClass({
|
||||
return false;
|
||||
},
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevState.breadcrumbs !== this.state.breadcrumbs) {
|
||||
Analytics.setBreadcrumbs(this.state.breadcrumbs);
|
||||
}
|
||||
},
|
||||
|
||||
_onBreadcrumbsChanged: function(settingName, roomId, level, valueAtLevel, value) {
|
||||
// Features are only possible at a single level, so we can get away with using valueAtLevel.
|
||||
// The SettingsStore runs on the same tick as the update, so `value` will be wrong.
|
||||
this.setState({breadcrumbs: valueAtLevel});
|
||||
|
||||
// For some reason the setState doesn't trigger a render of the component, so force one.
|
||||
// Probably has to do with the change happening outside of a change detector cycle.
|
||||
this.forceUpdate();
|
||||
},
|
||||
|
||||
_onFocus: function(ev) {
|
||||
this.focusedElement = ev.target;
|
||||
},
|
||||
@@ -220,7 +249,7 @@ const LeftPanel = React.createClass({
|
||||
collapsed={this.props.collapsed} />);
|
||||
|
||||
let breadcrumbs;
|
||||
if (SettingsStore.isFeatureEnabled("feature_room_breadcrumbs")) {
|
||||
if (this.state.breadcrumbs) {
|
||||
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
|
||||
}
|
||||
|
||||
@@ -234,14 +263,13 @@ const LeftPanel = React.createClass({
|
||||
<CallPreview ConferenceHandler={VectorConferenceHandler} />
|
||||
<RoomList
|
||||
ref={this.collectRoomList}
|
||||
toolbarShown={this.props.toolbarShown}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapsed}
|
||||
searchFilter={this.state.searchFilter}
|
||||
ConferenceHandler={VectorConferenceHandler} />
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
// <BottomLeftMenu collapsed={this.props.collapsed}/>
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import PropTypes from 'prop-types';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
|
||||
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
|
||||
import Notifier from '../../Notifier';
|
||||
import PageTypes from '../../PageTypes';
|
||||
import CallMediaHandler from '../../CallMediaHandler';
|
||||
import sdk from '../../index';
|
||||
@@ -121,6 +120,18 @@ const LoggedInView = React.createClass({
|
||||
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
|
||||
},
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// attempt to guess when a banner was opened or closed
|
||||
if (
|
||||
(prevProps.showCookieBar !== this.props.showCookieBar) ||
|
||||
(prevProps.hasNewVersion !== this.props.hasNewVersion) ||
|
||||
(prevProps.userHasGeneratedPassword !== this.props.userHasGeneratedPassword) ||
|
||||
(prevProps.showNotifierToolbar !== this.props.showNotifierToolbar)
|
||||
) {
|
||||
this.props.resizeNotifier.notifyBannersChanged();
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
document.removeEventListener('keydown', this._onKeyDown);
|
||||
this._matrixClient.removeListener("accountData", this.onAccountData);
|
||||
@@ -173,6 +184,7 @@ const LoggedInView = React.createClass({
|
||||
},
|
||||
onResized: (size) => {
|
||||
window.localStorage.setItem("mx_lhs_size", '' + size);
|
||||
this.props.resizeNotifier.notifyLeftHandleResized();
|
||||
},
|
||||
};
|
||||
const resizer = new Resizer(
|
||||
@@ -448,6 +460,7 @@ const LoggedInView = React.createClass({
|
||||
disabled={this.props.middleDisabled}
|
||||
collapsedRhs={this.props.collapsedRhs}
|
||||
ConferenceHandler={this.props.ConferenceHandler}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>;
|
||||
break;
|
||||
|
||||
@@ -489,7 +502,6 @@ const LoggedInView = React.createClass({
|
||||
});
|
||||
|
||||
let topBar;
|
||||
const isGuest = this.props.matrixClient.isGuest();
|
||||
if (this.state.syncErrorData && this.state.syncErrorData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
topBar = <ServerLimitBar kind='hard'
|
||||
adminContact={this.state.syncErrorData.error.data.admin_contact}
|
||||
@@ -513,10 +525,7 @@ const LoggedInView = React.createClass({
|
||||
topBar = <UpdateCheckBar {...this.props.checkingForUpdate} />;
|
||||
} else if (this.state.userHasGeneratedPassword) {
|
||||
topBar = <PasswordNagBar />;
|
||||
} else if (
|
||||
!isGuest && Notifier.supportsDesktopNotifications() &&
|
||||
!Notifier.isEnabled() && !Notifier.isToolbarHidden()
|
||||
) {
|
||||
} else if (this.props.showNotifierToolbar) {
|
||||
topBar = <MatrixToolbar />;
|
||||
}
|
||||
|
||||
@@ -534,7 +543,7 @@ const LoggedInView = React.createClass({
|
||||
<DragDropContext onDragEnd={this._onDragEnd}>
|
||||
<div ref={this._setResizeContainerRef} className={bodyClasses}>
|
||||
<LeftPanel
|
||||
toolbarShown={!!topBar}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapseLhs || false}
|
||||
disabled={this.props.leftDisabled}
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,9 @@ export default class MainSplit extends React.Component {
|
||||
|
||||
_onResized(size) {
|
||||
window.localStorage.setItem("mx_rhs_size", size);
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.notifyRightHandleResized();
|
||||
}
|
||||
}
|
||||
|
||||
_createResizer() {
|
||||
|
||||
@@ -29,6 +29,7 @@ import PlatformPeg from "../../PlatformPeg";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import * as RoomListSorter from "../../RoomListSorter";
|
||||
import dis from "../../dispatcher";
|
||||
import Notifier from '../../Notifier';
|
||||
|
||||
import Modal from "../../Modal";
|
||||
import Tinter from "../../Tinter";
|
||||
@@ -48,6 +49,7 @@ import { _t, getCurrentLanguage } from '../../languageHandler';
|
||||
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
|
||||
import { startAnyRegistrationFlow } from "../../Registration.js";
|
||||
import { messageForSyncError } from '../../utils/ErrorUtils';
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
|
||||
const AutoDiscovery = Matrix.AutoDiscovery;
|
||||
|
||||
@@ -194,6 +196,8 @@ export default React.createClass({
|
||||
hideToSRUsers: false,
|
||||
|
||||
syncError: null, // If the current syncing status is ERROR, the error object, otherwise null.
|
||||
resizeNotifier: new ResizeNotifier(),
|
||||
showNotifierToolbar: false,
|
||||
};
|
||||
return s;
|
||||
},
|
||||
@@ -245,17 +249,6 @@ export default React.createClass({
|
||||
return this.state.defaultIsUrl || "https://vector.im";
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether to skip the server details phase of registration and start at the
|
||||
* actual form.
|
||||
* @return {boolean}
|
||||
* If there was a configured default HS or default server name, skip the
|
||||
* the server details.
|
||||
*/
|
||||
skipServerDetailsForRegistration() {
|
||||
return !!this.state.defaultHsUrl;
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
SdkConfig.put(this.props.config);
|
||||
|
||||
@@ -316,6 +309,9 @@ export default React.createClass({
|
||||
// N.B. we don't call the whole of setTheme() here as we may be
|
||||
// racing with the theme CSS download finishing from index.js
|
||||
Tinter.tint();
|
||||
|
||||
// For PersistentElement
|
||||
this.state.resizeNotifier.on("middlePanelResized", this._dispatchTimelineResize);
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
@@ -398,6 +394,7 @@ export default React.createClass({
|
||||
dis.unregister(this.dispatcherRef);
|
||||
window.removeEventListener("focus", this.onFocus);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize);
|
||||
},
|
||||
|
||||
componentWillUpdate: function(props, state) {
|
||||
@@ -556,19 +553,8 @@ export default React.createClass({
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'view_user':
|
||||
// FIXME: ugly hack to expand the RightPanel and then re-dispatch.
|
||||
if (this.state.collapsedRhs) {
|
||||
setTimeout(()=>{
|
||||
dis.dispatch({
|
||||
action: 'show_right_panel',
|
||||
});
|
||||
dis.dispatch({
|
||||
action: 'view_user',
|
||||
member: payload.member,
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
case 'view_user_info':
|
||||
this._viewUser(payload.userId, payload.subAction);
|
||||
break;
|
||||
case 'view_room':
|
||||
// Takes either a room ID or room alias: if switching to a room the client is already
|
||||
@@ -588,8 +574,8 @@ export default React.createClass({
|
||||
break;
|
||||
case 'view_user_settings': {
|
||||
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
|
||||
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {}, 'mx_SettingsDialog',
|
||||
/*isPriority=*/false, /*isStatic=*/true);
|
||||
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {},
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
|
||||
// View the welcome or home page if we need something to look at
|
||||
this._viewSomethingBehindModal();
|
||||
@@ -638,8 +624,9 @@ export default React.createClass({
|
||||
case 'view_invite':
|
||||
showRoomInviteDialog(payload.roomId);
|
||||
break;
|
||||
case 'notifier_enabled':
|
||||
this.forceUpdate();
|
||||
case 'notifier_enabled': {
|
||||
this.setState({showNotifierToolbar: Notifier.shouldShowToolbar()});
|
||||
}
|
||||
break;
|
||||
case 'hide_left_panel':
|
||||
this.setState({
|
||||
@@ -923,6 +910,22 @@ export default React.createClass({
|
||||
this.notifyNewScreen('home');
|
||||
},
|
||||
|
||||
_viewUser: function(userId, subAction) {
|
||||
// Wait for the first sync so that `getRoom` gives us a room object if it's
|
||||
// in the sync response
|
||||
const waitForSync = this.firstSyncPromise ?
|
||||
this.firstSyncPromise.promise : Promise.resolve();
|
||||
waitForSync.then(() => {
|
||||
if (subAction === 'chat') {
|
||||
this._chatCreateOrReuse(userId);
|
||||
return;
|
||||
}
|
||||
this.notifyNewScreen('user/' + userId);
|
||||
this.setState({currentUserId: userId});
|
||||
this._setPage(PageTypes.UserView);
|
||||
});
|
||||
},
|
||||
|
||||
_setMxId: function(payload) {
|
||||
const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog');
|
||||
const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, {
|
||||
@@ -1058,34 +1061,48 @@ export default React.createClass({
|
||||
button: _t("Leave"),
|
||||
onFinished: (shouldLeave) => {
|
||||
if (shouldLeave) {
|
||||
const d = MatrixClientPeg.get().leave(roomId);
|
||||
const d = MatrixClientPeg.get().leaveRoomChain(roomId);
|
||||
|
||||
// FIXME: controller shouldn't be loading a view :(
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
|
||||
|
||||
d.then(() => {
|
||||
d.then((errors) => {
|
||||
modal.close();
|
||||
|
||||
for (const leftRoomId of Object.keys(errors)) {
|
||||
const err = errors[leftRoomId];
|
||||
if (!err) continue;
|
||||
|
||||
console.error("Failed to leave room " + leftRoomId + " " + err);
|
||||
let title = _t("Failed to leave room");
|
||||
let message = _t("Server may be unavailable, overloaded, or you hit a bug.");
|
||||
if (err.errcode === 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') {
|
||||
title = _t("Can't leave Server Notices room");
|
||||
message = _t(
|
||||
"This room is used for important messages from the Homeserver, " +
|
||||
"so you cannot leave it.",
|
||||
);
|
||||
} else if (err && err.message) {
|
||||
message = err.message;
|
||||
}
|
||||
Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, {
|
||||
title: title,
|
||||
description: message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.currentRoomId === roomId) {
|
||||
dis.dispatch({action: 'view_next_room'});
|
||||
}
|
||||
}, (err) => {
|
||||
// This should only happen if something went seriously wrong with leaving the chain.
|
||||
modal.close();
|
||||
console.error("Failed to leave room " + roomId + " " + err);
|
||||
let title = _t("Failed to leave room");
|
||||
let message = _t("Server may be unavailable, overloaded, or you hit a bug.");
|
||||
if (err.errcode == 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') {
|
||||
title = _t("Can't leave Server Notices room");
|
||||
message = _t(
|
||||
"This room is used for important messages from the Homeserver, " +
|
||||
"so you cannot leave it.",
|
||||
);
|
||||
} else if (err && err.message) {
|
||||
message = err.message;
|
||||
}
|
||||
Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, {
|
||||
title: title,
|
||||
description: message,
|
||||
title: _t("Failed to leave room"),
|
||||
description: _t("Unknown error"),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1173,7 +1190,7 @@ export default React.createClass({
|
||||
* Called when a new logged in session has started
|
||||
*/
|
||||
_onLoggedIn: async function() {
|
||||
this.setStateForNewView({view: VIEWS.LOGGED_IN});
|
||||
this.setStateForNewView({ view: VIEWS.LOGGED_IN });
|
||||
if (this._is_registered) {
|
||||
this._is_registered = false;
|
||||
|
||||
@@ -1306,7 +1323,10 @@ export default React.createClass({
|
||||
self.firstSyncPromise.resolve();
|
||||
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
self.setState({ready: true});
|
||||
self.setState({
|
||||
ready: true,
|
||||
showNotifierToolbar: Notifier.shouldShowToolbar(),
|
||||
});
|
||||
});
|
||||
cli.on('Call.incoming', function(call) {
|
||||
// we dispatch this synchronously to make sure that the event
|
||||
@@ -1535,7 +1555,16 @@ export default React.createClass({
|
||||
} else if (screen.indexOf('room/') == 0) {
|
||||
const segments = screen.substring(5).split('/');
|
||||
const roomString = segments[0];
|
||||
const eventId = segments[1]; // undefined if no event id given
|
||||
let eventId = segments.splice(1).join("/"); // empty string if no event id given
|
||||
|
||||
// Previously we pulled the eventID from the segments in such a way
|
||||
// where if there was no eventId then we'd get undefined. However, we
|
||||
// now do a splice and join to handle v3 event IDs which results in
|
||||
// an empty string. To maintain our potential contract with the rest
|
||||
// of the app, we coerce the eventId to be undefined where applicable.
|
||||
if (!eventId) eventId = undefined;
|
||||
|
||||
// TODO: Handle encoded room/event IDs: https://github.com/vector-im/riot-web/issues/9149
|
||||
|
||||
// FIXME: sort_out caseConsistency
|
||||
const thirdPartyInvite = {
|
||||
@@ -1579,19 +1608,10 @@ export default React.createClass({
|
||||
dis.dispatch(payload);
|
||||
} else if (screen.indexOf('user/') == 0) {
|
||||
const userId = screen.substring(5);
|
||||
|
||||
// Wait for the first sync so that `getRoom` gives us a room object if it's
|
||||
// in the sync response
|
||||
const waitFor = this.firstSyncPromise ?
|
||||
this.firstSyncPromise.promise : Promise.resolve();
|
||||
waitFor.then(() => {
|
||||
if (params.action === 'chat') {
|
||||
this._chatCreateOrReuse(userId);
|
||||
return;
|
||||
}
|
||||
this.notifyNewScreen('user/' + userId);
|
||||
this.setState({currentUserId: userId});
|
||||
this._setPage(PageTypes.UserView);
|
||||
dis.dispatch({
|
||||
action: 'view_user_info',
|
||||
userId: userId,
|
||||
subAction: params.action,
|
||||
});
|
||||
} else if (screen.indexOf('group/') == 0) {
|
||||
const groupId = screen.substring(6);
|
||||
@@ -1661,9 +1681,14 @@ export default React.createClass({
|
||||
dis.dispatch({ action: 'show_right_panel' });
|
||||
}
|
||||
|
||||
this.state.resizeNotifier.notifyWindowResized();
|
||||
this._windowWidth = window.innerWidth;
|
||||
},
|
||||
|
||||
_dispatchTimelineResize() {
|
||||
dis.dispatch({ action: 'timeline_resize' });
|
||||
},
|
||||
|
||||
onRoomCreated: function(roomId) {
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
@@ -1720,7 +1745,7 @@ export default React.createClass({
|
||||
hasCancelButton: false,
|
||||
});
|
||||
|
||||
return;
|
||||
return MatrixClientPeg.get();
|
||||
}
|
||||
}
|
||||
return Lifecycle.setLoggedIn(credentials);
|
||||
@@ -1759,7 +1784,7 @@ export default React.createClass({
|
||||
},
|
||||
|
||||
_setPageSubtitle: function(subtitle='') {
|
||||
document.title = `Riot ${subtitle}`;
|
||||
document.title = `${SdkConfig.get().brand || 'Riot'} ${subtitle}`;
|
||||
},
|
||||
|
||||
updateStatusIndicator: function(state, prevState) {
|
||||
@@ -1937,7 +1962,6 @@ export default React.createClass({
|
||||
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
|
||||
defaultHsUrl={this.getDefaultHsUrl()}
|
||||
defaultIsUrl={this.getDefaultIsUrl()}
|
||||
skipServerDetails={this.skipServerDetailsForRegistration()}
|
||||
brand={this.props.config.brand}
|
||||
customHsUrl={this.getCurrentHsUrl()}
|
||||
customIsUrl={this.getCurrentIsUrl()}
|
||||
@@ -1980,7 +2004,6 @@ export default React.createClass({
|
||||
fallbackHsUrl={this.getFallbackHsUrl()}
|
||||
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
|
||||
onForgotPasswordClick={this.onForgotPasswordClick}
|
||||
enableGuest={this.props.enableGuest}
|
||||
onServerConfigChange={this.onServerConfigChange}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,6 @@ import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import shouldHideEvent from '../../shouldHideEvent';
|
||||
import {wantsDateSeparator} from '../../DateUtils';
|
||||
import dis from "../../dispatcher";
|
||||
import sdk from '../../index';
|
||||
|
||||
import MatrixClientPeg from '../../MatrixClientPeg';
|
||||
@@ -628,16 +627,29 @@ module.exports = React.createClass({
|
||||
_onHeightChanged: function() {
|
||||
const scrollPanel = this.refs.scrollPanel;
|
||||
if (scrollPanel) {
|
||||
scrollPanel.forceUpdate();
|
||||
scrollPanel.checkScroll();
|
||||
}
|
||||
},
|
||||
|
||||
_onTypingVisible: function() {
|
||||
_onTypingShown: function() {
|
||||
const scrollPanel = this.refs.scrollPanel;
|
||||
// this will make the timeline grow, so checkScroll
|
||||
scrollPanel.checkScroll();
|
||||
if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
|
||||
// scroll down if at bottom
|
||||
scrollPanel.preventShrinking();
|
||||
}
|
||||
},
|
||||
|
||||
_onTypingHidden: function() {
|
||||
const scrollPanel = this.refs.scrollPanel;
|
||||
if (scrollPanel) {
|
||||
// as hiding the typing notifications doesn't
|
||||
// update the scrollPanel, we tell it to apply
|
||||
// the shrinking prevention once the typing notifs are hidden
|
||||
scrollPanel.updatePreventShrinking();
|
||||
// order is important here as checkScroll will scroll down to
|
||||
// reveal added padding to balance the notifs disappearing.
|
||||
scrollPanel.checkScroll();
|
||||
scrollPanel.blockShrinking();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -653,22 +665,18 @@ module.exports = React.createClass({
|
||||
// update the min-height, so once the last
|
||||
// person stops typing, no jumping occurs
|
||||
if (isAtBottom && isTypingVisible) {
|
||||
scrollPanel.blockShrinking();
|
||||
scrollPanel.preventShrinking();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearTimelineHeight: function() {
|
||||
onTimelineReset: function() {
|
||||
const scrollPanel = this.refs.scrollPanel;
|
||||
if (scrollPanel) {
|
||||
scrollPanel.clearBlockShrinking();
|
||||
scrollPanel.clearPreventShrinking();
|
||||
}
|
||||
},
|
||||
|
||||
onResize: function() {
|
||||
dis.dispatch({ action: 'timeline_resize' }, true);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
|
||||
const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile");
|
||||
@@ -693,7 +701,12 @@ module.exports = React.createClass({
|
||||
|
||||
let whoIsTyping;
|
||||
if (this.props.room) {
|
||||
whoIsTyping = (<WhoIsTypingTile room={this.props.room} onVisible={this._onTypingVisible} ref="whoIsTyping" />);
|
||||
whoIsTyping = (<WhoIsTypingTile
|
||||
room={this.props.room}
|
||||
onShown={this._onTypingShown}
|
||||
onHidden={this._onTypingHidden}
|
||||
ref="whoIsTyping" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -703,7 +716,8 @@ module.exports = React.createClass({
|
||||
onFillRequest={this.props.onFillRequest}
|
||||
onUnfillRequest={this.props.onUnfillRequest}
|
||||
style={style}
|
||||
stickyBottom={this.props.stickyBottom}>
|
||||
stickyBottom={this.props.stickyBottom}
|
||||
resizeNotifier={this.props.resizeNotifier}>
|
||||
{ topSpinner }
|
||||
{ this._getEventTiles() }
|
||||
{ whoIsTyping }
|
||||
|
||||
@@ -50,6 +50,7 @@ export default class RightPanel extends React.Component {
|
||||
FilePanel: 'FilePanel',
|
||||
NotificationPanel: 'NotificationPanel',
|
||||
RoomMemberInfo: 'RoomMemberInfo',
|
||||
Room3pidMemberInfo: 'Room3pidMemberInfo',
|
||||
GroupMemberInfo: 'GroupMemberInfo',
|
||||
});
|
||||
|
||||
@@ -155,6 +156,7 @@ export default class RightPanel extends React.Component {
|
||||
groupRoomId: payload.groupRoomId,
|
||||
groupId: payload.groupId,
|
||||
member: payload.member,
|
||||
event: payload.event,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -162,6 +164,7 @@ export default class RightPanel extends React.Component {
|
||||
render() {
|
||||
const MemberList = sdk.getComponent('rooms.MemberList');
|
||||
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
|
||||
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
|
||||
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
|
||||
const FilePanel = sdk.getComponent('structures.FilePanel');
|
||||
|
||||
@@ -180,6 +183,8 @@ export default class RightPanel extends React.Component {
|
||||
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.Room3pidMemberInfo) {
|
||||
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
|
||||
} else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) {
|
||||
panel = <GroupMemberInfo
|
||||
groupMember={this.state.member}
|
||||
@@ -193,7 +198,7 @@ export default class RightPanel extends React.Component {
|
||||
} else if (this.state.phase === RightPanel.Phase.NotificationPanel) {
|
||||
panel = <NotificationPanel />;
|
||||
} else if (this.state.phase === RightPanel.Phase.FilePanel) {
|
||||
panel = <FilePanel roomId={this.props.roomId} />;
|
||||
panel = <FilePanel roomId={this.props.roomId} resizeNotifier={this.props.resizeNotifier} />;
|
||||
}
|
||||
|
||||
const classes = classNames("mx_RightPanel", "mx_fadable", {
|
||||
|
||||
@@ -26,14 +26,17 @@ const dis = require('../../dispatcher');
|
||||
|
||||
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
|
||||
import Promise from 'bluebird';
|
||||
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
import {instanceForInstanceId, protocolNameForInstanceId} from '../../utils/DirectoryUtils';
|
||||
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
|
||||
import Analytics from '../../Analytics';
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 160;
|
||||
|
||||
function track(action) {
|
||||
Analytics.trackEvent('RoomDirectory', action);
|
||||
}
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'RoomDirectory',
|
||||
|
||||
@@ -53,6 +56,7 @@ module.exports = React.createClass({
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
protocolsLoading: true,
|
||||
error: null,
|
||||
instanceId: null,
|
||||
includeAll: false,
|
||||
roomServer: null,
|
||||
@@ -95,10 +99,12 @@ module.exports = React.createClass({
|
||||
// thing you see when loading the client!
|
||||
return;
|
||||
}
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to get protocol list from homeserver', '', ErrorDialog, {
|
||||
title: _t('Failed to get protocol list from homeserver'),
|
||||
description: _t('The homeserver may be too old to support third party networks'),
|
||||
track('Failed to get protocol list from homeserver');
|
||||
this.setState({
|
||||
error: _t(
|
||||
'Riot failed to get the protocol list from the homeserver. ' +
|
||||
'The homeserver may be too old to support third party networks.',
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,12 +193,14 @@ module.exports = React.createClass({
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: false });
|
||||
console.error("Failed to get publicRooms: %s", JSON.stringify(err));
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to get public room list', '', ErrorDialog, {
|
||||
title: _t('Failed to get public room list'),
|
||||
description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')),
|
||||
track('Failed to get public room list');
|
||||
this.setState({
|
||||
loading: false,
|
||||
error:
|
||||
`${_t('Riot failed to get the public room list.')} ` +
|
||||
`${(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')}`
|
||||
,
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -511,25 +519,15 @@ module.exports = React.createClass({
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
// TODO: clean this up
|
||||
if (this.state.protocolsLoading) {
|
||||
return (
|
||||
<div className="mx_RoomDirectory">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let content;
|
||||
if (this.state.loading) {
|
||||
content = <div className="mx_RoomDirectory">
|
||||
<Loader />
|
||||
</div>;
|
||||
if (this.state.error) {
|
||||
content = this.state.error;
|
||||
} else if (this.state.protocolsLoading || this.state.loading) {
|
||||
content = <Loader />;
|
||||
} else {
|
||||
const rows = this.getRows();
|
||||
// we still show the scrollpanel, at least for now, because
|
||||
@@ -551,39 +549,53 @@ module.exports = React.createClass({
|
||||
onFillRequest={ this.onFillRequest }
|
||||
stickyBottom={false}
|
||||
startAtBottom={false}
|
||||
onResize={function() {}}
|
||||
>
|
||||
{ scrollpanel_content }
|
||||
</ScrollPanel>;
|
||||
}
|
||||
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
let instance_expected_field_type;
|
||||
if (
|
||||
protocolName &&
|
||||
this.protocols &&
|
||||
this.protocols[protocolName] &&
|
||||
this.protocols[protocolName].location_fields.length > 0 &&
|
||||
this.protocols[protocolName].field_types
|
||||
) {
|
||||
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0];
|
||||
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
|
||||
}
|
||||
let listHeader;
|
||||
if (!this.state.protocolsLoading) {
|
||||
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
|
||||
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
|
||||
|
||||
|
||||
let placeholder = _t('Search for a room');
|
||||
if (!this.state.instanceId) {
|
||||
placeholder = _t('Search for a room like #example') + ':' + this.state.roomServer;
|
||||
} else if (instance_expected_field_type) {
|
||||
placeholder = instance_expected_field_type.placeholder;
|
||||
}
|
||||
|
||||
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type);
|
||||
if (protocolName) {
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
|
||||
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) {
|
||||
showJoinButton = false;
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
let instance_expected_field_type;
|
||||
if (
|
||||
protocolName &&
|
||||
this.protocols &&
|
||||
this.protocols[protocolName] &&
|
||||
this.protocols[protocolName].location_fields.length > 0 &&
|
||||
this.protocols[protocolName].field_types
|
||||
) {
|
||||
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0];
|
||||
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
|
||||
}
|
||||
|
||||
|
||||
let placeholder = _t('Search for a room');
|
||||
if (!this.state.instanceId) {
|
||||
placeholder = _t('Search for a room like #example') + ':' + this.state.roomServer;
|
||||
} else if (instance_expected_field_type) {
|
||||
placeholder = instance_expected_field_type.placeholder;
|
||||
}
|
||||
|
||||
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type);
|
||||
if (protocolName) {
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
|
||||
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) {
|
||||
showJoinButton = false;
|
||||
}
|
||||
}
|
||||
|
||||
listHeader = <div className="mx_RoomDirectory_listheader">
|
||||
<DirectorySearchBox
|
||||
className="mx_RoomDirectory_searchbox"
|
||||
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinClick}
|
||||
placeholder={placeholder} showJoinButton={showJoinButton}
|
||||
/>
|
||||
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
const createRoomButton = (<AccessibleButton
|
||||
@@ -591,8 +603,6 @@ module.exports = React.createClass({
|
||||
className="mx_RoomDirectory_createRoom"
|
||||
>{_t("Create new room")}</AccessibleButton>);
|
||||
|
||||
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
|
||||
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
|
||||
return (
|
||||
<BaseDialog
|
||||
className={'mx_RoomDirectory_dialog'}
|
||||
@@ -603,14 +613,7 @@ module.exports = React.createClass({
|
||||
>
|
||||
<div className="mx_RoomDirectory">
|
||||
<div className="mx_RoomDirectory_list">
|
||||
<div className="mx_RoomDirectory_listheader">
|
||||
<DirectorySearchBox
|
||||
className="mx_RoomDirectory_searchbox"
|
||||
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinClick}
|
||||
placeholder={placeholder} showJoinButton={showJoinButton}
|
||||
/>
|
||||
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
|
||||
</div>
|
||||
{listHeader}
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
@@ -146,8 +146,8 @@ const RoomSubList = React.createClass({
|
||||
key={room.roomId}
|
||||
collapsed={this.props.collapsed || false}
|
||||
unread={Unread.doesRoomHaveUnreadMessages(room)}
|
||||
highlight={room.getUnreadNotificationCount('highlight') > 0 || this.props.isInvite}
|
||||
notificationCount={room.getUnreadNotificationCount()}
|
||||
highlight={this.props.isInvite || RoomNotifs.getUnreadNotificationCount(room, 'highlight') > 0}
|
||||
notificationCount={RoomNotifs.getUnreadNotificationCount(room)}
|
||||
isInvite={this.props.isInvite}
|
||||
refreshSubList={this._updateSubListCount}
|
||||
incomingCall={null}
|
||||
|
||||
@@ -19,29 +19,28 @@ limitations under the License.
|
||||
// TODO: This component is enormous! There's several things which could stand-alone:
|
||||
// - Search results component
|
||||
// - Drag and drop
|
||||
// - File uploading - uploadFile()
|
||||
|
||||
import shouldHideEvent from "../../shouldHideEvent";
|
||||
import shouldHideEvent from '../../shouldHideEvent';
|
||||
|
||||
const React = require("react");
|
||||
const ReactDOM = require("react-dom");
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import Promise from 'bluebird';
|
||||
import filesize from 'filesize';
|
||||
const classNames = require("classnames");
|
||||
import classNames from 'classnames';
|
||||
import { _t } from '../../languageHandler';
|
||||
import {RoomPermalinkCreator} from "../../matrix-to";
|
||||
import {RoomPermalinkCreator} from '../../matrix-to';
|
||||
|
||||
const MatrixClientPeg = require("../../MatrixClientPeg");
|
||||
const ContentMessages = require("../../ContentMessages");
|
||||
const Modal = require("../../Modal");
|
||||
const sdk = require('../../index');
|
||||
const CallHandler = require('../../CallHandler');
|
||||
const dis = require("../../dispatcher");
|
||||
const Tinter = require("../../Tinter");
|
||||
const rate_limited_func = require('../../ratelimitedfunc');
|
||||
const ObjectUtils = require('../../ObjectUtils');
|
||||
const Rooms = require('../../Rooms');
|
||||
import MatrixClientPeg from '../../MatrixClientPeg';
|
||||
import ContentMessages from '../../ContentMessages';
|
||||
import Modal from '../../Modal';
|
||||
import sdk from '../../index';
|
||||
import CallHandler from '../../CallHandler';
|
||||
import dis from '../../dispatcher';
|
||||
import Tinter from '../../Tinter';
|
||||
import rate_limited_func from '../../ratelimitedfunc';
|
||||
import ObjectUtils from '../../ObjectUtils';
|
||||
import Rooms from '../../Rooms';
|
||||
|
||||
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
|
||||
|
||||
@@ -52,6 +51,7 @@ import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
|
||||
import WidgetEchoStore from '../../stores/WidgetEchoStore';
|
||||
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
|
||||
import WidgetUtils from '../../utils/WidgetUtils';
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
|
||||
const DEBUG = false;
|
||||
let debuglog = function() {};
|
||||
@@ -144,6 +144,7 @@ module.exports = React.createClass({
|
||||
// the end of the live timeline. It has the effect of hiding the
|
||||
// 'scroll to bottom' knob, among a couple of other things.
|
||||
atEndOfLiveTimeline: true,
|
||||
atEndOfLiveTimelineInit: false, // used by componentDidUpdate to avoid unnecessary checks
|
||||
|
||||
showTopUnreadMessagesBar: false,
|
||||
|
||||
@@ -168,7 +169,6 @@ module.exports = React.createClass({
|
||||
MatrixClientPeg.get().on("accountData", this.onAccountData);
|
||||
MatrixClientPeg.get().on("crypto.keyBackupStatus", this.onKeyBackupStatus);
|
||||
MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged);
|
||||
this._fetchMediaConfig();
|
||||
// Start listening for RoomViewStore updates
|
||||
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
|
||||
this._onRoomViewStoreUpdate(true);
|
||||
@@ -176,27 +176,6 @@ module.exports = React.createClass({
|
||||
WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate);
|
||||
},
|
||||
|
||||
_fetchMediaConfig: function(invalidateCache: boolean = false) {
|
||||
/// NOTE: Using global here so we don't make repeated requests for the
|
||||
/// config every time we swap room.
|
||||
if(global.mediaConfig !== undefined && !invalidateCache) {
|
||||
this.setState({mediaConfig: global.mediaConfig});
|
||||
return;
|
||||
}
|
||||
console.log("[Media Config] Fetching");
|
||||
MatrixClientPeg.get().getMediaConfig().then((config) => {
|
||||
console.log("[Media Config] Fetched config:", config);
|
||||
return config;
|
||||
}).catch(() => {
|
||||
// Media repo can't or won't report limits, so provide an empty object (no limits).
|
||||
console.log("[Media Config] Could not fetch config, so not limiting uploads.");
|
||||
return {};
|
||||
}).then((config) => {
|
||||
global.mediaConfig = config;
|
||||
this.setState({mediaConfig: config});
|
||||
});
|
||||
},
|
||||
|
||||
_onRoomViewStoreUpdate: function(initial) {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
@@ -293,6 +272,28 @@ module.exports = React.createClass({
|
||||
return this.state.room ? this.state.room.roomId : this.state.roomId;
|
||||
},
|
||||
|
||||
_getPermalinkCreatorForRoom: function(room) {
|
||||
if (!this._permalinkCreators) this._permalinkCreators = {};
|
||||
if (this._permalinkCreators[room.roomId]) return this._permalinkCreators[room.roomId];
|
||||
|
||||
this._permalinkCreators[room.roomId] = new RoomPermalinkCreator(room);
|
||||
if (this.state.room && room.roomId === this.state.room.roomId) {
|
||||
// We want to watch for changes in the creator for the primary room in the view, but
|
||||
// don't need to do so for search results.
|
||||
this._permalinkCreators[room.roomId].start();
|
||||
} else {
|
||||
this._permalinkCreators[room.roomId].load();
|
||||
}
|
||||
return this._permalinkCreators[room.roomId];
|
||||
},
|
||||
|
||||
_stopAllPermalinkCreators: function() {
|
||||
if (!this._permalinkCreators) return;
|
||||
for (const roomId of Object.keys(this._permalinkCreators)) {
|
||||
this._permalinkCreators[roomId].stop();
|
||||
}
|
||||
},
|
||||
|
||||
_onWidgetEchoStoreUpdate: function() {
|
||||
this.setState({
|
||||
showApps: this._shouldShowApps(this.state.room),
|
||||
@@ -392,7 +393,9 @@ module.exports = React.createClass({
|
||||
this._updateConfCallNotification();
|
||||
|
||||
window.addEventListener('beforeunload', this.onPageUnload);
|
||||
window.addEventListener('resize', this.onResize);
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
|
||||
}
|
||||
this.onResize();
|
||||
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
@@ -428,6 +431,18 @@ module.exports = React.createClass({
|
||||
roomView.addEventListener('dragend', this.onDragLeaveOrEnd);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We check the ref here with a flag because componentDidMount, despite
|
||||
// documentation, does not define our messagePanel ref. It looks like our spinner
|
||||
// in render() prevents the ref from being set on first mount, so we try and
|
||||
// catch the messagePanel when it does mount. Because we only want the ref once,
|
||||
// we use a boolean flag to avoid duplicate work.
|
||||
if (this.refs.messagePanel && !this.state.atEndOfLiveTimelineInit) {
|
||||
this.setState({
|
||||
atEndOfLiveTimelineInit: true,
|
||||
atEndOfLiveTimeline: this.refs.messagePanel.isAtEndOfLiveTimeline(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
@@ -443,9 +458,7 @@ module.exports = React.createClass({
|
||||
}
|
||||
|
||||
// stop tracking room changes to format permalinks
|
||||
if (this.state.permalinkCreator) {
|
||||
this.state.permalinkCreator.stop();
|
||||
}
|
||||
this._stopAllPermalinkCreators();
|
||||
|
||||
if (this.refs.roomView) {
|
||||
// disconnect the D&D event listeners from the room view. This
|
||||
@@ -472,7 +485,9 @@ module.exports = React.createClass({
|
||||
}
|
||||
|
||||
window.removeEventListener('beforeunload', this.onPageUnload);
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
|
||||
}
|
||||
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
|
||||
@@ -492,7 +507,7 @@ module.exports = React.createClass({
|
||||
},
|
||||
|
||||
onPageUnload(event) {
|
||||
if (ContentMessages.getCurrentUploads().length > 0) {
|
||||
if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) {
|
||||
return event.returnValue =
|
||||
_t("You seem to be uploading files, are you sure you want to quit?");
|
||||
} else if (this._getCallForRoom() && this.state.callState !== 'ended') {
|
||||
@@ -541,16 +556,14 @@ module.exports = React.createClass({
|
||||
payload.data.description || payload.data.name);
|
||||
break;
|
||||
case 'picture_snapshot':
|
||||
this.uploadFile(payload.file);
|
||||
return ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
[payload.file], this.state.room.roomId, MatrixClientPeg.get(),
|
||||
);
|
||||
break;
|
||||
case 'upload_failed':
|
||||
// 413: File was too big or upset the server in some way.
|
||||
if (payload.error && payload.error.http_status === 413) {
|
||||
this._fetchMediaConfig(true);
|
||||
}
|
||||
case 'notifier_enabled':
|
||||
case 'upload_started':
|
||||
case 'upload_finished':
|
||||
case 'upload_canceled':
|
||||
this.forceUpdate();
|
||||
break;
|
||||
case 'call_state':
|
||||
@@ -658,11 +671,6 @@ module.exports = React.createClass({
|
||||
this._loadMembersIfJoined(room);
|
||||
this._calculateRecommendedVersion(room);
|
||||
this._updateE2EStatus(room);
|
||||
if (!this.state.permalinkCreator) {
|
||||
const permalinkCreator = new RoomPermalinkCreator(room);
|
||||
permalinkCreator.start();
|
||||
this.setState({permalinkCreator});
|
||||
}
|
||||
},
|
||||
|
||||
_calculateRecommendedVersion: async function(room) {
|
||||
@@ -738,8 +746,19 @@ module.exports = React.createClass({
|
||||
if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) {
|
||||
return;
|
||||
}
|
||||
if (!MatrixClientPeg.get().isCryptoEnabled()) {
|
||||
// If crypto is not currently enabled, we aren't tracking devices at all,
|
||||
// so we don't know what the answer is. Let's error on the safe side and show
|
||||
// a warning for this case.
|
||||
this.setState({
|
||||
e2eStatus: "warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
room.hasUnverifiedDevices().then((hasUnverifiedDevices) => {
|
||||
this.setState({e2eStatus: hasUnverifiedDevices ? "warning" : "verified"});
|
||||
this.setState({
|
||||
e2eStatus: hasUnverifiedDevices ? "warning" : "verified",
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -865,10 +884,6 @@ module.exports = React.createClass({
|
||||
}
|
||||
},
|
||||
|
||||
onSearchResultsResize: function() {
|
||||
dis.dispatch({ action: 'timeline_resize' }, true);
|
||||
},
|
||||
|
||||
onSearchResultsFillRequest: function(backwards) {
|
||||
if (!backwards) {
|
||||
return Promise.resolve(false);
|
||||
@@ -1001,9 +1016,11 @@ module.exports = React.createClass({
|
||||
onDrop: function(ev) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
ev.dataTransfer.files, this.state.room.roomId, MatrixClientPeg.get(),
|
||||
);
|
||||
this.setState({ draggingFile: false });
|
||||
const files = [...ev.dataTransfer.files];
|
||||
files.forEach(this.uploadFile);
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
},
|
||||
|
||||
onDragLeaveOrEnd: function(ev) {
|
||||
@@ -1012,55 +1029,13 @@ module.exports = React.createClass({
|
||||
this.setState({ draggingFile: false });
|
||||
},
|
||||
|
||||
isFileUploadAllowed(file) {
|
||||
if (this.state.mediaConfig !== undefined &&
|
||||
this.state.mediaConfig["m.upload.size"] !== undefined &&
|
||||
file.size > this.state.mediaConfig["m.upload.size"]) {
|
||||
return _t("File is too big. Maximum file size is %(fileSize)s", {fileSize: filesize(this.state.mediaConfig["m.upload.size"])});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
uploadFile: async function(file) {
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
dis.dispatch({action: 'require_registration'});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ContentMessages.sendContentToRoom(file, this.state.room.roomId, MatrixClientPeg.get());
|
||||
} catch (error) {
|
||||
if (error.name === "UnknownDeviceError") {
|
||||
// Let the status bar handle this
|
||||
return;
|
||||
}
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to upload file " + file + " " + error);
|
||||
Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, {
|
||||
title: _t('Failed to upload file'),
|
||||
description: ((error && error.message)
|
||||
? error.message : _t("Server may be unavailable, overloaded, or the file too big")),
|
||||
});
|
||||
|
||||
// bail early to avoid calling the dispatch below
|
||||
return;
|
||||
}
|
||||
|
||||
// Send message_sent callback, for things like _checkIfAlone because after all a file is still a message.
|
||||
dis.dispatch({
|
||||
action: 'message_sent',
|
||||
});
|
||||
},
|
||||
|
||||
injectSticker: function(url, info, text) {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
dis.dispatch({action: 'require_registration'});
|
||||
return;
|
||||
}
|
||||
|
||||
ContentMessages.sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get())
|
||||
ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get())
|
||||
.done(undefined, (error) => {
|
||||
if (error.name === "UnknownDeviceError") {
|
||||
// Let the staus bar handle this
|
||||
@@ -1209,6 +1184,7 @@ module.exports = React.createClass({
|
||||
|
||||
const mxEv = result.context.getEvent();
|
||||
const roomId = mxEv.getRoomId();
|
||||
const room = cli.getRoom(roomId);
|
||||
|
||||
if (!EventTile.haveTileForEvent(mxEv)) {
|
||||
// XXX: can this ever happen? It will make the result count
|
||||
@@ -1218,7 +1194,6 @@ module.exports = React.createClass({
|
||||
|
||||
if (this.state.searchScope === 'All') {
|
||||
if (roomId != lastRoomId) {
|
||||
const room = cli.getRoom(roomId);
|
||||
|
||||
// XXX: if we've left the room, we might not know about
|
||||
// it. We should tell the js sdk to go and find out about
|
||||
@@ -1239,7 +1214,7 @@ module.exports = React.createClass({
|
||||
searchResult={result}
|
||||
searchHighlights={this.state.searchHighlights}
|
||||
resultLink={resultLink}
|
||||
permalinkCreator={this.state.permalinkCreator}
|
||||
permalinkCreator={this._getPermalinkCreatorForRoom(room)}
|
||||
onHeightChanged={onHeightChanged} />);
|
||||
}
|
||||
return ret;
|
||||
@@ -1364,8 +1339,7 @@ module.exports = React.createClass({
|
||||
|
||||
const showBar = this.refs.messagePanel.canJumpToReadMarker();
|
||||
if (this.state.showTopUnreadMessagesBar != showBar) {
|
||||
this.setState({showTopUnreadMessagesBar: showBar},
|
||||
this.onChildResize);
|
||||
this.setState({showTopUnreadMessagesBar: showBar});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1408,7 +1382,7 @@ module.exports = React.createClass({
|
||||
};
|
||||
},
|
||||
|
||||
onResize: function(e) {
|
||||
onResize: function() {
|
||||
// It seems flexbox doesn't give us a way to constrain the auxPanel height to have
|
||||
// a minimum of the height of the video element, whilst also capping it from pushing out the page
|
||||
// so we have to do it via JS instead. In this implementation we cap the height by putting
|
||||
@@ -1426,9 +1400,6 @@ module.exports = React.createClass({
|
||||
if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
|
||||
|
||||
this.setState({auxPanelMaxHeight: auxPanelMaxHeight});
|
||||
|
||||
// changing the maxHeight on the auxpanel will trigger a callback go
|
||||
// onChildResize, so no need to worry about that here.
|
||||
},
|
||||
|
||||
onFullscreenClick: function() {
|
||||
@@ -1458,10 +1429,6 @@ module.exports = React.createClass({
|
||||
this.forceUpdate(); // TODO: just update the voip buttons
|
||||
},
|
||||
|
||||
onChildResize: function() {
|
||||
// no longer anything to do here
|
||||
},
|
||||
|
||||
onStatusBarVisible: function() {
|
||||
if (this.unmounted) return;
|
||||
this.setState({
|
||||
@@ -1515,6 +1482,25 @@ module.exports = React.createClass({
|
||||
}
|
||||
},
|
||||
|
||||
_getOldRoom: function() {
|
||||
const createEvent = this.state.room.currentState.getStateEvents("m.room.create", "");
|
||||
if (!createEvent || !createEvent.getContent()['predecessor']) return null;
|
||||
|
||||
return MatrixClientPeg.get().getRoom(createEvent.getContent()['predecessor']['room_id']);
|
||||
},
|
||||
|
||||
_getHiddenHighlightCount: function() {
|
||||
const oldRoom = this._getOldRoom();
|
||||
if (!oldRoom) return 0;
|
||||
return oldRoom.getUnreadNotificationCount('highlight');
|
||||
},
|
||||
|
||||
_onHiddenHighlightsClick: function() {
|
||||
const oldRoom = this._getOldRoom();
|
||||
if (!oldRoom) return;
|
||||
dis.dispatch({action: "view_room", room_id: oldRoom.roomId});
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const RoomHeader = sdk.getComponent('rooms.RoomHeader');
|
||||
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
|
||||
@@ -1525,16 +1511,21 @@ module.exports = React.createClass({
|
||||
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
const RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar");
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
|
||||
const RoomUpgradeWarningBar = sdk.getComponent("rooms.RoomUpgradeWarningBar");
|
||||
const RoomRecoveryReminder = sdk.getComponent("rooms.RoomRecoveryReminder");
|
||||
|
||||
if (!this.state.room) {
|
||||
if (this.state.roomLoading || this.state.peekLoading) {
|
||||
const loading = this.state.roomLoading || this.state.peekLoading;
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
<Loader />
|
||||
<RoomPreviewBar
|
||||
canPreview={false}
|
||||
error={this.state.roomLoadError}
|
||||
loading={loading}
|
||||
joining={this.state.joining}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
@@ -1552,28 +1543,16 @@ module.exports = React.createClass({
|
||||
const roomAlias = this.state.roomAlias;
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
<RoomHeader ref="header"
|
||||
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectThreepidInviteButtonClicked}
|
||||
canPreview={false} error={this.state.roomLoadError}
|
||||
roomAlias={roomAlias}
|
||||
joining={this.state.joining}
|
||||
inviterName={inviterName}
|
||||
invitedEmail={invitedEmail}
|
||||
room={this.state.room}
|
||||
oobData={this.props.oobData}
|
||||
collapsedRhs={this.props.collapsedRhs}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1583,9 +1562,12 @@ module.exports = React.createClass({
|
||||
if (myMembership == 'invite') {
|
||||
if (this.state.joining || this.state.rejecting) {
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
<Loader />
|
||||
</div>
|
||||
<RoomPreviewBar
|
||||
canPreview={false}
|
||||
error={this.state.roomLoadError}
|
||||
joining={this.state.joining}
|
||||
rejecting={this.state.rejecting}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
@@ -1600,26 +1582,14 @@ module.exports = React.createClass({
|
||||
// We have a regular invite for this room.
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
<RoomHeader
|
||||
ref="header"
|
||||
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectButtonClicked}
|
||||
inviterName={inviterName}
|
||||
canPreview={false}
|
||||
joining={this.state.joining}
|
||||
room={this.state.room}
|
||||
collapsedRhs={this.props.collapsedRhs}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1641,7 +1611,7 @@ module.exports = React.createClass({
|
||||
let statusBar;
|
||||
let isStatusAreaExpanded = true;
|
||||
|
||||
if (ContentMessages.getCurrentUploads().length > 0) {
|
||||
if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) {
|
||||
const UploadBar = sdk.getComponent('structures.UploadBar');
|
||||
statusBar = <UploadBar room={this.state.room} />;
|
||||
} else if (!this.state.searchResults) {
|
||||
@@ -1654,7 +1624,6 @@ module.exports = React.createClass({
|
||||
isPeeking={myMembership !== "join"}
|
||||
onInviteClick={this.onInviteButtonClick}
|
||||
onStopWarningClick={this.onStopAloneWarningClick}
|
||||
onResize={this.onChildResize}
|
||||
onVisible={this.onStatusBarVisible}
|
||||
onHidden={this.onStatusBarHidden}
|
||||
/>;
|
||||
@@ -1673,8 +1642,12 @@ module.exports = React.createClass({
|
||||
!MatrixClientPeg.get().getKeyBackupEnabled()
|
||||
);
|
||||
|
||||
const hiddenHighlightCount = this._getHiddenHighlightCount();
|
||||
|
||||
let aux = null;
|
||||
let previewBar;
|
||||
let hideCancel = false;
|
||||
let hideRightPanel = false;
|
||||
if (this.state.forwardingEvent !== null) {
|
||||
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
|
||||
} else if (this.state.searching) {
|
||||
@@ -1701,18 +1674,36 @@ module.exports = React.createClass({
|
||||
invitedEmail = this.props.thirdPartyInvite.invitedEmail;
|
||||
}
|
||||
hideCancel = true;
|
||||
aux = (
|
||||
previewBar = (
|
||||
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectThreepidInviteButtonClicked}
|
||||
spinner={this.state.joining}
|
||||
spinnerState="joining"
|
||||
joining={this.state.joining}
|
||||
inviterName={inviterName}
|
||||
invitedEmail={invitedEmail}
|
||||
canPreview={this.state.canPeek}
|
||||
room={this.state.room}
|
||||
/>
|
||||
);
|
||||
if (!this.state.canPeek) {
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
{ previewBar }
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
hideRightPanel = true;
|
||||
}
|
||||
} else if (hiddenHighlightCount > 0) {
|
||||
aux = (
|
||||
<AccessibleButton element="div" className="mx_RoomView_auxPanel_hiddenHighlights"
|
||||
onClick={this._onHiddenHighlightsClick}>
|
||||
{_t(
|
||||
"You have %(count)s unread notifications in a prior version of this room.",
|
||||
{count: hiddenHighlightCount},
|
||||
)}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
const auxPanel = (
|
||||
@@ -1723,7 +1714,6 @@ module.exports = React.createClass({
|
||||
draggingFile={this.state.draggingFile}
|
||||
displayConfCallNotification={this.state.displayConfCallNotification}
|
||||
maxHeight={this.state.auxPanelMaxHeight}
|
||||
onResize={this.onChildResize}
|
||||
showApps={this.state.showApps}
|
||||
hideAppsDrawer={false} >
|
||||
{ aux }
|
||||
@@ -1739,22 +1729,14 @@ module.exports = React.createClass({
|
||||
messageComposer =
|
||||
<MessageComposer
|
||||
room={this.state.room}
|
||||
onResize={this.onChildResize}
|
||||
uploadFile={this.uploadFile}
|
||||
callState={this.state.callState}
|
||||
disabled={this.props.disabled}
|
||||
showApps={this.state.showApps}
|
||||
uploadAllowed={this.isFileUploadAllowed}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
permalinkCreator={this.state.permalinkCreator}
|
||||
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
|
||||
/>;
|
||||
}
|
||||
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
const AuthButtons = sdk.getComponent('views.auth.AuthButtons');
|
||||
messageComposer = <AuthButtons />;
|
||||
}
|
||||
|
||||
// TODO: Why aren't we storing the term/scope/count in this format
|
||||
// in this.state if this is what RoomHeader desires?
|
||||
if (this.state.searchResults) {
|
||||
@@ -1814,7 +1796,7 @@ module.exports = React.createClass({
|
||||
<ScrollPanel ref="searchResultsPanel"
|
||||
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
|
||||
onFillRequest={this.onSearchResultsFillRequest}
|
||||
onResize={this.onSearchResultsResize}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
>
|
||||
<li className={scrollheader_classes}></li>
|
||||
{ this.getSearchResultTiles() }
|
||||
@@ -1848,7 +1830,8 @@ module.exports = React.createClass({
|
||||
showUrlPreview = {this.state.showUrlPreview}
|
||||
className="mx_RoomView_messagePanel"
|
||||
membersLoaded={this.state.membersLoaded}
|
||||
permalinkCreator={this.state.permalinkCreator}
|
||||
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>);
|
||||
|
||||
let topUnreadMessagesBar = null;
|
||||
@@ -1881,14 +1864,16 @@ module.exports = React.createClass({
|
||||
},
|
||||
);
|
||||
|
||||
const rightPanel = this.state.room ? <RightPanel roomId={this.state.room.roomId} /> : undefined;
|
||||
const rightPanel = !hideRightPanel && this.state.room &&
|
||||
<RightPanel roomId={this.state.room.roomId} resizeNotifier={this.props.resizeNotifier} />;
|
||||
const collapsedRhs = hideRightPanel || this.props.collapsedRhs;
|
||||
|
||||
return (
|
||||
<main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView">
|
||||
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
|
||||
oobData={this.props.oobData}
|
||||
inRoom={myMembership === 'join'}
|
||||
collapsedRhs={this.props.collapsedRhs}
|
||||
collapsedRhs={collapsedRhs}
|
||||
onSearchClick={this.onSearchClick}
|
||||
onSettingsClick={this.onSettingsClick}
|
||||
onPinnedClick={this.onPinnedClick}
|
||||
@@ -1897,7 +1882,11 @@ module.exports = React.createClass({
|
||||
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
/>
|
||||
<MainSplit panel={rightPanel} collapsedRhs={this.props.collapsedRhs}>
|
||||
<MainSplit
|
||||
panel={rightPanel}
|
||||
collapsedRhs={collapsedRhs}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
>
|
||||
<div className={fadableSectionClasses}>
|
||||
{ auxPanel }
|
||||
<div className="mx_RoomView_timeline">
|
||||
@@ -1912,6 +1901,7 @@ module.exports = React.createClass({
|
||||
{ statusBar }
|
||||
</div>
|
||||
</div>
|
||||
{ previewBar }
|
||||
{ messageComposer }
|
||||
</div>
|
||||
</MainSplit>
|
||||
|
||||
@@ -15,14 +15,13 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const ReactDOM = require("react-dom");
|
||||
import PropTypes from 'prop-types';
|
||||
import Promise from 'bluebird';
|
||||
import { KeyCode } from '../../Keyboard';
|
||||
import sdk from '../../index.js';
|
||||
import Timer from '../../utils/Timer';
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
|
||||
const DEBUG_SCROLL = false;
|
||||
// var DEBUG_SCROLL = true;
|
||||
|
||||
// The amount of extra scroll distance to allow prior to unfilling.
|
||||
// See _getExcessHeight.
|
||||
@@ -30,12 +29,18 @@ const UNPAGINATION_PADDING = 6000;
|
||||
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent
|
||||
// many scroll events causing many unfilling requests.
|
||||
const UNFILL_REQUEST_DEBOUNCE_MS = 200;
|
||||
// _updateHeight makes the height a ceiled multiple of this so we
|
||||
// don't have to update the height too often. It also allows the user
|
||||
// to scroll past the pagination spinner a bit so they don't feel blocked so
|
||||
// much while the content loads.
|
||||
const PAGE_SIZE = 400;
|
||||
|
||||
let debuglog;
|
||||
if (DEBUG_SCROLL) {
|
||||
// using bind means that we get to keep useful line numbers in the console
|
||||
var debuglog = console.log.bind(console);
|
||||
debuglog = console.log.bind(console, "ScrollPanel debuglog:");
|
||||
} else {
|
||||
var debuglog = function() {};
|
||||
debuglog = function() {};
|
||||
}
|
||||
|
||||
/* This component implements an intelligent scrolling list.
|
||||
@@ -129,11 +134,6 @@ module.exports = React.createClass({
|
||||
*/
|
||||
onScroll: PropTypes.func,
|
||||
|
||||
/* onResize: a callback which is called whenever the Gemini scroll
|
||||
* panel is resized
|
||||
*/
|
||||
onResize: PropTypes.func,
|
||||
|
||||
/* className: classnames to add to the top-level div
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
@@ -141,6 +141,9 @@ module.exports = React.createClass({
|
||||
/* style: styles to add to the top-level div
|
||||
*/
|
||||
style: PropTypes.object,
|
||||
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
|
||||
*/
|
||||
resizeNotifier: PropTypes.object,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
@@ -150,12 +153,18 @@ module.exports = React.createClass({
|
||||
onFillRequest: function(backwards) { return Promise.resolve(false); },
|
||||
onUnfillRequest: function(backwards, scrollToken) {},
|
||||
onScroll: function() {},
|
||||
onResize: function() {},
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this._fillRequestWhileRunning = false;
|
||||
this._isFilling = false;
|
||||
this._pendingFillRequests = {b: null, f: null};
|
||||
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
|
||||
}
|
||||
|
||||
this.resetScrollState();
|
||||
},
|
||||
|
||||
@@ -170,6 +179,7 @@ module.exports = React.createClass({
|
||||
//
|
||||
// This will also re-check the fill state, in case the paginate was inadequate
|
||||
this.checkScroll();
|
||||
this.updatePreventShrinking();
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
@@ -178,81 +188,49 @@ module.exports = React.createClass({
|
||||
//
|
||||
// (We could use isMounted(), but facebook have deprecated that.)
|
||||
this.unmounted = true;
|
||||
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
|
||||
}
|
||||
},
|
||||
|
||||
onScroll: function(ev) {
|
||||
const sn = this._getScrollNode();
|
||||
debuglog("Scroll event: offset now:", sn.scrollTop,
|
||||
"_lastSetScroll:", this._lastSetScroll);
|
||||
|
||||
// Sometimes we see attempts to write to scrollTop essentially being
|
||||
// ignored. (Or rather, it is successfully written, but on the next
|
||||
// scroll event, it's been reset again).
|
||||
//
|
||||
// This was observed on Chrome 47, when scrolling using the trackpad in OS
|
||||
// X Yosemite. Can't reproduce on El Capitan. Our theory is that this is
|
||||
// due to Chrome not being able to cope with the scroll offset being reset
|
||||
// while a two-finger drag is in progress.
|
||||
//
|
||||
// By way of a workaround, we detect this situation and just keep
|
||||
// resetting scrollTop until we see the scroll node have the right
|
||||
// value.
|
||||
if (this._lastSetScroll !== undefined && sn.scrollTop < this._lastSetScroll-200) {
|
||||
console.log("Working around vector-im/vector-web#528");
|
||||
this._restoreSavedScrollState();
|
||||
return;
|
||||
}
|
||||
|
||||
// If there weren't enough children to fill the viewport, the scroll we
|
||||
// got might be different to the scroll we wanted; we don't want to
|
||||
// forget what we wanted, so don't overwrite the saved state unless
|
||||
// this appears to be a user-initiated scroll.
|
||||
if (sn.scrollTop != this._lastSetScroll) {
|
||||
this._saveScrollState();
|
||||
} else {
|
||||
debuglog("Ignoring scroll echo");
|
||||
// only ignore the echo once, otherwise we'll get confused when the
|
||||
// user scrolls away from, and back to, the autoscroll point.
|
||||
this._lastSetScroll = undefined;
|
||||
}
|
||||
|
||||
this._checkBlockShrinking();
|
||||
|
||||
debuglog("onScroll", this._getScrollNode().scrollTop);
|
||||
this._scrollTimeout.restart();
|
||||
this._saveScrollState();
|
||||
this.updatePreventShrinking();
|
||||
this.props.onScroll(ev);
|
||||
|
||||
this.checkFillState();
|
||||
},
|
||||
|
||||
onResize: function() {
|
||||
this.clearBlockShrinking();
|
||||
this.props.onResize();
|
||||
this.checkScroll();
|
||||
if (this._gemScroll) this._gemScroll.forceUpdate();
|
||||
// update preventShrinkingState if present
|
||||
if (this.preventShrinkingState) {
|
||||
this.preventShrinking();
|
||||
}
|
||||
},
|
||||
|
||||
// after an update to the contents of the panel, check that the scroll is
|
||||
// where it ought to be, and set off pagination requests if necessary.
|
||||
checkScroll: function() {
|
||||
this._restoreSavedScrollState();
|
||||
this._checkBlockShrinking();
|
||||
this.checkFillState();
|
||||
},
|
||||
|
||||
// return true if the content is fully scrolled down right now; else false.
|
||||
//
|
||||
// note that this is independent of the 'stuckAtBottom' state - it is simply
|
||||
// about whether the the content is scrolled down right now, irrespective of
|
||||
// about whether the content is scrolled down right now, irrespective of
|
||||
// whether it will stay that way when the children update.
|
||||
isAtBottom: function() {
|
||||
const sn = this._getScrollNode();
|
||||
// fractional values (both too big and too small)
|
||||
// for scrollTop happen on certain browsers/platforms
|
||||
// when scrolled all the way down. E.g. Chrome 72 on debian.
|
||||
// so check difference <= 1;
|
||||
return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1;
|
||||
|
||||
// there seems to be some bug with flexbox/gemini/chrome/richvdh's
|
||||
// understanding of the box model, wherein the scrollNode ends up 2
|
||||
// pixels higher than the available space, even when there are less
|
||||
// than a screenful of messages. + 3 is a fudge factor to pretend
|
||||
// that we're at the bottom when we're still a few pixels off.
|
||||
|
||||
return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3;
|
||||
},
|
||||
|
||||
// returns the vertical height in the given direction that can be removed from
|
||||
@@ -288,19 +266,25 @@ module.exports = React.createClass({
|
||||
// `---------' -
|
||||
_getExcessHeight: function(backwards) {
|
||||
const sn = this._getScrollNode();
|
||||
const contentHeight = this._getMessagesHeight();
|
||||
const listHeight = this._getListHeight();
|
||||
const clippedHeight = contentHeight - listHeight;
|
||||
const unclippedScrollTop = sn.scrollTop + clippedHeight;
|
||||
|
||||
if (backwards) {
|
||||
return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING;
|
||||
return unclippedScrollTop - sn.clientHeight - UNPAGINATION_PADDING;
|
||||
} else {
|
||||
return sn.scrollHeight - (sn.scrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
|
||||
return contentHeight - (unclippedScrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
|
||||
}
|
||||
},
|
||||
|
||||
// check the scroll state and send out backfill requests if necessary.
|
||||
checkFillState: function() {
|
||||
checkFillState: async function(depth=0) {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstCall = depth === 0;
|
||||
const sn = this._getScrollNode();
|
||||
|
||||
// if there is less than a screenful of messages above or below the
|
||||
@@ -327,13 +311,53 @@ module.exports = React.createClass({
|
||||
// `---------' -
|
||||
//
|
||||
|
||||
if (sn.scrollTop < sn.clientHeight) {
|
||||
// need to back-fill
|
||||
this._maybeFill(true);
|
||||
// as filling is async and recursive,
|
||||
// don't allow more than 1 chain of calls concurrently
|
||||
// do make a note when a new request comes in while already running one,
|
||||
// so we can trigger a new chain of calls once done.
|
||||
if (isFirstCall) {
|
||||
if (this._isFilling) {
|
||||
debuglog("_isFilling: not entering while request is ongoing, marking for a subsequent request");
|
||||
this._fillRequestWhileRunning = true;
|
||||
return;
|
||||
}
|
||||
debuglog("_isFilling: setting");
|
||||
this._isFilling = true;
|
||||
}
|
||||
if (sn.scrollTop > sn.scrollHeight - sn.clientHeight * 2) {
|
||||
|
||||
const itemlist = this.refs.itemlist;
|
||||
const firstTile = itemlist && itemlist.firstElementChild;
|
||||
const contentTop = firstTile && firstTile.offsetTop;
|
||||
const fillPromises = [];
|
||||
|
||||
// if scrollTop gets to 1 screen from the top of the first tile,
|
||||
// try backward filling
|
||||
if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) {
|
||||
// need to back-fill
|
||||
fillPromises.push(this._maybeFill(depth, true));
|
||||
}
|
||||
// if scrollTop gets to 2 screens from the end (so 1 screen below viewport),
|
||||
// try forward filling
|
||||
if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) {
|
||||
// need to forward-fill
|
||||
this._maybeFill(false);
|
||||
fillPromises.push(this._maybeFill(depth, false));
|
||||
}
|
||||
|
||||
if (fillPromises.length) {
|
||||
try {
|
||||
await Promise.all(fillPromises);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
if (isFirstCall) {
|
||||
debuglog("_isFilling: clearing");
|
||||
this._isFilling = false;
|
||||
}
|
||||
|
||||
if (this._fillRequestWhileRunning) {
|
||||
this._fillRequestWhileRunning = false;
|
||||
this.checkFillState();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -343,6 +367,9 @@ module.exports = React.createClass({
|
||||
if (excessHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const origExcessHeight = excessHeight;
|
||||
|
||||
const tiles = this.refs.itemlist.children;
|
||||
|
||||
// The scroll token of the first/last tile to be unpaginated
|
||||
@@ -354,8 +381,9 @@ module.exports = React.createClass({
|
||||
// pagination.
|
||||
//
|
||||
// If backwards is true, we unpaginate (remove) tiles from the back (top).
|
||||
let tile;
|
||||
for (let i = 0; i < tiles.length; i++) {
|
||||
const tile = tiles[backwards ? i : tiles.length - 1 - i];
|
||||
tile = tiles[backwards ? i : tiles.length - 1 - i];
|
||||
// Subtract height of tile as if it were unpaginated
|
||||
excessHeight -= tile.clientHeight;
|
||||
//If removing the tile would lead to future pagination, break before setting scroll token
|
||||
@@ -376,26 +404,31 @@ module.exports = React.createClass({
|
||||
}
|
||||
this._unfillDebouncer = setTimeout(() => {
|
||||
this._unfillDebouncer = null;
|
||||
debuglog("unfilling now", backwards, origExcessHeight);
|
||||
this.props.onUnfillRequest(backwards, markerScrollToken);
|
||||
}, UNFILL_REQUEST_DEBOUNCE_MS);
|
||||
}
|
||||
},
|
||||
|
||||
// check if there is already a pending fill request. If not, set one off.
|
||||
_maybeFill: function(backwards) {
|
||||
_maybeFill: function(depth, backwards) {
|
||||
const dir = backwards ? 'b' : 'f';
|
||||
if (this._pendingFillRequests[dir]) {
|
||||
debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another");
|
||||
debuglog("Already a "+dir+" fill in progress - not starting another");
|
||||
return;
|
||||
}
|
||||
|
||||
debuglog("ScrollPanel: starting "+dir+" fill");
|
||||
debuglog("starting "+dir+" fill");
|
||||
|
||||
// onFillRequest can end up calling us recursively (via onScroll
|
||||
// events) so make sure we set this before firing off the call.
|
||||
this._pendingFillRequests[dir] = true;
|
||||
|
||||
Promise.try(() => {
|
||||
// wait 1ms before paginating, because otherwise
|
||||
// this will block the scroll event handler for +700ms
|
||||
// if messages are already cached in memory,
|
||||
// This would cause jumping to happen on Chrome/macOS.
|
||||
return new Promise(resolve => setTimeout(resolve, 1)).then(() => {
|
||||
return this.props.onFillRequest(backwards);
|
||||
}).finally(() => {
|
||||
this._pendingFillRequests[dir] = false;
|
||||
@@ -406,14 +439,14 @@ module.exports = React.createClass({
|
||||
// Unpaginate once filling is complete
|
||||
this._checkUnfillState(!backwards);
|
||||
|
||||
debuglog("ScrollPanel: "+dir+" fill complete; hasMoreResults:"+hasMoreResults);
|
||||
debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults);
|
||||
if (hasMoreResults) {
|
||||
// further pagination requests have been disabled until now, so
|
||||
// it's time to check the fill state again in case the pagination
|
||||
// was insufficient.
|
||||
this.checkFillState();
|
||||
return this.checkFillState(depth + 1);
|
||||
}
|
||||
}).done();
|
||||
});
|
||||
},
|
||||
|
||||
/* get the current scroll state. This returns an object with the following
|
||||
@@ -426,7 +459,7 @@ module.exports = React.createClass({
|
||||
* false, the first token in data-scroll-tokens of the child which we are
|
||||
* tracking.
|
||||
*
|
||||
* number pixelOffset: undefined if stuckAtBottom is true; if it is false,
|
||||
* number bottomOffset: undefined if stuckAtBottom is true; if it is false,
|
||||
* the number of pixels the bottom of the tracked child is above the
|
||||
* bottom of the scroll panel.
|
||||
*/
|
||||
@@ -447,14 +480,20 @@ module.exports = React.createClass({
|
||||
* child list.)
|
||||
*/
|
||||
resetScrollState: function() {
|
||||
this.scrollState = {stuckAtBottom: this.props.startAtBottom};
|
||||
this.scrollState = {
|
||||
stuckAtBottom: this.props.startAtBottom,
|
||||
};
|
||||
this._bottomGrowth = 0;
|
||||
this._pages = 0;
|
||||
this._scrollTimeout = new Timer(100);
|
||||
this._heightUpdateInProgress = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* jump to the top of the content.
|
||||
*/
|
||||
scrollToTop: function() {
|
||||
this._setScrollTop(0);
|
||||
this._getScrollNode().scrollTop = 0;
|
||||
this._saveScrollState();
|
||||
},
|
||||
|
||||
@@ -466,24 +505,26 @@ module.exports = React.createClass({
|
||||
// saved is to do the scroll, then save the updated state. (Calculating
|
||||
// it ourselves is hard, and we can't rely on an onScroll callback
|
||||
// happening, since there may be no user-visible change here).
|
||||
this._setScrollTop(Number.MAX_VALUE);
|
||||
const sn = this._getScrollNode();
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
this._saveScrollState();
|
||||
},
|
||||
|
||||
/**
|
||||
* Page up/down.
|
||||
*
|
||||
* mult: -1 to page up, +1 to page down
|
||||
* @param {number} mult: -1 to page up, +1 to page down
|
||||
*/
|
||||
scrollRelative: function(mult) {
|
||||
const scrollNode = this._getScrollNode();
|
||||
const delta = mult * scrollNode.clientHeight * 0.5;
|
||||
this._setScrollTop(scrollNode.scrollTop + delta);
|
||||
scrollNode.scrollTop = scrollNode.scrollTop + delta;
|
||||
this._saveScrollState();
|
||||
},
|
||||
|
||||
/**
|
||||
* Scroll up/down in response to a scroll key
|
||||
* @param {object} ev the keyboard event
|
||||
*/
|
||||
handleScrollKey: function(ev) {
|
||||
switch (ev.keyCode) {
|
||||
@@ -528,77 +569,41 @@ module.exports = React.createClass({
|
||||
pixelOffset = pixelOffset || 0;
|
||||
offsetBase = offsetBase || 0;
|
||||
|
||||
// convert pixelOffset so that it is based on the bottom of the
|
||||
// container.
|
||||
pixelOffset += this._getScrollNode().clientHeight * (1-offsetBase);
|
||||
|
||||
// save the desired scroll state. It's important we do this here rather
|
||||
// than as a result of the scroll event, because (a) we might not *get*
|
||||
// a scroll event, and (b) it might not currently be possible to set
|
||||
// the requested scroll state (eg, because we hit the end of the
|
||||
// timeline and need to do more pagination); we want to save the
|
||||
// *desired* scroll state rather than what we end up achieving.
|
||||
// set the trackedScrollToken so we can get the node through _getTrackedNode
|
||||
this.scrollState = {
|
||||
stuckAtBottom: false,
|
||||
trackedScrollToken: scrollToken,
|
||||
pixelOffset: pixelOffset,
|
||||
};
|
||||
|
||||
// ... then make it so.
|
||||
this._restoreSavedScrollState();
|
||||
},
|
||||
|
||||
// set the scrollTop attribute appropriately to position the given child at the
|
||||
// given offset in the window. A helper for _restoreSavedScrollState.
|
||||
_scrollToToken: function(scrollToken, pixelOffset) {
|
||||
/* find the dom node with the right scrolltoken */
|
||||
let node;
|
||||
const messages = this.refs.itemlist.children;
|
||||
for (let i = messages.length-1; i >= 0; --i) {
|
||||
const m = messages[i];
|
||||
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
|
||||
// There might only be one scroll token
|
||||
if (m.dataset.scrollTokens &&
|
||||
m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) {
|
||||
node = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
debuglog("ScrollPanel: No node with scrollToken '"+scrollToken+"'");
|
||||
return;
|
||||
}
|
||||
|
||||
const trackedNode = this._getTrackedNode();
|
||||
const scrollNode = this._getScrollNode();
|
||||
const scrollTop = scrollNode.scrollTop;
|
||||
const viewportBottom = scrollTop + scrollNode.clientHeight;
|
||||
const nodeBottom = node.offsetTop + node.clientHeight;
|
||||
const intendedViewportBottom = nodeBottom + pixelOffset;
|
||||
const scrollDelta = intendedViewportBottom - viewportBottom;
|
||||
|
||||
debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" +
|
||||
pixelOffset + " (delta: "+scrollDelta+")");
|
||||
|
||||
if (scrollDelta !== 0) {
|
||||
this._setScrollTop(scrollTop + scrollDelta);
|
||||
if (trackedNode) {
|
||||
// set the scrollTop to the position we want.
|
||||
// note though, that this might not succeed if the combination of offsetBase and pixelOffset
|
||||
// would position the trackedNode towards the top of the viewport.
|
||||
// This because when setting the scrollTop only 10 or so events might be loaded,
|
||||
// not giving enough content below the trackedNode to scroll downwards
|
||||
// enough so it ends up in the top of the viewport.
|
||||
debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop});
|
||||
scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset;
|
||||
this._saveScrollState();
|
||||
}
|
||||
},
|
||||
|
||||
_saveScrollState: function() {
|
||||
if (this.props.stickyBottom && this.isAtBottom()) {
|
||||
this.scrollState = { stuckAtBottom: true };
|
||||
debuglog("ScrollPanel: Saved scroll state", this.scrollState);
|
||||
debuglog("saved stuckAtBottom state");
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollNode = this._getScrollNode();
|
||||
const viewportBottom = scrollNode.scrollTop + scrollNode.clientHeight;
|
||||
const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
|
||||
|
||||
const itemlist = this.refs.itemlist;
|
||||
const messages = itemlist.children;
|
||||
let node = null;
|
||||
|
||||
// TODO: do a binary search here, as items are sorted by offsetTop
|
||||
// loop backwards, from bottom-most message (as that is the most common case)
|
||||
for (let i = messages.length-1; i >= 0; --i) {
|
||||
if (!messages[i].dataset.scrollTokens) {
|
||||
@@ -607,59 +612,150 @@ module.exports = React.createClass({
|
||||
node = messages[i];
|
||||
// break at the first message (coming from the bottom)
|
||||
// that has it's offsetTop above the bottom of the viewport.
|
||||
if (node.offsetTop < viewportBottom) {
|
||||
if (this._topFromBottom(node) > viewportBottom) {
|
||||
// Use this node as the scrollToken
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
|
||||
debuglog("unable to save scroll state: found no children in the viewport");
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeBottom = node.offsetTop + node.clientHeight;
|
||||
debuglog("ScrollPanel: saved scroll state", this.scrollState);
|
||||
const scrollToken = node.dataset.scrollTokens.split(',')[0];
|
||||
debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken);
|
||||
const bottomOffset = this._topFromBottom(node);
|
||||
this.scrollState = {
|
||||
stuckAtBottom: false,
|
||||
trackedScrollToken: node.dataset.scrollTokens.split(',')[0],
|
||||
pixelOffset: viewportBottom - nodeBottom,
|
||||
trackedNode: node,
|
||||
trackedScrollToken: scrollToken,
|
||||
bottomOffset: bottomOffset,
|
||||
pixelOffset: bottomOffset - viewportBottom, //needed for restoring the scroll position when coming back to the room
|
||||
};
|
||||
},
|
||||
|
||||
_restoreSavedScrollState: function() {
|
||||
_restoreSavedScrollState: async function() {
|
||||
const scrollState = this.scrollState;
|
||||
|
||||
if (scrollState.stuckAtBottom) {
|
||||
this._setScrollTop(Number.MAX_VALUE);
|
||||
const sn = this._getScrollNode();
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
} else if (scrollState.trackedScrollToken) {
|
||||
this._scrollToToken(scrollState.trackedScrollToken,
|
||||
scrollState.pixelOffset);
|
||||
const itemlist = this.refs.itemlist;
|
||||
const trackedNode = this._getTrackedNode();
|
||||
if (trackedNode) {
|
||||
const newBottomOffset = this._topFromBottom(trackedNode);
|
||||
const bottomDiff = newBottomOffset - scrollState.bottomOffset;
|
||||
this._bottomGrowth += bottomDiff;
|
||||
scrollState.bottomOffset = newBottomOffset;
|
||||
itemlist.style.height = `${this._getListHeight()}px`;
|
||||
debuglog("balancing height because messages below viewport grew by", bottomDiff);
|
||||
}
|
||||
}
|
||||
if (!this._heightUpdateInProgress) {
|
||||
this._heightUpdateInProgress = true;
|
||||
try {
|
||||
await this._updateHeight();
|
||||
} finally {
|
||||
this._heightUpdateInProgress = false;
|
||||
}
|
||||
} else {
|
||||
debuglog("not updating height because request already in progress");
|
||||
}
|
||||
},
|
||||
// need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content?
|
||||
async _updateHeight() {
|
||||
// wait until user has stopped scrolling
|
||||
if (this._scrollTimeout.isRunning()) {
|
||||
debuglog("updateHeight waiting for scrolling to end ... ");
|
||||
await this._scrollTimeout.finished();
|
||||
} else {
|
||||
debuglog("updateHeight getting straight to business, no scrolling going on.");
|
||||
}
|
||||
|
||||
const sn = this._getScrollNode();
|
||||
const itemlist = this.refs.itemlist;
|
||||
const contentHeight = this._getMessagesHeight();
|
||||
const minHeight = sn.clientHeight;
|
||||
const height = Math.max(minHeight, contentHeight);
|
||||
this._pages = Math.ceil(height / PAGE_SIZE);
|
||||
this._bottomGrowth = 0;
|
||||
const newHeight = this._getListHeight();
|
||||
|
||||
const scrollState = this.scrollState;
|
||||
if (scrollState.stuckAtBottom) {
|
||||
itemlist.style.height = `${newHeight}px`;
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
debuglog("updateHeight to", newHeight);
|
||||
} else if (scrollState.trackedScrollToken) {
|
||||
const trackedNode = this._getTrackedNode();
|
||||
// if the timeline has been reloaded
|
||||
// this can be called before scrollToBottom or whatever has been called
|
||||
// so don't do anything if the node has disappeared from
|
||||
// the currently filled piece of the timeline
|
||||
if (trackedNode) {
|
||||
const oldTop = trackedNode.offsetTop;
|
||||
// changing the height might change the scrollTop
|
||||
// if the new height is smaller than the scrollTop.
|
||||
// We calculate the diff that needs to be applied
|
||||
// ourselves, so be sure to measure the
|
||||
// scrollTop before changing the height.
|
||||
const preexistingScrollTop = sn.scrollTop;
|
||||
itemlist.style.height = `${newHeight}px`;
|
||||
const newTop = trackedNode.offsetTop;
|
||||
const topDiff = newTop - oldTop;
|
||||
sn.scrollTop = preexistingScrollTop + topDiff;
|
||||
debuglog("updateHeight to", {newHeight, topDiff, preexistingScrollTop});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_setScrollTop: function(scrollTop) {
|
||||
const scrollNode = this._getScrollNode();
|
||||
_getTrackedNode() {
|
||||
const scrollState = this.scrollState;
|
||||
const trackedNode = scrollState.trackedNode;
|
||||
|
||||
const prevScroll = scrollNode.scrollTop;
|
||||
if (!trackedNode || !trackedNode.parentElement) {
|
||||
let node;
|
||||
const messages = this.refs.itemlist.children;
|
||||
const scrollToken = scrollState.trackedScrollToken;
|
||||
|
||||
// FF ignores attempts to set scrollTop to very large numbers
|
||||
scrollNode.scrollTop = Math.min(scrollTop, scrollNode.scrollHeight);
|
||||
|
||||
// If this change generates a scroll event, we should not update the
|
||||
// saved scroll state on it. See the comments in onScroll.
|
||||
//
|
||||
// If we *don't* expect a scroll event, we need to leave _lastSetScroll
|
||||
// alone, otherwise we'll end up ignoring a future scroll event which is
|
||||
// nothing to do with this change.
|
||||
|
||||
if (scrollNode.scrollTop != prevScroll) {
|
||||
this._lastSetScroll = scrollNode.scrollTop;
|
||||
for (let i = messages.length-1; i >= 0; --i) {
|
||||
const m = messages[i];
|
||||
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
|
||||
// There might only be one scroll token
|
||||
if (m.dataset.scrollTokens &&
|
||||
m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) {
|
||||
node = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (node) {
|
||||
debuglog("had to find tracked node again for " + scrollState.trackedScrollToken);
|
||||
}
|
||||
scrollState.trackedNode = node;
|
||||
}
|
||||
|
||||
debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
|
||||
"requested:", scrollTop,
|
||||
"_lastSetScroll:", this._lastSetScroll);
|
||||
if (!scrollState.trackedNode) {
|
||||
debuglog("No node with ; '"+scrollState.trackedScrollToken+"'");
|
||||
return;
|
||||
}
|
||||
|
||||
return scrollState.trackedNode;
|
||||
},
|
||||
|
||||
_getListHeight() {
|
||||
return this._bottomGrowth + (this._pages * PAGE_SIZE);
|
||||
},
|
||||
|
||||
_getMessagesHeight() {
|
||||
const itemlist = this.refs.itemlist;
|
||||
const lastNode = itemlist.lastElementChild;
|
||||
// 18 is itemlist padding
|
||||
return (lastNode.offsetTop + lastNode.clientHeight) - itemlist.firstElementChild.offsetTop + (18 * 2);
|
||||
},
|
||||
|
||||
_topFromBottom(node) {
|
||||
return this.refs.itemlist.clientHeight - node.offsetTop;
|
||||
},
|
||||
|
||||
/* get the DOM node which has the scrollTop property we care about for our
|
||||
@@ -672,71 +768,112 @@ module.exports = React.createClass({
|
||||
throw new Error("ScrollPanel._getScrollNode called when unmounted");
|
||||
}
|
||||
|
||||
if (!this._gemScroll) {
|
||||
if (!this._divScroll) {
|
||||
// Likewise, we should have the ref by this point, but if not
|
||||
// turn the NPE into something meaningful.
|
||||
throw new Error("ScrollPanel._getScrollNode called before gemini ref collected");
|
||||
}
|
||||
|
||||
return this._gemScroll.scrollbar.getViewElement();
|
||||
return this._divScroll;
|
||||
},
|
||||
|
||||
_collectGeminiScroll: function(gemScroll) {
|
||||
this._gemScroll = gemScroll;
|
||||
_collectScroll: function(divScroll) {
|
||||
this._divScroll = divScroll;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the current height as the min height for the message list
|
||||
* so the timeline cannot shrink. This is used to avoid
|
||||
* jumping when the typing indicator gets replaced by a smaller message.
|
||||
*/
|
||||
blockShrinking: function() {
|
||||
Mark the bottom offset of the last tile so we can balance it out when
|
||||
anything below it changes, by calling updatePreventShrinking, to keep
|
||||
the same minimum bottom offset, effectively preventing the timeline to shrink.
|
||||
*/
|
||||
preventShrinking: function() {
|
||||
const messageList = this.refs.itemlist;
|
||||
if (messageList) {
|
||||
const currentHeight = messageList.clientHeight;
|
||||
messageList.style.minHeight = `${currentHeight}px`;
|
||||
const tiles = messageList && messageList.children;
|
||||
if (!messageList) {
|
||||
return;
|
||||
}
|
||||
let lastTileNode;
|
||||
for (let i = tiles.length - 1; i >= 0; i--) {
|
||||
const node = tiles[i];
|
||||
if (node.dataset.scrollTokens) {
|
||||
lastTileNode = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!lastTileNode) {
|
||||
return;
|
||||
}
|
||||
this.clearPreventShrinking();
|
||||
const offsetFromBottom = messageList.clientHeight - (lastTileNode.offsetTop + lastTileNode.clientHeight);
|
||||
this.preventShrinkingState = {
|
||||
offsetFromBottom: offsetFromBottom,
|
||||
offsetNode: lastTileNode,
|
||||
};
|
||||
debuglog("prevent shrinking, last tile ", offsetFromBottom, "px from bottom");
|
||||
},
|
||||
|
||||
/** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */
|
||||
clearPreventShrinking: function() {
|
||||
const messageList = this.refs.itemlist;
|
||||
const balanceElement = messageList && messageList.parentElement;
|
||||
if (balanceElement) balanceElement.style.paddingBottom = null;
|
||||
this.preventShrinkingState = null;
|
||||
debuglog("prevent shrinking cleared");
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the previously set min height
|
||||
*/
|
||||
clearBlockShrinking: function() {
|
||||
const messageList = this.refs.itemlist;
|
||||
if (messageList) {
|
||||
messageList.style.minHeight = null;
|
||||
}
|
||||
},
|
||||
|
||||
_checkBlockShrinking: function() {
|
||||
const sn = this._getScrollNode();
|
||||
const scrollState = this.scrollState;
|
||||
if (!scrollState.stuckAtBottom) {
|
||||
const spaceBelowViewport = sn.scrollHeight - (sn.scrollTop + sn.clientHeight);
|
||||
// only if we've scrolled up 200px from the bottom
|
||||
// should we clear the min-height used by the typing notifications,
|
||||
// otherwise we might still see it jump as the whitespace disappears
|
||||
// when scrolling up from the bottom
|
||||
if (spaceBelowViewport >= 200) {
|
||||
this.clearBlockShrinking();
|
||||
update the container padding to balance
|
||||
the bottom offset of the last tile since
|
||||
preventShrinking was called.
|
||||
Clears the prevent-shrinking state ones the offset
|
||||
from the bottom of the marked tile grows larger than
|
||||
what it was when marking.
|
||||
*/
|
||||
updatePreventShrinking: function() {
|
||||
if (this.preventShrinkingState) {
|
||||
const sn = this._getScrollNode();
|
||||
const scrollState = this.scrollState;
|
||||
const messageList = this.refs.itemlist;
|
||||
const {offsetNode, offsetFromBottom} = this.preventShrinkingState;
|
||||
// element used to set paddingBottom to balance the typing notifs disappearing
|
||||
const balanceElement = messageList.parentElement;
|
||||
// if the offsetNode got unmounted, clear
|
||||
let shouldClear = !offsetNode.parentElement;
|
||||
// also if 200px from bottom
|
||||
if (!shouldClear && !scrollState.stuckAtBottom) {
|
||||
const spaceBelowViewport = sn.scrollHeight - (sn.scrollTop + sn.clientHeight);
|
||||
shouldClear = spaceBelowViewport >= 200;
|
||||
}
|
||||
// try updating if not clearing
|
||||
if (!shouldClear) {
|
||||
const currentOffset = messageList.clientHeight - (offsetNode.offsetTop + offsetNode.clientHeight);
|
||||
const offsetDiff = offsetFromBottom - currentOffset;
|
||||
if (offsetDiff > 0) {
|
||||
balanceElement.style.paddingBottom = `${offsetDiff}px`;
|
||||
debuglog("update prevent shrinking ", offsetDiff, "px from bottom");
|
||||
} else if (offsetDiff < 0) {
|
||||
shouldClear = true;
|
||||
}
|
||||
}
|
||||
if (shouldClear) {
|
||||
this.clearPreventShrinking();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
// TODO: the classnames on the div and ol could do with being updated to
|
||||
// reflect the fact that we don't necessarily contain a list of messages.
|
||||
// it's not obvious why we have a separate div and ol anyway.
|
||||
return (<GeminiScrollbarWrapper autoshow={true} wrappedRef={this._collectGeminiScroll}
|
||||
onScroll={this.onScroll} onResize={this.onResize}
|
||||
className={this.props.className} style={this.props.style}>
|
||||
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
|
||||
onScroll={this.onScroll}
|
||||
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
|
||||
<div className="mx_RoomView_messageListWrapper">
|
||||
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
|
||||
{ this.props.children }
|
||||
</ol>
|
||||
</div>
|
||||
</GeminiScrollbarWrapper>
|
||||
);
|
||||
</AutoHideScrollbar>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ const TagPanelButtons = React.createClass({
|
||||
if (payload.action === "show_redesign_feedback_dialog") {
|
||||
const RedesignFeedbackDialog =
|
||||
sdk.getComponent("views.dialogs.RedesignFeedbackDialog");
|
||||
Modal.createDialog(RedesignFeedbackDialog);
|
||||
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -44,11 +44,10 @@ const READ_RECEIPT_INTERVAL_MS = 500;
|
||||
|
||||
const DEBUG = false;
|
||||
|
||||
let debuglog = function() {};
|
||||
if (DEBUG) {
|
||||
// using bind means that we get to keep useful line numbers in the console
|
||||
var debuglog = console.log.bind(console);
|
||||
} else {
|
||||
var debuglog = function() {};
|
||||
debuglog = console.log.bind(console);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -56,7 +55,7 @@ if (DEBUG) {
|
||||
*
|
||||
* Also responsible for handling and sending read receipts.
|
||||
*/
|
||||
var TimelinePanel = React.createClass({
|
||||
const TimelinePanel = React.createClass({
|
||||
displayName: 'TimelinePanel',
|
||||
|
||||
propTypes: {
|
||||
@@ -445,6 +444,7 @@ var TimelinePanel = React.createClass({
|
||||
|
||||
const updatedState = {events: events};
|
||||
|
||||
let callRMUpdated;
|
||||
if (this.props.manageReadMarkers) {
|
||||
// when a new event arrives when the user is not watching the
|
||||
// window, but the window is in its auto-scroll mode, make sure the
|
||||
@@ -456,7 +456,7 @@ var TimelinePanel = React.createClass({
|
||||
//
|
||||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
const sender = ev.sender ? ev.sender.userId : null;
|
||||
var callRMUpdated = false;
|
||||
callRMUpdated = false;
|
||||
if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
|
||||
updatedState.readMarkerVisible = true;
|
||||
} else if (lastEv && this.getReadMarkerPosition() === 0) {
|
||||
@@ -566,7 +566,7 @@ var TimelinePanel = React.createClass({
|
||||
UserActivity.sharedInstance().timeWhileActiveRecently(this._readMarkerActivityTimer);
|
||||
try {
|
||||
await this._readMarkerActivityTimer.finished();
|
||||
} catch(e) { continue; /* aborted */ }
|
||||
} catch (e) { continue; /* aborted */ }
|
||||
// outside of try/catch to not swallow errors
|
||||
this.updateReadMarker();
|
||||
}
|
||||
@@ -578,7 +578,7 @@ var TimelinePanel = React.createClass({
|
||||
UserActivity.sharedInstance().timeWhileActiveNow(this._readReceiptActivityTimer);
|
||||
try {
|
||||
await this._readReceiptActivityTimer.finished();
|
||||
} catch(e) { continue; /* aborted */ }
|
||||
} catch (e) { continue; /* aborted */ }
|
||||
// outside of try/catch to not swallow errors
|
||||
this.sendReadReceipt();
|
||||
}
|
||||
@@ -732,7 +732,8 @@ var TimelinePanel = React.createClass({
|
||||
const events = this._timelineWindow.getEvents();
|
||||
|
||||
// first find where the current RM is
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
let i;
|
||||
for (i = 0; i < events.length; i++) {
|
||||
if (events[i].getId() == this.state.readMarkerEventId) {
|
||||
break;
|
||||
}
|
||||
@@ -744,7 +745,7 @@ var TimelinePanel = React.createClass({
|
||||
// now think about advancing it
|
||||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
for (i++; i < events.length; i++) {
|
||||
var ev = events[i];
|
||||
const ev = events[i];
|
||||
if (!ev.sender || ev.sender.userId != myUserId) {
|
||||
break;
|
||||
}
|
||||
@@ -752,7 +753,7 @@ var TimelinePanel = React.createClass({
|
||||
// i is now the first unread message which we didn't send ourselves.
|
||||
i--;
|
||||
|
||||
var ev = events[i];
|
||||
const ev = events[i];
|
||||
this._setReadMarker(ev.getId(), ev.getTs());
|
||||
},
|
||||
|
||||
@@ -882,7 +883,7 @@ var TimelinePanel = React.createClass({
|
||||
return ret;
|
||||
},
|
||||
|
||||
/**
|
||||
/*
|
||||
* called by the parent component when PageUp/Down/etc is pressed.
|
||||
*
|
||||
* We pass it down to the scroll panel.
|
||||
@@ -939,7 +940,7 @@ var TimelinePanel = React.createClass({
|
||||
// clear the timeline min-height when
|
||||
// (re)loading the timeline
|
||||
if (this.refs.messagePanel) {
|
||||
this.refs.messagePanel.clearTimelineHeight();
|
||||
this.refs.messagePanel.onTimelineReset();
|
||||
}
|
||||
this._reloadEvents();
|
||||
|
||||
@@ -975,11 +976,10 @@ var TimelinePanel = React.createClass({
|
||||
};
|
||||
|
||||
const onError = (error) => {
|
||||
this.setState({timelineLoading: false});
|
||||
this.setState({ timelineLoading: false });
|
||||
console.error(
|
||||
`Error loading timeline panel at ${eventId}: ${error}`,
|
||||
);
|
||||
const msg = error.message ? error.message : JSON.stringify(error);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
let onFinished;
|
||||
@@ -997,9 +997,18 @@ var TimelinePanel = React.createClass({
|
||||
});
|
||||
};
|
||||
}
|
||||
const message = (error.errcode == 'M_FORBIDDEN')
|
||||
? _t("Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.")
|
||||
: _t("Tried to load a specific point in this room's timeline, but was unable to find it.");
|
||||
let message;
|
||||
if (error.errcode == 'M_FORBIDDEN') {
|
||||
message = _t(
|
||||
"Tried to load a specific point in this room's timeline, but you " +
|
||||
"do not have permission to view the message in question.",
|
||||
);
|
||||
} else {
|
||||
message = _t(
|
||||
"Tried to load a specific point in this room's timeline, but was " +
|
||||
"unable to find it.",
|
||||
);
|
||||
}
|
||||
Modal.createTrackedDialog('Failed to load timeline position', '', ErrorDialog, {
|
||||
title: _t("Failed to load timeline position"),
|
||||
description: message,
|
||||
@@ -1104,12 +1113,13 @@ var TimelinePanel = React.createClass({
|
||||
},
|
||||
|
||||
/**
|
||||
* get the id of the event corresponding to our user's latest read-receipt.
|
||||
* Get the id of the event corresponding to our user's latest read-receipt.
|
||||
*
|
||||
* @param {Boolean} ignoreSynthesized If true, return only receipts that
|
||||
* have been sent by the server, not
|
||||
* implicit ones generated by the JS
|
||||
* SDK.
|
||||
* @return {String} the event ID
|
||||
*/
|
||||
_getCurrentReadReceipt: function(ignoreSynthesized) {
|
||||
const client = MatrixClientPeg.get();
|
||||
@@ -1228,6 +1238,7 @@ var TimelinePanel = React.createClass({
|
||||
alwaysShowTimestamps={this.state.alwaysShowTimestamps}
|
||||
className={this.props.className}
|
||||
tileShape={this.props.tileShape}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
const React = require('react');
|
||||
import PropTypes from 'prop-types';
|
||||
const ContentMessages = require('../../ContentMessages');
|
||||
import ContentMessages from '../../ContentMessages';
|
||||
const dis = require('../../dispatcher');
|
||||
const filesize = require('filesize');
|
||||
import { _t } from '../../languageHandler';
|
||||
@@ -40,6 +40,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
|
||||
switch (payload.action) {
|
||||
case 'upload_progress':
|
||||
case 'upload_finished':
|
||||
case 'upload_canceled':
|
||||
case 'upload_failed':
|
||||
if (this.mounted) this.forceUpdate();
|
||||
break;
|
||||
@@ -47,7 +48,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const uploads = ContentMessages.getCurrentUploads();
|
||||
const uploads = ContentMessages.sharedInstance().getCurrentUploads();
|
||||
|
||||
// for testing UI... - also fix up the ContentMessages.getCurrentUploads().length
|
||||
// check in RoomView
|
||||
@@ -93,7 +94,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
|
||||
</div>
|
||||
<img className="mx_UploadBar_uploadIcon mx_filterFlipColor" src={require("../../../res/img/fileicon.png")} width="17" height="22" />
|
||||
<img className="mx_UploadBar_uploadCancel mx_filterFlipColor" src={require("../../../res/img/cancel.svg")} width="18" height="18"
|
||||
onClick={function() { ContentMessages.cancelUpload(upload.promise); }}
|
||||
onClick={function() { ContentMessages.sharedInstance().cancelUpload(upload.promise); }}
|
||||
/>
|
||||
<div className="mx_UploadBar_uploadBytes">
|
||||
{ uploadedSize } / { totalSize }
|
||||
|
||||
@@ -222,7 +222,7 @@ module.exports = React.createClass({
|
||||
}
|
||||
|
||||
let yourMatrixAccountText = _t('Your Matrix account');
|
||||
if (this.state.enteredHsUrl === this.props.defaultHsUrl) {
|
||||
if (this.state.enteredHsUrl === this.props.defaultHsUrl && this.props.defaultServerName) {
|
||||
yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.defaultServerName,
|
||||
});
|
||||
|
||||
@@ -42,7 +42,12 @@ const PHASES_ENABLED = true;
|
||||
// These are used in several places, and come from the js-sdk's autodiscovery
|
||||
// stuff. We define them here so that they'll be picked up by i18n.
|
||||
_td("Invalid homeserver discovery response");
|
||||
_td("Failed to get autodiscovery configuration from server");
|
||||
_td("Invalid base_url for m.homeserver");
|
||||
_td("Homeserver URL does not appear to be a valid Matrix homeserver");
|
||||
_td("Invalid identity server discovery response");
|
||||
_td("Invalid base_url for m.identity_server");
|
||||
_td("Identity server URL does not appear to be a valid identity server");
|
||||
_td("General failure");
|
||||
|
||||
/**
|
||||
@@ -54,8 +59,6 @@ module.exports = React.createClass({
|
||||
propTypes: {
|
||||
onLoggedIn: PropTypes.func.isRequired,
|
||||
|
||||
enableGuest: PropTypes.bool,
|
||||
|
||||
// The default server name to use when the user hasn't specified
|
||||
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
|
||||
// via `.well-known` discovery. The server name is used instead of the
|
||||
@@ -225,37 +228,6 @@ module.exports = React.createClass({
|
||||
}).done();
|
||||
},
|
||||
|
||||
_onLoginAsGuestClick: function(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const self = this;
|
||||
self.setState({
|
||||
busy: true,
|
||||
errorText: null,
|
||||
loginIncorrect: false,
|
||||
});
|
||||
|
||||
this._loginLogic.loginAsGuest().then(function(data) {
|
||||
self.props.onLoggedIn(data);
|
||||
}, function(error) {
|
||||
let errorText;
|
||||
if (error.httpStatus === 403) {
|
||||
errorText = _t("Guest access is disabled on this homeserver.");
|
||||
} else {
|
||||
errorText = self._errorTextFromError(error);
|
||||
}
|
||||
self.setState({
|
||||
errorText: errorText,
|
||||
loginIncorrect: false,
|
||||
});
|
||||
}).finally(function() {
|
||||
self.setState({
|
||||
busy: false,
|
||||
});
|
||||
}).done();
|
||||
},
|
||||
|
||||
onUsernameChanged: function(username) {
|
||||
this.setState({ username: username });
|
||||
},
|
||||
@@ -627,14 +599,6 @@ module.exports = React.createClass({
|
||||
|
||||
const errorText = this.props.defaultServerDiscoveryError || this.state.discoveryError || this.state.errorText;
|
||||
|
||||
let loginAsGuestJsx;
|
||||
if (this.props.enableGuest) {
|
||||
loginAsGuestJsx =
|
||||
<a className="mx_AuthBody_changeFlow" onClick={this._onLoginAsGuestClick} href="#">
|
||||
{ _t('Try the app first') }
|
||||
</a>;
|
||||
}
|
||||
|
||||
let errorTextSection;
|
||||
if (errorText) {
|
||||
errorTextSection = (
|
||||
@@ -658,7 +622,6 @@ module.exports = React.createClass({
|
||||
<a className="mx_AuthBody_changeFlow" onClick={this.onRegisterClick} href="#">
|
||||
{ _t('Create account') }
|
||||
</a>
|
||||
{ loginAsGuestJsx }
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
|
||||
@@ -28,8 +28,6 @@ import SdkConfig from '../../../SdkConfig';
|
||||
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
|
||||
import * as ServerType from '../../views/auth/ServerTypeSelector';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 6;
|
||||
|
||||
// Phases
|
||||
// Show controls to configure server details
|
||||
const PHASE_SERVER_DETAILS = 0;
|
||||
@@ -60,7 +58,6 @@ module.exports = React.createClass({
|
||||
customIsUrl: PropTypes.string,
|
||||
defaultHsUrl: PropTypes.string,
|
||||
defaultIsUrl: PropTypes.string,
|
||||
skipServerDetails: PropTypes.bool,
|
||||
brand: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
// registration shouldn't know or care how login is done.
|
||||
@@ -71,26 +68,6 @@ module.exports = React.createClass({
|
||||
getInitialState: function() {
|
||||
const serverType = ServerType.getTypeFromHsUrl(this.props.customHsUrl);
|
||||
|
||||
const customURLsAllowed = !SdkConfig.get()['disable_custom_urls'];
|
||||
let initialPhase = this.getDefaultPhaseForServerType(serverType);
|
||||
if (
|
||||
// if we have these two, skip to the good bit
|
||||
// (they could come in from the URL params in a
|
||||
// registration email link)
|
||||
(this.props.clientSecret && this.props.sessionId) ||
|
||||
// if custom URLs aren't allowed, skip to form
|
||||
!customURLsAllowed ||
|
||||
// if other logic says to, skip to form
|
||||
this.props.skipServerDetails
|
||||
) {
|
||||
// TODO: It would seem we've now added enough conditions here that the initial
|
||||
// phase will _always_ be the form. It's tempting to remove the complexity and
|
||||
// just do that, but we keep tweaking and changing auth, so let's wait until
|
||||
// things settle a bit.
|
||||
// Filed https://github.com/vector-im/riot-web/issues/8886 to track this.
|
||||
initialPhase = PHASE_REGISTRATION;
|
||||
}
|
||||
|
||||
return {
|
||||
busy: false,
|
||||
errorText: null,
|
||||
@@ -113,7 +90,7 @@ module.exports = React.createClass({
|
||||
hsUrl: this.props.customHsUrl,
|
||||
isUrl: this.props.customIsUrl,
|
||||
// Phase of the overall registration dialog.
|
||||
phase: initialPhase,
|
||||
phase: PHASE_REGISTRATION,
|
||||
flows: null,
|
||||
};
|
||||
},
|
||||
@@ -308,58 +285,6 @@ module.exports = React.createClass({
|
||||
});
|
||||
},
|
||||
|
||||
onFormValidationChange: function(fieldErrors) {
|
||||
// `fieldErrors` is an object mapping field IDs to error codes when there is an
|
||||
// error or `null` for no error, so the values array will be something like:
|
||||
// `[ null, "RegistrationForm.ERR_PASSWORD_MISSING", null]`
|
||||
// Find the first non-null error code and show that.
|
||||
const errCode = Object.values(fieldErrors).find(value => !!value);
|
||||
if (!errCode) {
|
||||
this.setState({
|
||||
errorText: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let errMsg;
|
||||
switch (errCode) {
|
||||
case "RegistrationForm.ERR_PASSWORD_MISSING":
|
||||
errMsg = _t('Missing password.');
|
||||
break;
|
||||
case "RegistrationForm.ERR_PASSWORD_MISMATCH":
|
||||
errMsg = _t('Passwords don\'t match.');
|
||||
break;
|
||||
case "RegistrationForm.ERR_PASSWORD_LENGTH":
|
||||
errMsg = _t('Password too short (min %(MIN_PASSWORD_LENGTH)s).', {MIN_PASSWORD_LENGTH});
|
||||
break;
|
||||
case "RegistrationForm.ERR_EMAIL_INVALID":
|
||||
errMsg = _t('This doesn\'t look like a valid email address.');
|
||||
break;
|
||||
case "RegistrationForm.ERR_PHONE_NUMBER_INVALID":
|
||||
errMsg = _t('This doesn\'t look like a valid phone number.');
|
||||
break;
|
||||
case "RegistrationForm.ERR_MISSING_EMAIL":
|
||||
errMsg = _t('An email address is required to register on this homeserver.');
|
||||
break;
|
||||
case "RegistrationForm.ERR_MISSING_PHONE_NUMBER":
|
||||
errMsg = _t('A phone number is required to register on this homeserver.');
|
||||
break;
|
||||
case "RegistrationForm.ERR_USERNAME_INVALID":
|
||||
errMsg = _t("A username can only contain lower case letters, numbers and '=_-./'");
|
||||
break;
|
||||
case "RegistrationForm.ERR_USERNAME_BLANK":
|
||||
errMsg = _t('You need to enter a username.');
|
||||
break;
|
||||
default:
|
||||
console.error("Unknown error code: %s", errCode);
|
||||
errMsg = _t('An unknown error occurred.');
|
||||
break;
|
||||
}
|
||||
this.setState({
|
||||
errorText: errMsg,
|
||||
});
|
||||
},
|
||||
|
||||
onLoginClick: function(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -534,8 +459,6 @@ module.exports = React.createClass({
|
||||
defaultPhoneCountry={this.state.formVals.phoneCountry}
|
||||
defaultPhoneNumber={this.state.formVals.phoneNumber}
|
||||
defaultPassword={this.state.formVals.password}
|
||||
minPasswordLength={MIN_PASSWORD_LENGTH}
|
||||
onValidationChange={this.onFormValidationChange}
|
||||
onRegisterClick={this.onFormSubmit}
|
||||
onEditServerDetailsClick={onEditServerDetailsClick}
|
||||
flows={this.state.flows}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
Copyright 2017 Vector Creations 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.
|
||||
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';
|
||||
|
||||
const React = require('react');
|
||||
import { _t } from '../../../languageHandler';
|
||||
const dis = require('../../../dispatcher');
|
||||
const AccessibleButton = require('../elements/AccessibleButton');
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'AuthButtons',
|
||||
|
||||
propTypes: {
|
||||
},
|
||||
|
||||
onLoginClick: function() {
|
||||
dis.dispatch({ action: 'start_login' });
|
||||
},
|
||||
|
||||
onRegisterClick: function() {
|
||||
dis.dispatch({ action: 'start_registration' });
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const loginButton = (
|
||||
<div className="mx_AuthButtons_loginButton_wrapper">
|
||||
<AccessibleButton className="mx_AuthButtons_loginButton" element="button" onClick={this.onLoginClick}>
|
||||
{ _t("Login") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className="mx_AuthButtons_registerButton" element="button" onClick={this.onRegisterClick}>
|
||||
{ _t("Register") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx_AuthButtons">
|
||||
{ loginButton }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import Modal from '../../../Modal';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
|
||||
import withValidation from '../elements/Validation';
|
||||
|
||||
const FIELD_EMAIL = 'field_email';
|
||||
const FIELD_PHONE_NUMBER = 'field_phone_number';
|
||||
@@ -32,6 +33,8 @@ const FIELD_USERNAME = 'field_username';
|
||||
const FIELD_PASSWORD = 'field_password';
|
||||
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
|
||||
|
||||
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
|
||||
|
||||
/**
|
||||
* A pure UI component which displays a registration form.
|
||||
*/
|
||||
@@ -45,8 +48,6 @@ module.exports = React.createClass({
|
||||
defaultPhoneNumber: PropTypes.string,
|
||||
defaultUsername: PropTypes.string,
|
||||
defaultPassword: PropTypes.string,
|
||||
minPasswordLength: PropTypes.number,
|
||||
onValidationChange: PropTypes.func,
|
||||
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
|
||||
onEditServerDetailsClick: PropTypes.func,
|
||||
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
@@ -59,7 +60,6 @@ module.exports = React.createClass({
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
minPasswordLength: 6,
|
||||
onValidationChange: console.error,
|
||||
};
|
||||
},
|
||||
@@ -67,7 +67,7 @@ module.exports = React.createClass({
|
||||
getInitialState: function() {
|
||||
return {
|
||||
// Field error codes by field ID
|
||||
fieldErrors: {},
|
||||
fieldValid: {},
|
||||
// The ISO2 country code selected in the phone number entry
|
||||
phoneCountry: this.props.defaultPhoneCountry,
|
||||
username: "",
|
||||
@@ -75,44 +75,37 @@ module.exports = React.createClass({
|
||||
phoneNumber: "",
|
||||
password: "",
|
||||
passwordConfirm: "",
|
||||
passwordComplexity: null,
|
||||
};
|
||||
},
|
||||
|
||||
onSubmit: function(ev) {
|
||||
onSubmit: async function(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
// validate everything, in reverse order so
|
||||
// the error that ends up being displayed
|
||||
// is the one from the first invalid field.
|
||||
// It's not super ideal that this just calls
|
||||
// onValidationChange once for each invalid field.
|
||||
this.validateField(FIELD_PHONE_NUMBER, ev.type);
|
||||
this.validateField(FIELD_EMAIL, ev.type);
|
||||
this.validateField(FIELD_PASSWORD_CONFIRM, ev.type);
|
||||
this.validateField(FIELD_PASSWORD, ev.type);
|
||||
this.validateField(FIELD_USERNAME, ev.type);
|
||||
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
|
||||
if (!allFieldsValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
if (this.allFieldsValid()) {
|
||||
if (this.state.email == '') {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
|
||||
title: _t("Warning!"),
|
||||
description:
|
||||
<div>
|
||||
{ _t("If you don't specify an email address, you won't be able to reset your password. " +
|
||||
"Are you sure?") }
|
||||
</div>,
|
||||
button: _t("Continue"),
|
||||
onFinished: function(confirmed) {
|
||||
if (confirmed) {
|
||||
self._doSubmit(ev);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
self._doSubmit(ev);
|
||||
}
|
||||
if (this.state.email == '') {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
|
||||
title: _t("Warning!"),
|
||||
description:
|
||||
<div>
|
||||
{ _t("If you don't specify an email address, you won't be able to reset your password. " +
|
||||
"Are you sure?") }
|
||||
</div>,
|
||||
button: _t("Continue"),
|
||||
onFinished: function(confirmed) {
|
||||
if (confirmed) {
|
||||
self._doSubmit(ev);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
self._doSubmit(ev);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -134,118 +127,81 @@ module.exports = React.createClass({
|
||||
}
|
||||
},
|
||||
|
||||
async verifyFieldsBeforeSubmit() {
|
||||
// Blur the active element if any, so we first run its blur validation,
|
||||
// which is less strict than the pass we're about to do below for all fields.
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
|
||||
const fieldIDsInDisplayOrder = [
|
||||
FIELD_USERNAME,
|
||||
FIELD_PASSWORD,
|
||||
FIELD_PASSWORD_CONFIRM,
|
||||
FIELD_EMAIL,
|
||||
FIELD_PHONE_NUMBER,
|
||||
];
|
||||
|
||||
// Run all fields with stricter validation that no longer allows empty
|
||||
// values for required fields.
|
||||
for (const fieldID of fieldIDsInDisplayOrder) {
|
||||
const field = this[fieldID];
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
field.validate({ allowEmpty: false });
|
||||
}
|
||||
|
||||
// Validation and state updates are async, so we need to wait for them to complete
|
||||
// first. Queue a `setState` callback and wait for it to resolve.
|
||||
await new Promise(resolve => this.setState({}, resolve));
|
||||
|
||||
if (this.allFieldsValid()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
|
||||
|
||||
if (!invalidField) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Focus the first invalid field and show feedback in the stricter mode
|
||||
// that no longer allows empty values for required fields.
|
||||
invalidField.focus();
|
||||
invalidField.validate({ allowEmpty: false, focused: true });
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* @returns {boolean} true if all fields were valid last time they were validated.
|
||||
*/
|
||||
allFieldsValid: function() {
|
||||
const keys = Object.keys(this.state.fieldErrors);
|
||||
const keys = Object.keys(this.state.fieldValid);
|
||||
for (let i = 0; i < keys.length; ++i) {
|
||||
if (this.state.fieldErrors[keys[i]]) {
|
||||
if (!this.state.fieldValid[keys[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
validateField: function(fieldID, eventType) {
|
||||
const pwd1 = this.state.password.trim();
|
||||
const pwd2 = this.state.passwordConfirm.trim();
|
||||
const allowEmpty = eventType === "blur";
|
||||
|
||||
switch (fieldID) {
|
||||
case FIELD_EMAIL: {
|
||||
const email = this.state.email;
|
||||
const emailValid = email === '' || Email.looksValid(email);
|
||||
if (this._authStepIsRequired('m.login.email.identity') && (!emailValid || email === '')) {
|
||||
this.markFieldValid(fieldID, false, "RegistrationForm.ERR_MISSING_EMAIL");
|
||||
} else this.markFieldValid(fieldID, emailValid, "RegistrationForm.ERR_EMAIL_INVALID");
|
||||
break;
|
||||
findFirstInvalidField(fieldIDs) {
|
||||
for (const fieldID of fieldIDs) {
|
||||
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
|
||||
return this[fieldID];
|
||||
}
|
||||
case FIELD_PHONE_NUMBER: {
|
||||
const phoneNumber = this.state.phoneNumber;
|
||||
const phoneNumberValid = phoneNumber === '' || phoneNumberLooksValid(phoneNumber);
|
||||
if (this._authStepIsRequired('m.login.msisdn') && (!phoneNumberValid || phoneNumber === '')) {
|
||||
this.markFieldValid(fieldID, false, "RegistrationForm.ERR_MISSING_PHONE_NUMBER");
|
||||
} else this.markFieldValid(fieldID, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID");
|
||||
break;
|
||||
}
|
||||
case FIELD_USERNAME: {
|
||||
const username = this.state.username;
|
||||
if (allowEmpty && username === '') {
|
||||
this.markFieldValid(fieldID, true);
|
||||
} else if (!SAFE_LOCALPART_REGEX.test(username)) {
|
||||
this.markFieldValid(
|
||||
fieldID,
|
||||
false,
|
||||
"RegistrationForm.ERR_USERNAME_INVALID",
|
||||
);
|
||||
} else if (username == '') {
|
||||
this.markFieldValid(
|
||||
fieldID,
|
||||
false,
|
||||
"RegistrationForm.ERR_USERNAME_BLANK",
|
||||
);
|
||||
} else {
|
||||
this.markFieldValid(fieldID, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case FIELD_PASSWORD:
|
||||
if (allowEmpty && pwd1 === "") {
|
||||
this.markFieldValid(fieldID, true);
|
||||
} else if (pwd1 == '') {
|
||||
this.markFieldValid(
|
||||
fieldID,
|
||||
false,
|
||||
"RegistrationForm.ERR_PASSWORD_MISSING",
|
||||
);
|
||||
} else if (pwd1.length < this.props.minPasswordLength) {
|
||||
this.markFieldValid(
|
||||
fieldID,
|
||||
false,
|
||||
"RegistrationForm.ERR_PASSWORD_LENGTH",
|
||||
);
|
||||
} else {
|
||||
this.markFieldValid(fieldID, true);
|
||||
}
|
||||
break;
|
||||
case FIELD_PASSWORD_CONFIRM:
|
||||
if (allowEmpty && pwd2 === "") {
|
||||
this.markFieldValid(fieldID, true);
|
||||
} else {
|
||||
this.markFieldValid(
|
||||
fieldID, pwd1 == pwd2,
|
||||
"RegistrationForm.ERR_PASSWORD_MISMATCH",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
markFieldValid: function(fieldID, valid, errorCode) {
|
||||
const { fieldErrors } = this.state;
|
||||
if (valid) {
|
||||
fieldErrors[fieldID] = null;
|
||||
} else {
|
||||
fieldErrors[fieldID] = errorCode;
|
||||
}
|
||||
markFieldValid: function(fieldID, valid) {
|
||||
const { fieldValid } = this.state;
|
||||
fieldValid[fieldID] = valid;
|
||||
this.setState({
|
||||
fieldErrors,
|
||||
fieldValid,
|
||||
});
|
||||
this.props.onValidationChange(fieldErrors);
|
||||
},
|
||||
|
||||
_classForField: function(fieldID, ...baseClasses) {
|
||||
let cls = baseClasses.join(' ');
|
||||
if (this.state.fieldErrors[fieldID]) {
|
||||
if (cls) cls += ' ';
|
||||
cls += 'error';
|
||||
}
|
||||
return cls;
|
||||
},
|
||||
|
||||
onEmailBlur(ev) {
|
||||
this.validateField(FIELD_EMAIL, ev.type);
|
||||
},
|
||||
|
||||
onEmailChange(ev) {
|
||||
@@ -254,26 +210,113 @@ module.exports = React.createClass({
|
||||
});
|
||||
},
|
||||
|
||||
onPasswordBlur(ev) {
|
||||
this.validateField(FIELD_PASSWORD, ev.type);
|
||||
async onEmailValidate(fieldState) {
|
||||
const result = await this.validateEmailRules(fieldState);
|
||||
this.markFieldValid(FIELD_EMAIL, result.valid);
|
||||
return result;
|
||||
},
|
||||
|
||||
validateEmailRules: withValidation({
|
||||
description: () => _t("Use an email address to recover your account"),
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: function({ value, allowEmpty }) {
|
||||
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter email address (required on this homeserver)"),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
test: ({ value }) => !value || Email.looksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid email address"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
onPasswordChange(ev) {
|
||||
this.setState({
|
||||
password: ev.target.value,
|
||||
});
|
||||
},
|
||||
|
||||
onPasswordConfirmBlur(ev) {
|
||||
this.validateField(FIELD_PASSWORD_CONFIRM, ev.type);
|
||||
async onPasswordValidate(fieldState) {
|
||||
const result = await this.validatePasswordRules(fieldState);
|
||||
this.markFieldValid(FIELD_PASSWORD, result.valid);
|
||||
return result;
|
||||
},
|
||||
|
||||
validatePasswordRules: withValidation({
|
||||
description: function() {
|
||||
const complexity = this.state.passwordComplexity;
|
||||
const score = complexity ? complexity.score : 0;
|
||||
return <progress
|
||||
className="mx_AuthBody_passwordScore"
|
||||
max={PASSWORD_MIN_SCORE}
|
||||
value={score}
|
||||
/>;
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: ({ value, allowEmpty }) => allowEmpty || !!value,
|
||||
invalid: () => _t("Enter password"),
|
||||
},
|
||||
{
|
||||
key: "complexity",
|
||||
test: async function({ value }) {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const { scorePassword } = await import('../../../utils/PasswordScorer');
|
||||
const complexity = scorePassword(value);
|
||||
this.setState({
|
||||
passwordComplexity: complexity,
|
||||
});
|
||||
return complexity.score >= PASSWORD_MIN_SCORE;
|
||||
},
|
||||
valid: () => _t("Nice, strong password!"),
|
||||
invalid: function() {
|
||||
const complexity = this.state.passwordComplexity;
|
||||
if (!complexity) {
|
||||
return null;
|
||||
}
|
||||
const { feedback } = complexity;
|
||||
return feedback.warning || feedback.suggestions[0] || _t("Keep going...");
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
onPasswordConfirmChange(ev) {
|
||||
this.setState({
|
||||
passwordConfirm: ev.target.value,
|
||||
});
|
||||
},
|
||||
|
||||
async onPasswordConfirmValidate(fieldState) {
|
||||
const result = await this.validatePasswordConfirmRules(fieldState);
|
||||
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid);
|
||||
return result;
|
||||
},
|
||||
|
||||
validatePasswordConfirmRules: withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: ({ value, allowEmpty }) => allowEmpty || !!value,
|
||||
invalid: () => _t("Confirm password"),
|
||||
},
|
||||
{
|
||||
key: "match",
|
||||
test: function({ value }) {
|
||||
return !value || value === this.state.password;
|
||||
},
|
||||
invalid: () => _t("Passwords don't match"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
onPhoneCountryChange(newVal) {
|
||||
this.setState({
|
||||
phoneCountry: newVal.iso2,
|
||||
@@ -281,26 +324,64 @@ module.exports = React.createClass({
|
||||
});
|
||||
},
|
||||
|
||||
onPhoneNumberBlur(ev) {
|
||||
this.validateField(FIELD_PHONE_NUMBER, ev.type);
|
||||
},
|
||||
|
||||
onPhoneNumberChange(ev) {
|
||||
this.setState({
|
||||
phoneNumber: ev.target.value,
|
||||
});
|
||||
},
|
||||
|
||||
onUsernameBlur(ev) {
|
||||
this.validateField(FIELD_USERNAME, ev.type);
|
||||
async onPhoneNumberValidate(fieldState) {
|
||||
const result = await this.validatePhoneNumberRules(fieldState);
|
||||
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid);
|
||||
return result;
|
||||
},
|
||||
|
||||
validatePhoneNumberRules: withValidation({
|
||||
description: () => _t("Other users can invite you to rooms using your contact details"),
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: function({ value, allowEmpty }) {
|
||||
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter phone number (required on this homeserver)"),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
test: ({ value }) => !value || phoneNumberLooksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid phone number"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
onUsernameChange(ev) {
|
||||
this.setState({
|
||||
username: ev.target.value,
|
||||
});
|
||||
},
|
||||
|
||||
async onUsernameValidate(fieldState) {
|
||||
const result = await this.validateUsernameRules(fieldState);
|
||||
this.markFieldValid(FIELD_USERNAME, result.valid);
|
||||
return result;
|
||||
},
|
||||
|
||||
validateUsernameRules: withValidation({
|
||||
description: () => _t("Use letters, numbers, dashes and underscores only"),
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: ({ value, allowEmpty }) => allowEmpty || !!value,
|
||||
invalid: () => _t("Enter username"),
|
||||
},
|
||||
{
|
||||
key: "safeLocalpart",
|
||||
test: ({ value }) => !value || SAFE_LOCALPART_REGEX.test(value),
|
||||
invalid: () => _t("Some characters not allowed"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
/**
|
||||
* A step is required if all flows include that step.
|
||||
*
|
||||
@@ -325,9 +406,99 @@ module.exports = React.createClass({
|
||||
});
|
||||
},
|
||||
|
||||
render: function() {
|
||||
renderEmail() {
|
||||
if (!this._authStepIsUsed('m.login.email.identity')) {
|
||||
return null;
|
||||
}
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
|
||||
_t("Email") :
|
||||
_t("Email (optional)");
|
||||
return <Field
|
||||
id="mx_RegistrationForm_email"
|
||||
ref={field => this[FIELD_EMAIL] = field}
|
||||
type="text"
|
||||
label={emailPlaceholder}
|
||||
defaultValue={this.props.defaultEmail}
|
||||
value={this.state.email}
|
||||
onChange={this.onEmailChange}
|
||||
onValidate={this.onEmailValidate}
|
||||
/>;
|
||||
},
|
||||
|
||||
renderPassword() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
return <Field
|
||||
id="mx_RegistrationForm_password"
|
||||
ref={field => this[FIELD_PASSWORD] = field}
|
||||
type="password"
|
||||
label={_t("Password")}
|
||||
defaultValue={this.props.defaultPassword}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChange}
|
||||
onValidate={this.onPasswordValidate}
|
||||
/>;
|
||||
},
|
||||
|
||||
renderPasswordConfirm() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
return <Field
|
||||
id="mx_RegistrationForm_passwordConfirm"
|
||||
ref={field => this[FIELD_PASSWORD_CONFIRM] = field}
|
||||
type="password"
|
||||
label={_t("Confirm")}
|
||||
defaultValue={this.props.defaultPassword}
|
||||
value={this.state.passwordConfirm}
|
||||
onChange={this.onPasswordConfirmChange}
|
||||
onValidate={this.onPasswordConfirmValidate}
|
||||
/>;
|
||||
},
|
||||
|
||||
renderPhoneNumber() {
|
||||
const threePidLogin = !SdkConfig.get().disable_3pid_login;
|
||||
if (!threePidLogin || !this._authStepIsUsed('m.login.msisdn')) {
|
||||
return null;
|
||||
}
|
||||
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ?
|
||||
_t("Phone") :
|
||||
_t("Phone (optional)");
|
||||
const phoneCountry = <CountryDropdown
|
||||
value={this.state.phoneCountry}
|
||||
isSmall={true}
|
||||
showPrefix={true}
|
||||
onOptionChange={this.onPhoneCountryChange}
|
||||
/>;
|
||||
return <Field
|
||||
id="mx_RegistrationForm_phoneNumber"
|
||||
ref={field => this[FIELD_PHONE_NUMBER] = field}
|
||||
type="text"
|
||||
label={phoneLabel}
|
||||
defaultValue={this.props.defaultPhoneNumber}
|
||||
value={this.state.phoneNumber}
|
||||
prefix={phoneCountry}
|
||||
onChange={this.onPhoneNumberChange}
|
||||
onValidate={this.onPhoneNumberValidate}
|
||||
/>;
|
||||
},
|
||||
|
||||
renderUsername() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
return <Field
|
||||
id="mx_RegistrationForm_username"
|
||||
ref={field => this[FIELD_USERNAME] = field}
|
||||
type="text"
|
||||
autoFocus={true}
|
||||
label={_t("Username")}
|
||||
defaultValue={this.props.defaultUsername}
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChange}
|
||||
onValidate={this.onUsernameValidate}
|
||||
/>;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
let yourMatrixAccountText = _t('Create your Matrix account');
|
||||
if (this.props.hsName) {
|
||||
yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
|
||||
@@ -353,53 +524,6 @@ module.exports = React.createClass({
|
||||
</a>;
|
||||
}
|
||||
|
||||
let emailSection;
|
||||
if (this._authStepIsUsed('m.login.email.identity')) {
|
||||
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
|
||||
_t("Email") :
|
||||
_t("Email (optional)");
|
||||
|
||||
emailSection = (
|
||||
<Field
|
||||
className={this._classForField(FIELD_EMAIL)}
|
||||
id="mx_RegistrationForm_email"
|
||||
type="text"
|
||||
label={emailPlaceholder}
|
||||
defaultValue={this.props.defaultEmail}
|
||||
value={this.state.email}
|
||||
onBlur={this.onEmailBlur}
|
||||
onChange={this.onEmailChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const threePidLogin = !SdkConfig.get().disable_3pid_login;
|
||||
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
|
||||
let phoneSection;
|
||||
if (threePidLogin && this._authStepIsUsed('m.login.msisdn')) {
|
||||
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ?
|
||||
_t("Phone") :
|
||||
_t("Phone (optional)");
|
||||
const phoneCountry = <CountryDropdown
|
||||
value={this.state.phoneCountry}
|
||||
isSmall={true}
|
||||
showPrefix={true}
|
||||
onOptionChange={this.onPhoneCountryChange}
|
||||
/>;
|
||||
|
||||
phoneSection = <Field
|
||||
className={this._classForField(FIELD_PHONE_NUMBER)}
|
||||
id="mx_RegistrationForm_phoneNumber"
|
||||
type="text"
|
||||
label={phoneLabel}
|
||||
defaultValue={this.props.defaultPhoneNumber}
|
||||
value={this.state.phoneNumber}
|
||||
prefix={phoneCountry}
|
||||
onBlur={this.onPhoneNumberBlur}
|
||||
onChange={this.onPhoneNumberChange}
|
||||
/>;
|
||||
}
|
||||
|
||||
const registerButton = (
|
||||
<input className="mx_Login_submit" type="submit" value={_t("Register")} />
|
||||
);
|
||||
@@ -412,48 +536,18 @@ module.exports = React.createClass({
|
||||
</h3>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
<Field
|
||||
className={this._classForField(FIELD_USERNAME)}
|
||||
id="mx_RegistrationForm_username"
|
||||
type="text"
|
||||
autoFocus={true}
|
||||
label={_t("Username")}
|
||||
defaultValue={this.props.defaultUsername}
|
||||
value={this.state.username}
|
||||
onBlur={this.onUsernameBlur}
|
||||
onChange={this.onUsernameChange}
|
||||
/>
|
||||
{this.renderUsername()}
|
||||
</div>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
<Field
|
||||
className={this._classForField(FIELD_PASSWORD)}
|
||||
id="mx_RegistrationForm_password"
|
||||
type="password"
|
||||
label={_t("Password")}
|
||||
defaultValue={this.props.defaultPassword}
|
||||
value={this.state.password}
|
||||
onBlur={this.onPasswordBlur}
|
||||
onChange={this.onPasswordChange}
|
||||
/>
|
||||
<Field
|
||||
className={this._classForField(FIELD_PASSWORD_CONFIRM)}
|
||||
id="mx_RegistrationForm_passwordConfirm"
|
||||
type="password"
|
||||
label={_t("Confirm")}
|
||||
defaultValue={this.props.defaultPassword}
|
||||
value={this.state.passwordConfirm}
|
||||
onBlur={this.onPasswordConfirmBlur}
|
||||
onChange={this.onPasswordConfirmChange}
|
||||
/>
|
||||
{this.renderPassword()}
|
||||
{this.renderPasswordConfirm()}
|
||||
</div>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
{ emailSection }
|
||||
{ phoneSection }
|
||||
{this.renderEmail()}
|
||||
{this.renderPhoneNumber()}
|
||||
</div>
|
||||
{_t(
|
||||
"Use an email address to recover your account. Other users " +
|
||||
"can invite you to rooms using your contact details.",
|
||||
)}
|
||||
{_t("Use an email address to recover your account.") + " "}
|
||||
{_t("Other users can invite you to rooms using your contact details.")}
|
||||
{ registerButton }
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import Modal from '../../../Modal';
|
||||
import Resend from '../../../Resend';
|
||||
import SettingsStore from '../../../settings/SettingsStore';
|
||||
import { isUrlPermitted } from '../../../HtmlUtils';
|
||||
import { isContentActionable } from '../../../utils/EventUtils';
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'MessageContextMenu',
|
||||
@@ -201,14 +202,6 @@ module.exports = React.createClass({
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
onReplyClick: function() {
|
||||
dis.dispatch({
|
||||
action: 'reply_to_event',
|
||||
event: this.props.mxEvent,
|
||||
});
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
onCollapseReplyThreadClick: function() {
|
||||
this.props.collapseReplyThread();
|
||||
this.closeMenu();
|
||||
@@ -226,7 +219,6 @@ module.exports = React.createClass({
|
||||
let unhidePreviewButton;
|
||||
let externalURLButton;
|
||||
let quoteButton;
|
||||
let replyButton;
|
||||
let collapseReplyThread;
|
||||
|
||||
// status is SENT before remote-echo, null after
|
||||
@@ -256,28 +248,19 @@ module.exports = React.createClass({
|
||||
);
|
||||
}
|
||||
|
||||
if (isSent && mxEvent.getType() === 'm.room.message') {
|
||||
const content = mxEvent.getContent();
|
||||
if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) {
|
||||
forwardButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
|
||||
{ _t('Forward Message') }
|
||||
if (isContentActionable(mxEvent)) {
|
||||
forwardButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
|
||||
{ _t('Forward Message') }
|
||||
</div>
|
||||
);
|
||||
|
||||
if (this.state.canPin) {
|
||||
pinButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
|
||||
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
|
||||
</div>
|
||||
);
|
||||
|
||||
replyButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onReplyClick}>
|
||||
{ _t('Reply') }
|
||||
</div>
|
||||
);
|
||||
|
||||
if (this.state.canPin) {
|
||||
pinButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
|
||||
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +351,6 @@ module.exports = React.createClass({
|
||||
{ unhidePreviewButton }
|
||||
{ permalinkButton }
|
||||
{ quoteButton }
|
||||
{ replyButton }
|
||||
{ externalURLButton }
|
||||
{ collapseReplyThread }
|
||||
{ e2eInfo }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2017, 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.
|
||||
@@ -566,7 +566,7 @@ module.exports = React.createClass({
|
||||
rows="1"
|
||||
id="textinput"
|
||||
ref="textinput"
|
||||
className="mx_ChatInviteDialog_input"
|
||||
className="mx_AddressPickerDialog_input"
|
||||
onChange={this.onQueryChanged}
|
||||
placeholder={this.props.placeholder}
|
||||
defaultValue={this.props.value}
|
||||
@@ -578,7 +578,7 @@ module.exports = React.createClass({
|
||||
let addressSelector;
|
||||
if (this.state.error) {
|
||||
const validTypeDescriptions = this.props.validAddressTypes.map((t) => _t(addressTypeName[t]));
|
||||
error = <div className="mx_ChatInviteDialog_error">
|
||||
error = <div className="mx_AddressPickerDialog_error">
|
||||
{ _t("You have entered an invalid address.") }
|
||||
<br />
|
||||
{ _t("Try using one of the following valid address types: %(validTypesList)s.", {
|
||||
@@ -586,9 +586,9 @@ module.exports = React.createClass({
|
||||
}) }
|
||||
</div>;
|
||||
} else if (this.state.searchError) {
|
||||
error = <div className="mx_ChatInviteDialog_error">{ this.state.searchError }</div>;
|
||||
error = <div className="mx_AddressPickerDialog_error">{ this.state.searchError }</div>;
|
||||
} else if (this.state.query.length > 0 && filteredSuggestedList.length === 0 && !this.state.busy) {
|
||||
error = <div className="mx_ChatInviteDialog_error">{ _t("No results") }</div>;
|
||||
error = <div className="mx_AddressPickerDialog_error">{ _t("No results") }</div>;
|
||||
} else {
|
||||
addressSelector = (
|
||||
<AddressSelector ref={(ref) => {this.addressSelector = ref;}}
|
||||
@@ -601,13 +601,13 @@ module.exports = React.createClass({
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_ChatInviteDialog" onKeyDown={this.onKeyDown}
|
||||
<BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown}
|
||||
onFinished={this.props.onFinished} title={this.props.title}>
|
||||
<div className="mx_ChatInviteDialog_label">
|
||||
<div className="mx_AddressPickerDialog_label">
|
||||
<label htmlFor="textinput">{ this.props.description }</label>
|
||||
</div>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_ChatInviteDialog_inputContainer">{ query }</div>
|
||||
<div className="mx_AddressPickerDialog_inputContainer">{ query }</div>
|
||||
{ error }
|
||||
{ addressSelector }
|
||||
{ this.props.extraNode }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
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.
|
||||
@@ -55,6 +55,11 @@ export default React.createClass({
|
||||
// CSS class to apply to dialog div
|
||||
className: PropTypes.string,
|
||||
|
||||
// if true, dialog container is 60% of the viewport width. Otherwise,
|
||||
// the container will have no fixed size, allowing its contents to
|
||||
// determine its size. Default: true.
|
||||
fixedWidth: PropTypes.bool,
|
||||
|
||||
// Title for the dialog.
|
||||
title: PropTypes.node.isRequired,
|
||||
|
||||
@@ -72,6 +77,7 @@ export default React.createClass({
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
hasCancel: true,
|
||||
fixedWidth: true,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -113,7 +119,10 @@ export default React.createClass({
|
||||
|
||||
return (
|
||||
<FocusTrap onKeyDown={this._onKeyDown}
|
||||
className={this.props.className}
|
||||
className={classNames({
|
||||
[this.props.className]: true,
|
||||
'mx_Dialog_fixedWidth': this.props.fixedWidth,
|
||||
})}
|
||||
role="dialog"
|
||||
aria-labelledby='mx_BaseDialog_title'
|
||||
// This should point to a node describing the dialog.
|
||||
@@ -131,8 +140,8 @@ export default React.createClass({
|
||||
{ this.props.title }
|
||||
</div>
|
||||
{ this.props.headerButton }
|
||||
{ cancelButton }
|
||||
</div>
|
||||
{ cancelButton }
|
||||
{ this.props.children }
|
||||
</FocusTrap>
|
||||
);
|
||||
|
||||
@@ -108,6 +108,7 @@ export default class BugReportDialog extends React.Component {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
||||
let error = null;
|
||||
if (this.state.err) {
|
||||
@@ -154,36 +155,29 @@ export default class BugReportDialog extends React.Component {
|
||||
},
|
||||
) }
|
||||
</b></p>
|
||||
<div className="mx_BugReportDialog_field_container">
|
||||
<label
|
||||
htmlFor="mx_BugReportDialog_issueUrl"
|
||||
className="mx_BugReportDialog_field_label"
|
||||
>
|
||||
{ _t("What GitHub issue are these logs for?") }
|
||||
</label>
|
||||
<input
|
||||
id="mx_BugReportDialog_issueUrl"
|
||||
type="text"
|
||||
className="mx_BugReportDialog_field_input"
|
||||
onChange={this._onIssueUrlChange}
|
||||
value={this.state.issueUrl}
|
||||
placeholder="https://github.com/vector-im/riot-web/issues/..."
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_BugReportDialog_field_container">
|
||||
<label
|
||||
htmlFor="mx_BugReportDialog_notes_label"
|
||||
className="mx_BugReportDialog_field_label"
|
||||
>
|
||||
{ _t("Notes:") }
|
||||
</label>
|
||||
<textarea
|
||||
className="mx_BugReportDialog_field_input"
|
||||
rows={5}
|
||||
onChange={this._onTextChange}
|
||||
value={this.state.text}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
id="mx_BugReportDialog_issueUrl"
|
||||
type="text"
|
||||
className="mx_BugReportDialog_field_input"
|
||||
label={_t("GitHub issue")}
|
||||
onChange={this._onIssueUrlChange}
|
||||
value={this.state.issueUrl}
|
||||
placeholder="https://github.com/vector-im/riot-web/issues/..."
|
||||
/>
|
||||
<Field
|
||||
className="mx_BugReportDialog_field_input"
|
||||
element="textarea"
|
||||
label={_t("Notes")}
|
||||
rows={5}
|
||||
onChange={this._onTextChange}
|
||||
value={this.state.text}
|
||||
placeholder={_t(
|
||||
"If there is additional context that would help in " +
|
||||
"analysing the issue, such as what you were doing at " +
|
||||
"the time, room IDs, user IDs, etc., " +
|
||||
"please include those things here.",
|
||||
)}
|
||||
/>
|
||||
{progress}
|
||||
{error}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ import sdk from '../../../index';
|
||||
import Analytics from '../../../Analytics';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import * as Lifecycle from '../../../Lifecycle';
|
||||
import Velocity from 'velocity-vector';
|
||||
import Velocity from 'velocity-animate';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
export default class DeactivateAccountDialog extends React.Component {
|
||||
|
||||
@@ -130,7 +130,7 @@ export default class LogoutDialog extends React.Component {
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
let setupButtonCaption;
|
||||
if (this.state.backupInfo) {
|
||||
setupButtonCaption = _t("Use Key Backup");
|
||||
setupButtonCaption = _t("Connect this device to Key Backup");
|
||||
} else {
|
||||
// if there's an error fetching the backup info, we'll just assume there's
|
||||
// no backup for the purpose of the button caption
|
||||
|
||||
@@ -52,7 +52,7 @@ export default class RoomSettingsDialog extends React.Component {
|
||||
tabs.push(new Tab(
|
||||
_td("Advanced"),
|
||||
"mx_RoomSettingsDialog_warningIcon",
|
||||
<AdvancedRoomSettingsTab roomId={this.props.roomId} />,
|
||||
<AdvancedRoomSettingsTab roomId={this.props.roomId} closeSettingsFn={this.props.onFinished} />,
|
||||
));
|
||||
|
||||
return tabs;
|
||||
|
||||
@@ -47,7 +47,9 @@ export default React.createClass({
|
||||
|
||||
_onUpgradeClick: function() {
|
||||
this.setState({busy: true});
|
||||
MatrixClientPeg.get().upgradeRoom(this.props.room.roomId, this._targetVersion).catch((err) => {
|
||||
MatrixClientPeg.get().upgradeRoom(this.props.room.roomId, this._targetVersion).then(() => {
|
||||
this.props.onFinished(true);
|
||||
}).catch((err) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to upgrade room', '', ErrorDialog, {
|
||||
title: _t("Failed to upgrade room"),
|
||||
@@ -82,10 +84,9 @@ export default React.createClass({
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_RoomUpgradeDialog"
|
||||
onFinished={this.onCancelled}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Upgrade Room Version")}
|
||||
contentId='mx_Dialog_content'
|
||||
onFinished={this.props.onFinished}
|
||||
hasCancel={true}
|
||||
>
|
||||
<p>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default React.createClass({
|
||||
Modal.createTrackedDialog('Session Restore Confirm Logout', '', QuestionDialog, {
|
||||
title: _t("Sign out"),
|
||||
description:
|
||||
<div>{ _t("Log out and remove encryption keys?") }</div>,
|
||||
<div>{ _t("Sign out and remove encryption keys?") }</div>,
|
||||
button: _t("Sign out"),
|
||||
danger: true,
|
||||
onFinished: this.props.onFinished,
|
||||
|
||||
77
src/components/views/dialogs/StorageEvictedDialog.js
Normal file
77
src/components/views/dialogs/StorageEvictedDialog.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
Copyright 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 PropTypes from 'prop-types';
|
||||
import sdk from '../../../index';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import Modal from '../../../Modal';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
export default class StorageEvictedDialog extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
_sendBugReport = ev => {
|
||||
ev.preventDefault();
|
||||
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
|
||||
Modal.createTrackedDialog('Storage evicted', 'Send Bug Report Dialog', BugReportDialog, {});
|
||||
};
|
||||
|
||||
_onSignOutClick = () => {
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
let logRequest;
|
||||
if (SdkConfig.get().bug_report_endpoint_url) {
|
||||
logRequest = _t(
|
||||
"To help us prevent this in future, please <a>send us logs</a>.", {},
|
||||
{
|
||||
a: text => <a href="#" onClick={this._sendBugReport}>{text}</a>,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
|
||||
title={_t('Missing session data')}
|
||||
contentId='mx_Dialog_content'
|
||||
hasCancel={false}
|
||||
>
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<p>{_t(
|
||||
"Some session data, including encrypted message keys, is " +
|
||||
"missing. Sign out and sign in to fix this, restoring keys " +
|
||||
"from backup.",
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"Your browser likely removed this data when running low on " +
|
||||
"disk space.",
|
||||
)} {logRequest}</p>
|
||||
</div>
|
||||
<DialogButtons primaryButton={_t("Sign out")}
|
||||
onPrimaryButtonClick={this._onSignOutClick}
|
||||
focus={true}
|
||||
hasCancel={false}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
107
src/components/views/dialogs/UploadConfirmDialog.js
Normal file
107
src/components/views/dialogs/UploadConfirmDialog.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
Copyright 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 PropTypes from 'prop-types';
|
||||
import sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
export default class UploadConfirmDialog extends React.Component {
|
||||
static propTypes = {
|
||||
file: PropTypes.object.isRequired,
|
||||
currentIndex: PropTypes.number,
|
||||
totalFiles: PropTypes.number,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
totalFiles: 1,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._objectUrl = URL.createObjectURL(props.file);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._objectUrl) URL.revokeObjectURL(this._objectUrl);
|
||||
}
|
||||
|
||||
_onCancelClick = () => {
|
||||
this.props.onFinished(false);
|
||||
}
|
||||
|
||||
_onUploadClick = () => {
|
||||
this.props.onFinished(true);
|
||||
}
|
||||
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
let title;
|
||||
if (this.props.totalFiles > 1 && this.props.currentIndex !== undefined) {
|
||||
title = _t(
|
||||
"Upload files (%(current)s of %(total)s)",
|
||||
{
|
||||
current: this.props.currentIndex + 1,
|
||||
total: this.props.totalFiles,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
title = _t('Upload files');
|
||||
}
|
||||
|
||||
let preview;
|
||||
if (this.props.file.type.startsWith('image/')) {
|
||||
preview = <div className="mx_UploadConfirmDialog_previewOuter">
|
||||
<div className="mx_UploadConfirmDialog_previewInner">
|
||||
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this._objectUrl} /></div>
|
||||
<div>{this.props.file.name}</div>
|
||||
</div>
|
||||
</div>;
|
||||
} else {
|
||||
preview = <div>
|
||||
<div>
|
||||
<img className="mx_UploadConfirmDialog_fileIcon"
|
||||
src={require("../../../../res/img/files.png")}
|
||||
/>
|
||||
{this.props.file.name}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog className='mx_UploadConfirmDialog'
|
||||
fixedWidth={false}
|
||||
onFinished={this._onCancelClick}
|
||||
title={title}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div id='mx_Dialog_content'>
|
||||
{preview}
|
||||
</div>
|
||||
|
||||
<DialogButtons primaryButton={_t('Upload')}
|
||||
hasCancel={false}
|
||||
onPrimaryButtonClick={this._onUploadClick}
|
||||
focus={true}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
120
src/components/views/dialogs/UploadFailureDialog.js
Normal file
120
src/components/views/dialogs/UploadFailureDialog.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
Copyright 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 filesize from 'filesize';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import ContentMessages from '../../../ContentMessages';
|
||||
|
||||
/*
|
||||
* Tells the user about files we know cannot be uploaded before we even try uploading
|
||||
* them. This is named fairly generically but the only thing we check right now is
|
||||
* the size of the file.
|
||||
*/
|
||||
export default class UploadFailureDialog extends React.Component {
|
||||
static propTypes = {
|
||||
badFiles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
totalFiles: PropTypes.number.isRequired,
|
||||
contentMessages: PropTypes.instanceOf(ContentMessages).isRequired,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
_onCancelClick = () => {
|
||||
this.props.onFinished(false);
|
||||
}
|
||||
|
||||
_onUploadClick = () => {
|
||||
this.props.onFinished(true);
|
||||
}
|
||||
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
let message;
|
||||
let preview;
|
||||
let buttons;
|
||||
if (this.props.totalFiles === 1 && this.props.badFiles.length === 1) {
|
||||
message = _t(
|
||||
"This file is <b>too large</b> to upload. " +
|
||||
"The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.",
|
||||
{
|
||||
limit: filesize(this.props.contentMessages.getUploadLimit()),
|
||||
sizeOfThisFile: filesize(this.props.badFiles[0].size),
|
||||
}, {
|
||||
b: sub => <b>{sub}</b>,
|
||||
},
|
||||
);
|
||||
buttons = <DialogButtons primaryButton={_t('OK')}
|
||||
hasCancel={false}
|
||||
onPrimaryButtonClick={this._onCancelClick}
|
||||
focus={true}
|
||||
/>;
|
||||
} else if (this.props.totalFiles === this.props.badFiles.length) {
|
||||
message = _t(
|
||||
"These files are <b>too large</b> to upload. " +
|
||||
"The file size limit is %(limit)s.",
|
||||
{
|
||||
limit: filesize(this.props.contentMessages.getUploadLimit()),
|
||||
}, {
|
||||
b: sub => <b>{sub}</b>,
|
||||
},
|
||||
);
|
||||
buttons = <DialogButtons primaryButton={_t('OK')}
|
||||
hasCancel={false}
|
||||
onPrimaryButtonClick={this._onCancelClick}
|
||||
focus={true}
|
||||
/>;
|
||||
} else {
|
||||
message = _t(
|
||||
"Some files are <b>too large</b> to be uploaded. " +
|
||||
"The file size limit is %(limit)s.",
|
||||
{
|
||||
limit: filesize(this.props.contentMessages.getUploadLimit()),
|
||||
}, {
|
||||
b: sub => <b>{sub}</b>,
|
||||
},
|
||||
);
|
||||
const howManyOthers = this.props.totalFiles - this.props.badFiles.length;
|
||||
buttons = <DialogButtons
|
||||
primaryButton={_t('Upload %(count)s other files', { count: howManyOthers })}
|
||||
onPrimaryButtonClick={this._onUploadClick}
|
||||
hasCancel={true}
|
||||
cancelButton={_t("Cancel All")}
|
||||
onCancel={this._onCancelClick}
|
||||
focus={true}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog className='mx_UploadFailureDialog'
|
||||
onFinished={this._onCancelClick}
|
||||
title={_t("Upload Error")}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div id='mx_Dialog_content'>
|
||||
{message}
|
||||
{preview}
|
||||
</div>
|
||||
|
||||
{buttons}
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
103
src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js
Normal file
103
src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright 2019 Travis Ralston
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
|
||||
import sdk from "../../../index";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
|
||||
export default class WidgetOpenIDPermissionsDialog extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
widgetUrl: PropTypes.string.isRequired,
|
||||
widgetId: PropTypes.string.isRequired,
|
||||
isUserWidget: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
rememberSelection: false,
|
||||
};
|
||||
}
|
||||
|
||||
_onAllow = () => {
|
||||
this._onPermissionSelection(true);
|
||||
};
|
||||
|
||||
_onDeny = () => {
|
||||
this._onPermissionSelection(false);
|
||||
};
|
||||
|
||||
_onPermissionSelection(allowed) {
|
||||
if (this.state.rememberSelection) {
|
||||
console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`);
|
||||
|
||||
const currentValues = SettingsStore.getValue("widgetOpenIDPermissions");
|
||||
if (!currentValues.allow) currentValues.allow = [];
|
||||
if (!currentValues.deny) currentValues.deny = [];
|
||||
|
||||
const securityKey = WidgetUtils.getWidgetSecurityKey(
|
||||
this.props.widgetId,
|
||||
this.props.widgetUrl,
|
||||
this.props.isUserWidget);
|
||||
(allowed ? currentValues.allow : currentValues.deny).push(securityKey);
|
||||
SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues);
|
||||
}
|
||||
|
||||
this.props.onFinished(allowed);
|
||||
}
|
||||
|
||||
_onRememberSelectionChange = (newVal) => {
|
||||
this.setState({rememberSelection: newVal});
|
||||
};
|
||||
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
return (
|
||||
<BaseDialog className='mx_WidgetOpenIDPermissionsDialog' hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("A widget would like to verify your identity")}>
|
||||
<div className='mx_WidgetOpenIDPermissionsDialog_content'>
|
||||
<p>
|
||||
{_t(
|
||||
"A widget located at %(widgetUrl)s would like to verify your identity. " +
|
||||
"By allowing this, the widget will be able to verify your user ID, but not " +
|
||||
"perform actions as you.", {
|
||||
widgetUrl: this.props.widgetUrl,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<LabelledToggleSwitch value={this.state.rememberSelection} toggleInFront={true}
|
||||
onChange={this._onRememberSelectionChange}
|
||||
label={_t("Remember my selection for this widget")} />
|
||||
</div>
|
||||
<DialogButtons
|
||||
primaryButton={_t("Allow")}
|
||||
onPrimaryButtonClick={this._onAllow}
|
||||
cancelButton={_t("Deny")}
|
||||
onCancel={this._onDeny}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export default class NetworkDropdown extends React.Component {
|
||||
expanded: false,
|
||||
selectedServer: server,
|
||||
selectedInstanceId: null,
|
||||
includeAllNetworks: true,
|
||||
includeAllNetworks: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export default class NetworkDropdown extends React.Component {
|
||||
expanded: false,
|
||||
selectedServer: e.target.value,
|
||||
selectedNetwork: null,
|
||||
includeAllNetworks: true,
|
||||
includeAllNetworks: false,
|
||||
});
|
||||
this.props.onOptionChange(e.target.value, null);
|
||||
}
|
||||
@@ -131,10 +131,11 @@ export default class NetworkDropdown extends React.Component {
|
||||
|
||||
_getMenuOptions() {
|
||||
const options = [];
|
||||
const roomDirectory = this.props.config.roomDirectory || {};
|
||||
|
||||
let servers = [];
|
||||
if (this.props.config.roomDirectory.servers) {
|
||||
servers = servers.concat(this.props.config.roomDirectory.servers);
|
||||
if (roomDirectory.servers) {
|
||||
servers = servers.concat(roomDirectory.servers);
|
||||
}
|
||||
|
||||
if (!servers.includes(MatrixClientPeg.getHomeServerName())) {
|
||||
|
||||
@@ -336,9 +336,12 @@ export default class AppTile extends React.Component {
|
||||
* Called when widget iframe has finished loading
|
||||
*/
|
||||
_onLoaded() {
|
||||
if (!ActiveWidgetStore.getWidgetMessaging(this.props.id)) {
|
||||
this._setupWidgetMessaging();
|
||||
}
|
||||
// Destroy the old widget messaging before starting it back up again. Some widgets
|
||||
// have startup routines that run when they are loaded, so we just need to reinitialize
|
||||
// the messaging for them.
|
||||
ActiveWidgetStore.delWidgetMessaging(this.props.id);
|
||||
this._setupWidgetMessaging();
|
||||
|
||||
ActiveWidgetStore.setRoomId(this.props.id, this.props.room.roomId);
|
||||
this.setState({loading: false});
|
||||
}
|
||||
@@ -346,7 +349,8 @@ export default class AppTile extends React.Component {
|
||||
_setupWidgetMessaging() {
|
||||
// FIXME: There's probably no reason to do this here: it should probably be done entirely
|
||||
// in ActiveWidgetStore.
|
||||
const widgetMessaging = new WidgetMessaging(this.props.id, this.props.url, this.refs.appFrame.contentWindow);
|
||||
const widgetMessaging = new WidgetMessaging(
|
||||
this.props.id, this.props.url, this.props.userWidget, this.refs.appFrame.contentWindow);
|
||||
ActiveWidgetStore.setWidgetMessaging(this.props.id, widgetMessaging);
|
||||
widgetMessaging.getCapabilities().then((requestedCapabilities) => {
|
||||
console.log(`Widget ${this.props.id} requested capabilities: ` + requestedCapabilities);
|
||||
@@ -442,10 +446,14 @@ export default class AppTile extends React.Component {
|
||||
}
|
||||
|
||||
// Toggle the view state of the apps drawer
|
||||
dis.dispatch({
|
||||
action: 'appsDrawer',
|
||||
show: !this.props.show,
|
||||
});
|
||||
if (this.props.userWidget) {
|
||||
this._onMinimiseClick();
|
||||
} else {
|
||||
dis.dispatch({
|
||||
action: 'appsDrawer',
|
||||
show: !this.props.show,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_getSafeUrl() {
|
||||
@@ -485,9 +493,9 @@ export default class AppTile extends React.Component {
|
||||
|
||||
_onPopoutWidgetClick(e) {
|
||||
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
|
||||
// window.open(this._getSafeUrl(), '_blank', 'noopener=yes,noreferrer=yes');
|
||||
// window.open(this._getSafeUrl(), '_blank', 'noopener=yes');
|
||||
Object.assign(document.createElement('a'),
|
||||
{ target: '_blank', href: this._getSafeUrl(), rel: 'noopener noreferrer'}).click();
|
||||
{ target: '_blank', href: this._getSafeUrl(), rel: 'noopener'}).click();
|
||||
}
|
||||
|
||||
_onReloadWidgetClick(e) {
|
||||
@@ -621,7 +629,7 @@ export default class AppTile extends React.Component {
|
||||
{ /* Maximise widget */ }
|
||||
{ showMaximiseButton && <AccessibleButton
|
||||
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_maximise"
|
||||
title={_t('Minimize apps')}
|
||||
title={_t('Maximize apps')}
|
||||
onClick={this._onMinimiseClick}
|
||||
/> }
|
||||
{ /* Title */ }
|
||||
|
||||
@@ -18,6 +18,10 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import sdk from '../../../index';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
// Invoke validation from user input (when typing, etc.) at most once every N ms.
|
||||
const VALIDATION_THROTTLE_MS = 200;
|
||||
|
||||
export default class Field extends React.PureComponent {
|
||||
static propTypes = {
|
||||
@@ -53,20 +57,73 @@ export default class Field extends React.PureComponent {
|
||||
};
|
||||
}
|
||||
|
||||
onChange = (ev) => {
|
||||
if (this.props.onValidate) {
|
||||
const result = this.props.onValidate(ev.target.value);
|
||||
this.setState({
|
||||
valid: result.valid,
|
||||
feedback: result.feedback,
|
||||
});
|
||||
onFocus = (ev) => {
|
||||
this.validate({
|
||||
focused: true,
|
||||
});
|
||||
// Parent component may have supplied its own `onFocus` as well
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(ev);
|
||||
}
|
||||
};
|
||||
|
||||
onChange = (ev) => {
|
||||
this.validateOnChange();
|
||||
// Parent component may have supplied its own `onChange` as well
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(ev);
|
||||
}
|
||||
};
|
||||
|
||||
onBlur = (ev) => {
|
||||
this.validate({
|
||||
focused: false,
|
||||
});
|
||||
// Parent component may have supplied its own `onBlur` as well
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur(ev);
|
||||
}
|
||||
};
|
||||
|
||||
focus() {
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
async validate({ focused, allowEmpty = true }) {
|
||||
if (!this.props.onValidate) {
|
||||
return;
|
||||
}
|
||||
const value = this.input ? this.input.value : null;
|
||||
const { valid, feedback } = await this.props.onValidate({
|
||||
value,
|
||||
focused,
|
||||
allowEmpty,
|
||||
});
|
||||
|
||||
if (feedback) {
|
||||
this.setState({
|
||||
valid,
|
||||
feedback,
|
||||
feedbackVisible: true,
|
||||
});
|
||||
} else {
|
||||
// When we receive null `feedback`, we want to hide the tooltip.
|
||||
// We leave the previous `feedback` content in state without updating it,
|
||||
// so that we can hide the tooltip containing the most recent feedback
|
||||
// via CSS animation.
|
||||
this.setState({
|
||||
valid,
|
||||
feedbackVisible: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
validateOnChange = throttle(() => {
|
||||
this.validate({
|
||||
focused: true,
|
||||
});
|
||||
}, VALIDATION_THROTTLE_MS);
|
||||
|
||||
render() {
|
||||
const { element, prefix, onValidate, children, ...inputProps } = this.props;
|
||||
|
||||
@@ -74,10 +131,12 @@ export default class Field extends React.PureComponent {
|
||||
|
||||
// Set some defaults for the <input> element
|
||||
inputProps.type = inputProps.type || "text";
|
||||
inputProps.ref = "fieldInput";
|
||||
inputProps.ref = input => this.input = input;
|
||||
inputProps.placeholder = inputProps.placeholder || inputProps.label;
|
||||
|
||||
inputProps.onFocus = this.onFocus;
|
||||
inputProps.onChange = this.onChange;
|
||||
inputProps.onBlur = this.onBlur;
|
||||
|
||||
const fieldInput = React.createElement(inputElement, inputProps, children);
|
||||
|
||||
@@ -86,28 +145,22 @@ export default class Field extends React.PureComponent {
|
||||
prefixContainer = <span className="mx_Field_prefix">{prefix}</span>;
|
||||
}
|
||||
|
||||
let validClass;
|
||||
if (onValidate) {
|
||||
validClass = classNames({
|
||||
mx_Field_valid: this.state.valid === true,
|
||||
mx_Field_invalid: this.state.valid === false,
|
||||
});
|
||||
}
|
||||
|
||||
const fieldClasses = classNames("mx_Field", `mx_Field_${inputElement}`, {
|
||||
// If we have a prefix element, leave the label always at the top left and
|
||||
// don't animate it, as it looks a bit clunky and would add complexity to do
|
||||
// properly.
|
||||
mx_Field_labelAlwaysTopLeft: prefix,
|
||||
[validClass]: true,
|
||||
mx_Field_valid: onValidate && this.state.valid === true,
|
||||
mx_Field_invalid: onValidate && this.state.valid === false,
|
||||
});
|
||||
|
||||
// handle displaying feedback on validity
|
||||
// Handle displaying feedback on validity
|
||||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
||||
let feedback;
|
||||
let tooltip;
|
||||
if (this.state.feedback) {
|
||||
feedback = <Tooltip
|
||||
tooltip = <Tooltip
|
||||
tooltipClassName="mx_Field_tooltip"
|
||||
visible={this.state.feedbackVisible}
|
||||
label={this.state.feedback}
|
||||
/>;
|
||||
}
|
||||
@@ -116,7 +169,7 @@ export default class Field extends React.PureComponent {
|
||||
{prefixContainer}
|
||||
{fieldInput}
|
||||
<label htmlFor={this.props.id}>{this.props.label}</label>
|
||||
{feedback}
|
||||
{tooltip}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import sdk from '../../../index';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
const HomeButton = function(props) {
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
return (
|
||||
<ActionButton action="view_home_page"
|
||||
label={_t("Home")}
|
||||
iconPath={require("../../../../res/img/icons-home.svg")}
|
||||
size={props.size}
|
||||
tooltip={props.tooltip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
HomeButton.propTypes = {
|
||||
size: PropTypes.string,
|
||||
tooltip: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default HomeButton;
|
||||
@@ -27,10 +27,8 @@ const Modal = require('../../../Modal');
|
||||
const sdk = require('../../../index');
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'ImageView',
|
||||
|
||||
propTypes: {
|
||||
export default class ImageView extends React.Component {
|
||||
static propTypes = {
|
||||
src: React.PropTypes.string.isRequired, // the source of the image being displayed
|
||||
name: React.PropTypes.string, // the main title ('name') for the image
|
||||
link: React.PropTypes.string, // the link (if any) applied to the name of the image
|
||||
@@ -44,27 +42,32 @@ module.exports = React.createClass({
|
||||
// properties above, which let us use lightboxes to display images which aren't associated
|
||||
// with events.
|
||||
mxEvent: React.PropTypes.object,
|
||||
},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { rotationDegrees: 0 };
|
||||
}
|
||||
|
||||
// XXX: keyboard shortcuts for managing dialogs should be done by the modal
|
||||
// dialog base class somehow, surely...
|
||||
componentDidMount: function() {
|
||||
componentDidMount() {
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
},
|
||||
}
|
||||
|
||||
onKeyDown: function(ev) {
|
||||
onKeyDown = (ev) => {
|
||||
if (ev.keyCode == 27) { // escape
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.props.onFinished();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
onRedactClick: function() {
|
||||
onRedactClick = () => {
|
||||
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
|
||||
Modal.createTrackedDialog('Confirm Redact Dialog', 'Image View', ConfirmRedactDialog, {
|
||||
onFinished: (proceed) => {
|
||||
@@ -83,17 +86,29 @@ module.exports = React.createClass({
|
||||
}).done();
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
getName: function() {
|
||||
getName() {
|
||||
let name = this.props.name;
|
||||
if (name && this.props.link) {
|
||||
name = <a href={ this.props.link } target="_blank" rel="noopener">{ name }</a>;
|
||||
}
|
||||
return name;
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
rotateCounterClockwise = () => {
|
||||
const cur = this.state.rotationDegrees;
|
||||
const rotationDegrees = (cur - 90) % 360;
|
||||
this.setState({ rotationDegrees });
|
||||
};
|
||||
|
||||
rotateClockwise = () => {
|
||||
const cur = this.state.rotationDegrees;
|
||||
const rotationDegrees = (cur + 90) % 360;
|
||||
this.setState({ rotationDegrees });
|
||||
};
|
||||
|
||||
render() {
|
||||
/*
|
||||
// In theory max-width: 80%, max-height: 80% on the CSS should work
|
||||
// but in practice, it doesn't, so do it manually:
|
||||
@@ -122,7 +137,8 @@ module.exports = React.createClass({
|
||||
height: displayHeight
|
||||
};
|
||||
*/
|
||||
let style; let res;
|
||||
let style = {};
|
||||
let res;
|
||||
|
||||
if (this.props.width && this.props.height) {
|
||||
style = {
|
||||
@@ -168,15 +184,26 @@ module.exports = React.createClass({
|
||||
</div>);
|
||||
}
|
||||
|
||||
const rotationDegrees = this.state.rotationDegrees;
|
||||
const effectiveStyle = {transform: `rotate(${rotationDegrees}deg)`, ...style};
|
||||
|
||||
return (
|
||||
<div className="mx_ImageView">
|
||||
<div className="mx_ImageView_lhs">
|
||||
</div>
|
||||
<div className="mx_ImageView_content">
|
||||
<img src={this.props.src} style={style} />
|
||||
<img src={this.props.src} title={this.props.name} style={effectiveStyle} className="mainImage" />
|
||||
<div className="mx_ImageView_labelWrapper">
|
||||
<div className="mx_ImageView_label">
|
||||
<AccessibleButton className="mx_ImageView_cancel" onClick={ this.props.onFinished }><img src={require("../../../../res/img/cancel-white.svg")} width="18" height="18" alt={ _t('Close') } /></AccessibleButton>
|
||||
<AccessibleButton className="mx_ImageView_rotateCounterClockwise" onClick={ this.rotateCounterClockwise }>
|
||||
<img src={require("../../../../res/img/rotate-ccw.svg")} alt={ _t('Rotate counter-clockwise') } width="18" height="18" />
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className="mx_ImageView_rotateClockwise" onClick={ this.rotateClockwise }>
|
||||
<img src={require("../../../../res/img/rotate-cw.svg")} alt={ _t('Rotate clockwise') } width="18" height="18" />
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className="mx_ImageView_cancel" onClick={ this.props.onFinished }>
|
||||
<img src={require("../../../../res/img/cancel-white.svg")} width="18" height="18" alt={ _t('Close') } />
|
||||
</AccessibleButton>
|
||||
<div className="mx_ImageView_shim">
|
||||
</div>
|
||||
<div className="mx_ImageView_name">
|
||||
@@ -199,5 +226,5 @@ module.exports = React.createClass({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,15 +31,29 @@ export default class LabelledToggleSwitch extends React.Component {
|
||||
|
||||
// Whether or not to disable the toggle switch
|
||||
disabled: PropTypes.bool,
|
||||
|
||||
// True to put the toggle in front of the label
|
||||
// Default false.
|
||||
toggleInFront: PropTypes.bool,
|
||||
};
|
||||
|
||||
render() {
|
||||
// This is a minimal version of a SettingsFlag
|
||||
|
||||
let firstPart = <span className="mx_SettingsFlag_label">{this.props.label}</span>;
|
||||
let secondPart = <ToggleSwitch checked={this.props.value} disabled={this.props.disabled}
|
||||
onChange={this.props.onChange} />;
|
||||
|
||||
if (this.props.toggleInFront) {
|
||||
const temp = firstPart;
|
||||
firstPart = secondPart;
|
||||
secondPart = temp;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_SettingsFlag">
|
||||
<span className="mx_SettingsFlag_label">{this.props.label}</span>
|
||||
<ToggleSwitch checked={this.props.value} disabled={this.props.disabled}
|
||||
onChange={this.props.onChange} />
|
||||
{firstPart}
|
||||
{secondPart}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,8 +73,12 @@ module.exports = React.createClass({
|
||||
_initStateFromProps: function(newProps) {
|
||||
// This needs to be done now because levelRoleMap has translated strings
|
||||
const levelRoleMap = Roles.levelRoleMap(newProps.usersDefault);
|
||||
const options = Object.keys(levelRoleMap).filter((l) => {
|
||||
return l === undefined || l <= newProps.maxValue;
|
||||
const options = Object.keys(levelRoleMap).filter(level => {
|
||||
return (
|
||||
level === undefined ||
|
||||
level <= newProps.maxValue ||
|
||||
level == newProps.value
|
||||
);
|
||||
});
|
||||
|
||||
const isCustom = levelRoleMap[newProps.value] === undefined;
|
||||
@@ -130,7 +134,7 @@ module.exports = React.createClass({
|
||||
<Field id={`powerSelector_custom_${this.props.powerLevelKey}`} type="number"
|
||||
label={this.props.label || _t("Power level")} max={this.props.maxValue}
|
||||
onBlur={this.onCustomBlur} onKeyPress={this.onCustomKeyPress} onChange={this.onCustomChange}
|
||||
value={this.state.customValue} disabled={this.props.disabled} />
|
||||
value={String(this.state.customValue)} disabled={this.props.disabled} />
|
||||
);
|
||||
} else {
|
||||
// Each level must have a definition in this.state.levelRoleMap
|
||||
@@ -148,7 +152,7 @@ module.exports = React.createClass({
|
||||
picker = (
|
||||
<Field id={`powerSelector_notCustom_${this.props.powerLevelKey}`} element="select"
|
||||
label={this.props.label || _t("Power level")} onChange={this.onSelectChange}
|
||||
value={this.state.selectValue} disabled={this.props.disabled}>
|
||||
value={String(this.state.selectValue)} disabled={this.props.disabled}>
|
||||
{options}
|
||||
</Field>
|
||||
);
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import sdk from '../../../index';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
const SettingsButton = function(props) {
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
return (
|
||||
<ActionButton action="view_user_settings"
|
||||
label={_t("Settings")}
|
||||
iconPath={require("../../../../res/img/icons-settings.svg")}
|
||||
size={props.size}
|
||||
tooltip={props.tooltip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
SettingsButton.propTypes = {
|
||||
size: PropTypes.string,
|
||||
tooltip: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default SettingsButton;
|
||||
@@ -31,10 +31,20 @@ module.exports = React.createClass({
|
||||
className: React.PropTypes.string,
|
||||
// Class applied to the tooltip itself
|
||||
tooltipClassName: React.PropTypes.string,
|
||||
// Whether the tooltip is visible or hidden.
|
||||
// The hidden state allows animating the tooltip away via CSS.
|
||||
// Defaults to visible if unset.
|
||||
visible: React.PropTypes.bool,
|
||||
// the react element to put into the tooltip
|
||||
label: React.PropTypes.node,
|
||||
},
|
||||
|
||||
getDefaultProps() {
|
||||
return {
|
||||
visible: true,
|
||||
};
|
||||
},
|
||||
|
||||
// Create a wrapper for the tooltip outside the parent and attach it to the body element
|
||||
componentDidMount: function() {
|
||||
this.tooltipContainer = document.createElement("div");
|
||||
@@ -85,7 +95,10 @@ module.exports = React.createClass({
|
||||
style = this._updatePosition(style);
|
||||
style.display = "block";
|
||||
|
||||
const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName);
|
||||
const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName, {
|
||||
"mx_Tooltip_visible": this.props.visible,
|
||||
"mx_Tooltip_invisible": !this.props.visible,
|
||||
});
|
||||
|
||||
const tooltip = (
|
||||
<div className={tooltipClasses} style={style}>
|
||||
|
||||
131
src/components/views/elements/Validation.js
Normal file
131
src/components/views/elements/Validation.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable babel/no-invalid-this */
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* Creates a validation function from a set of rules describing what to validate.
|
||||
*
|
||||
* @param {Function} description
|
||||
* Function that returns a string summary of the kind of value that will
|
||||
* meet the validation rules. Shown at the top of the validation feedback.
|
||||
* @param {Object} rules
|
||||
* An array of rules describing how to check to input value. Each rule in an object
|
||||
* and may have the following properties:
|
||||
* - `key`: A unique ID for the rule. Required.
|
||||
* - `test`: A function used to determine the rule's current validity. Required.
|
||||
* - `valid`: Function returning text to show when the rule is valid. Only shown if set.
|
||||
* - `invalid`: Function returning text to show when the rule is invalid. Only shown if set.
|
||||
* @returns {Function}
|
||||
* A validation function that takes in the current input value and returns
|
||||
* the overall validity and a feedback UI that can be rendered for more detail.
|
||||
*/
|
||||
export default function withValidation({ description, rules }) {
|
||||
return async function onValidate({ value, focused, allowEmpty = true }) {
|
||||
if (!value && allowEmpty) {
|
||||
return {
|
||||
valid: null,
|
||||
feedback: null,
|
||||
};
|
||||
}
|
||||
|
||||
const results = [];
|
||||
let valid = true;
|
||||
if (rules && rules.length) {
|
||||
for (const rule of rules) {
|
||||
if (!rule.key || !rule.test) {
|
||||
continue;
|
||||
}
|
||||
// We're setting `this` to whichever component holds the validation
|
||||
// function. That allows rules to access the state of the component.
|
||||
const ruleValid = await rule.test.call(this, { value, allowEmpty });
|
||||
valid = valid && ruleValid;
|
||||
if (ruleValid && rule.valid) {
|
||||
// If the rule's result is valid and has text to show for
|
||||
// the valid state, show it.
|
||||
const text = rule.valid.call(this);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
key: rule.key,
|
||||
valid: true,
|
||||
text,
|
||||
});
|
||||
} else if (!ruleValid && rule.invalid) {
|
||||
// If the rule's result is invalid and has text to show for
|
||||
// the invalid state, show it.
|
||||
const text = rule.invalid.call(this);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
key: rule.key,
|
||||
valid: false,
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide feedback when not focused
|
||||
if (!focused) {
|
||||
return {
|
||||
valid,
|
||||
feedback: null,
|
||||
};
|
||||
}
|
||||
|
||||
let details;
|
||||
if (results && results.length) {
|
||||
details = <ul className="mx_Validation_details">
|
||||
{results.map(result => {
|
||||
const classes = classNames({
|
||||
"mx_Validation_detail": true,
|
||||
"mx_Validation_valid": result.valid,
|
||||
"mx_Validation_invalid": !result.valid,
|
||||
});
|
||||
return <li key={result.key} className={classes}>
|
||||
{result.text}
|
||||
</li>;
|
||||
})}
|
||||
</ul>;
|
||||
}
|
||||
|
||||
let summary;
|
||||
if (description) {
|
||||
// We're setting `this` to whichever component holds the validation
|
||||
// function. That allows rules to access the state of the component.
|
||||
const content = description.call(this);
|
||||
summary = <div className="mx_Validation_description">{content}</div>;
|
||||
}
|
||||
|
||||
let feedback;
|
||||
if (summary || details) {
|
||||
feedback = <div className="mx_Validation">
|
||||
{summary}
|
||||
{details}
|
||||
</div>;
|
||||
}
|
||||
|
||||
return {
|
||||
valid,
|
||||
feedback,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -294,6 +294,8 @@ module.exports = React.createClass({
|
||||
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
|
||||
const contentUrl = this._getContentUrl();
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
const fileSize = content.info ? content.info.size : null;
|
||||
const fileType = content.info ? content.info.mimetype : "application/octet-stream";
|
||||
|
||||
if (isEncrypted) {
|
||||
if (this.state.decryptedBlob === null) {
|
||||
@@ -372,6 +374,50 @@ module.exports = React.createClass({
|
||||
</span>
|
||||
);
|
||||
} else if (contentUrl) {
|
||||
const downloadProps = {
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
|
||||
// We set the href regardless of whether or not we intercept the download
|
||||
// because we don't really want to convert the file to a blob eagerly, and
|
||||
// still want "open in new tab" and "save link as" to work.
|
||||
href: contentUrl,
|
||||
};
|
||||
|
||||
// Blobs can only have up to 500mb, so if the file reports as being too large then
|
||||
// we won't try and convert it. Likewise, if the file size is unknown then we'll assume
|
||||
// it is too big. There is the risk of the reported file size and the actual file size
|
||||
// being different, however the user shouldn't normally run into this problem.
|
||||
const fileTooBig = typeof(fileSize) === 'number' ? fileSize > 524288000 : true;
|
||||
|
||||
if (["application/pdf"].includes(fileType) && !fileTooBig) {
|
||||
// We want to force a download on this type, so use an onClick handler.
|
||||
downloadProps["onClick"] = (e) => {
|
||||
console.log(`Downloading ${fileType} as blob (unencrypted)`);
|
||||
|
||||
// Avoid letting the <a> do its thing
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Start a fetch for the download
|
||||
// Based upon https://stackoverflow.com/a/49500465
|
||||
fetch(contentUrl).then((response) => response.blob()).then((blob) => {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// We have to create an anchor to download the file
|
||||
const tempAnchor = document.createElement('a');
|
||||
tempAnchor.download = fileName;
|
||||
tempAnchor.href = blobUrl;
|
||||
document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
|
||||
tempAnchor.click();
|
||||
tempAnchor.remove();
|
||||
});
|
||||
};
|
||||
} else {
|
||||
// Else we are hoping the browser will do the right thing
|
||||
downloadProps["download"] = fileName;
|
||||
}
|
||||
|
||||
// If the attachment is not encrypted then we check whether we
|
||||
// are being displayed in the room timeline or in a list of
|
||||
// files in the right hand side of the screen.
|
||||
@@ -379,7 +425,7 @@ module.exports = React.createClass({
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
<div className="mx_MFileBody_download">
|
||||
<a className="mx_MFileBody_downloadLink" href={contentUrl} download={fileName} target="_blank">
|
||||
<a className="mx_MFileBody_downloadLink" {...downloadProps}>
|
||||
{ fileName }
|
||||
</a>
|
||||
<div className="mx_MImageBody_size">
|
||||
@@ -392,7 +438,7 @@ module.exports = React.createClass({
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
<div className="mx_MFileBody_download">
|
||||
<a href={contentUrl} download={fileName} target="_blank" rel="noopener">
|
||||
<a {...downloadProps}>
|
||||
<img src={tintedDownloadImageURL} width="12" height="14" ref="downloadImage" />
|
||||
{ _t("Download %(text)s", { text: text }) }
|
||||
</a>
|
||||
|
||||
@@ -150,7 +150,7 @@ export default class MImageBody extends React.Component {
|
||||
|
||||
if (this.refs.image) {
|
||||
const { naturalWidth, naturalHeight } = this.refs.image;
|
||||
|
||||
// this is only used as a fallback in case content.info.w/h is missing
|
||||
loadedImageDimensions = { naturalWidth, naturalHeight };
|
||||
}
|
||||
|
||||
@@ -167,6 +167,14 @@ export default class MImageBody extends React.Component {
|
||||
}
|
||||
|
||||
_getThumbUrl() {
|
||||
// FIXME: the dharma skin lets images grow as wide as you like, rather than capped to 800x600.
|
||||
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
|
||||
// thumbnail resolution will be unnecessarily reduced.
|
||||
// custom timeline widths seems preferable.
|
||||
const pixelRatio = window.devicePixelRatio;
|
||||
const thumbWidth = 800 * pixelRatio;
|
||||
const thumbHeight = 600 * pixelRatio;
|
||||
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (content.file !== undefined) {
|
||||
// Don't use the thumbnail for clients wishing to autoplay gifs.
|
||||
@@ -175,14 +183,61 @@ export default class MImageBody extends React.Component {
|
||||
}
|
||||
return this.state.decryptedUrl;
|
||||
} else if (content.info && content.info.mimetype === "image/svg+xml" && content.info.thumbnail_url) {
|
||||
// special case to return client-generated thumbnails for SVGs, if any,
|
||||
// special case to return clientside sender-generated thumbnails for SVGs, if any,
|
||||
// given we deliberately don't thumbnail them serverside to prevent
|
||||
// billion lol attacks and similar
|
||||
return this.context.matrixClient.mxcUrlToHttp(
|
||||
content.info.thumbnail_url, 800, 600,
|
||||
content.info.thumbnail_url,
|
||||
thumbWidth,
|
||||
thumbHeight,
|
||||
);
|
||||
} else {
|
||||
return this.context.matrixClient.mxcUrlToHttp(content.url, 800, 600);
|
||||
// we try to download the correct resolution
|
||||
// for hi-res images (like retina screenshots).
|
||||
// synapse only supports 800x600 thumbnails for now though,
|
||||
// so we'll need to download the original image for this to work
|
||||
// well for now. First, let's try a few cases that let us avoid
|
||||
// downloading the original:
|
||||
if (pixelRatio === 1.0 ||
|
||||
(!content.info || !content.info.w ||
|
||||
!content.info.h || !content.info.size)) {
|
||||
// always thumbnail. it may look a bit worse, but it'll save bandwidth.
|
||||
// which is probably desirable on a lo-dpi device anyway.
|
||||
return this.context.matrixClient.mxcUrlToHttp(content.url, thumbWidth, thumbHeight);
|
||||
} else {
|
||||
// we should only request thumbnails if the image is bigger than 800x600
|
||||
// (or 1600x1200 on retina) otherwise the image in the timeline will just
|
||||
// end up resampled and de-retina'd for no good reason.
|
||||
// Ideally the server would pregen 1600x1200 thumbnails in order to provide retina
|
||||
// thumbnails, but we don't do this currently in synapse for fear of disk space.
|
||||
// As a compromise, let's switch to non-retina thumbnails only if the original
|
||||
// image is both physically too large and going to be massive to load in the
|
||||
// timeline (e.g. >1MB).
|
||||
|
||||
const isLargerThanThumbnail = (
|
||||
content.info.w > thumbWidth ||
|
||||
content.info.h > thumbHeight
|
||||
);
|
||||
const isLargeFileSize = content.info.size > 1*1024*1024;
|
||||
|
||||
if (isLargeFileSize && isLargerThanThumbnail) {
|
||||
// image is too large physically and bytewise to clutter our timeline so
|
||||
// we ask for a thumbnail, despite knowing that it will be max 800x600
|
||||
// despite us being retina (as synapse doesn't do 1600x1200 thumbs yet).
|
||||
return this.context.matrixClient.mxcUrlToHttp(
|
||||
content.url,
|
||||
thumbWidth,
|
||||
thumbHeight,
|
||||
);
|
||||
} else {
|
||||
// download the original image otherwise, so we can scale it client side
|
||||
// to take pixelRatio into account.
|
||||
// ( no width/height means we want the original image)
|
||||
return this.context.matrixClient.mxcUrlToHttp(
|
||||
content.url,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
165
src/components/views/messages/MessageActionBar.js
Normal file
165
src/components/views/messages/MessageActionBar.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
Copyright 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 PropTypes from 'prop-types';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import sdk from '../../../index';
|
||||
import dis from '../../../dispatcher';
|
||||
import Modal from '../../../Modal';
|
||||
import { createMenu } from '../../structures/ContextualMenu';
|
||||
import SettingsStore from '../../../settings/SettingsStore';
|
||||
import { isContentActionable } from '../../../utils/EventUtils';
|
||||
|
||||
export default class MessageActionBar extends React.PureComponent {
|
||||
static propTypes = {
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
permalinkCreator: PropTypes.object,
|
||||
getTile: PropTypes.func,
|
||||
getReplyThread: PropTypes.func,
|
||||
onFocusChange: PropTypes.func,
|
||||
};
|
||||
|
||||
onFocusChange = (focused) => {
|
||||
if (!this.props.onFocusChange) {
|
||||
return;
|
||||
}
|
||||
this.props.onFocusChange(focused);
|
||||
}
|
||||
|
||||
onCryptoClicked = () => {
|
||||
const event = this.props.mxEvent;
|
||||
Modal.createTrackedDialogAsync('Encrypted Event Dialog', '',
|
||||
import('../../../async-components/views/dialogs/EncryptedEventDialog'),
|
||||
{event},
|
||||
);
|
||||
}
|
||||
|
||||
onReplyClick = (ev) => {
|
||||
dis.dispatch({
|
||||
action: 'reply_to_event',
|
||||
event: this.props.mxEvent,
|
||||
});
|
||||
}
|
||||
|
||||
onOptionsClick = (ev) => {
|
||||
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
|
||||
const buttonRect = ev.target.getBoundingClientRect();
|
||||
|
||||
// The window X and Y offsets are to adjust position when zoomed in to page
|
||||
const x = buttonRect.right + window.pageXOffset;
|
||||
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
|
||||
|
||||
const { getTile, getReplyThread } = this.props;
|
||||
const tile = getTile && getTile();
|
||||
const replyThread = getReplyThread && getReplyThread();
|
||||
|
||||
let e2eInfoCallback = null;
|
||||
if (this.props.mxEvent.isEncrypted()) {
|
||||
e2eInfoCallback = () => this.onCryptoClicked();
|
||||
}
|
||||
|
||||
createMenu(MessageContextMenu, {
|
||||
chevronOffset: 10,
|
||||
mxEvent: this.props.mxEvent,
|
||||
left: x,
|
||||
top: y,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
eventTileOps: tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined,
|
||||
collapseReplyThread: replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined,
|
||||
e2eInfoCallback: e2eInfoCallback,
|
||||
onFinished: () => {
|
||||
this.onFocusChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
this.onFocusChange(true);
|
||||
}
|
||||
|
||||
isReactionsEnabled() {
|
||||
return SettingsStore.isFeatureEnabled("feature_reactions");
|
||||
}
|
||||
|
||||
renderAgreeDimension() {
|
||||
if (!this.isReactionsEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ReactionDimension = sdk.getComponent('messages.ReactionDimension');
|
||||
const options = [
|
||||
{
|
||||
key: "agree",
|
||||
content: "👍",
|
||||
},
|
||||
{
|
||||
key: "disagree",
|
||||
content: "👎",
|
||||
},
|
||||
];
|
||||
return <ReactionDimension
|
||||
title={_t("Agree or Disagree")}
|
||||
options={options}
|
||||
/>;
|
||||
}
|
||||
|
||||
renderLikeDimension() {
|
||||
if (!this.isReactionsEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ReactionDimension = sdk.getComponent('messages.ReactionDimension');
|
||||
const options = [
|
||||
{
|
||||
key: "like",
|
||||
content: "🙂",
|
||||
},
|
||||
{
|
||||
key: "dislike",
|
||||
content: "😔",
|
||||
},
|
||||
];
|
||||
return <ReactionDimension
|
||||
title={_t("Like or Dislike")}
|
||||
options={options}
|
||||
/>;
|
||||
}
|
||||
|
||||
render() {
|
||||
let agreeDimensionReactionButtons;
|
||||
let likeDimensionReactionButtons;
|
||||
let replyButton;
|
||||
|
||||
if (isContentActionable(this.props.mxEvent)) {
|
||||
agreeDimensionReactionButtons = this.renderAgreeDimension();
|
||||
likeDimensionReactionButtons = this.renderLikeDimension();
|
||||
replyButton = <span className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
|
||||
title={_t("Reply")}
|
||||
onClick={this.onReplyClick}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <div className="mx_MessageActionBar">
|
||||
{agreeDimensionReactionButtons}
|
||||
{likeDimensionReactionButtons}
|
||||
{replyButton}
|
||||
<span className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
|
||||
title={_t("Options")}
|
||||
onClick={this.onOptionsClick}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
73
src/components/views/messages/ReactionDimension.js
Normal file
73
src/components/views/messages/ReactionDimension.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Copyright 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 PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class ReactionDimension extends React.PureComponent {
|
||||
static propTypes = {
|
||||
options: PropTypes.array.isRequired,
|
||||
title: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selected: null,
|
||||
};
|
||||
}
|
||||
|
||||
onOptionClick = (ev) => {
|
||||
const { key } = ev.target.dataset;
|
||||
this.toggleDimensionValue(key);
|
||||
}
|
||||
|
||||
toggleDimensionValue(value) {
|
||||
const state = this.state.selected;
|
||||
const newState = state !== value ? value : null;
|
||||
this.setState({
|
||||
selected: newState,
|
||||
});
|
||||
// TODO: Send the reaction event
|
||||
}
|
||||
|
||||
render() {
|
||||
const { selected } = this.state;
|
||||
const { options } = this.props;
|
||||
|
||||
const items = options.map(option => {
|
||||
const disabled = selected && selected !== option.key;
|
||||
const classes = classNames({
|
||||
mx_ReactionDimension_disabled: disabled,
|
||||
});
|
||||
return <span key={option.key}
|
||||
data-key={option.key}
|
||||
className={classes}
|
||||
onClick={this.onOptionClick}
|
||||
>
|
||||
{option.content}
|
||||
</span>;
|
||||
});
|
||||
|
||||
return <span className="mx_ReactionDimension"
|
||||
title={this.props.title}
|
||||
>
|
||||
{items}
|
||||
</span>;
|
||||
}
|
||||
}
|
||||
65
src/components/views/messages/ReactionsRow.js
Normal file
65
src/components/views/messages/ReactionsRow.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Copyright 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 PropTypes from 'prop-types';
|
||||
|
||||
import sdk from '../../../index';
|
||||
import { isContentActionable } from '../../../utils/EventUtils';
|
||||
|
||||
// TODO: Actually load reactions from the timeline
|
||||
// Since we don't yet load reactions, let's inject some dummy data for testing the UI
|
||||
// only. The UI assumes these are already sorted into the order we want to present,
|
||||
// presumably highest vote first.
|
||||
const SAMPLE_REACTIONS = {
|
||||
"👍": 4,
|
||||
"👎": 2,
|
||||
"🙂": 1,
|
||||
};
|
||||
|
||||
export default class ReactionsRow extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
const { mxEvent } = this.props;
|
||||
|
||||
if (!isContentActionable(mxEvent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = mxEvent.getContent();
|
||||
// TODO: Remove this once we load real reactions
|
||||
if (!content.body || content.body !== "reactions test") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton');
|
||||
const items = Object.entries(SAMPLE_REACTIONS).map(([content, count]) => {
|
||||
return <ReactionsRowButton
|
||||
key={content}
|
||||
content={content}
|
||||
count={count}
|
||||
/>;
|
||||
});
|
||||
|
||||
return <div className="mx_ReactionsRow">
|
||||
{items}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
65
src/components/views/messages/ReactionsRowButton.js
Normal file
65
src/components/views/messages/ReactionsRowButton.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Copyright 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 PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class ReactionsRowButton extends React.PureComponent {
|
||||
static propTypes = {
|
||||
content: PropTypes.string.isRequired,
|
||||
count: PropTypes.number.isRequired,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// TODO: This should be derived from actual reactions you may have sent
|
||||
// once we have some API to read them.
|
||||
this.state = {
|
||||
selected: false,
|
||||
};
|
||||
}
|
||||
|
||||
onClick = (ev) => {
|
||||
const state = this.state.selected;
|
||||
this.setState({
|
||||
selected: !state,
|
||||
});
|
||||
// TODO: Send the reaction event
|
||||
};
|
||||
|
||||
render() {
|
||||
const { content, count } = this.props;
|
||||
const { selected } = this.state;
|
||||
|
||||
const classes = classNames({
|
||||
mx_ReactionsRowButton: true,
|
||||
mx_ReactionsRowButton_selected: selected,
|
||||
});
|
||||
|
||||
let adjustedCount = count;
|
||||
if (selected) {
|
||||
adjustedCount++;
|
||||
}
|
||||
|
||||
return <span className={classes}
|
||||
onClick={this.onClick}
|
||||
>
|
||||
{content} {adjustedCount}
|
||||
</span>;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +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';
|
||||
import {getUserNameColorClass} from '../../../utils/FormattingUtils';
|
||||
|
||||
export default React.createClass({
|
||||
displayName: 'SenderProfile',
|
||||
@@ -97,7 +97,7 @@ export default React.createClass({
|
||||
render() {
|
||||
const EmojiText = sdk.getComponent('elements.EmojiText');
|
||||
const {mxEvent} = this.props;
|
||||
const colorNumber = (hashCode(mxEvent.getSender()) % 8) + 1;
|
||||
const colorClass = getUserNameColorClass(mxEvent.getSender());
|
||||
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
|
||||
const {msgtype} = mxEvent.getContent();
|
||||
|
||||
@@ -121,7 +121,7 @@ export default React.createClass({
|
||||
|
||||
// Name + flair
|
||||
const nameFlair = <span>
|
||||
<span className={`mx_SenderProfile_name mx_SenderProfile_color${colorNumber}`}>
|
||||
<span className={`mx_SenderProfile_name ${colorClass}`}>
|
||||
{ nameElem }
|
||||
</span>
|
||||
{ flair }
|
||||
|
||||
@@ -431,7 +431,7 @@ module.exports = React.createClass({
|
||||
|
||||
const stripReply = ReplyThread.getParentEventId(mxEvent);
|
||||
let body = HtmlUtils.bodyToHtml(content, this.props.highlights, {
|
||||
disableBigEmoji: !SettingsStore.getValue('TextualBody.enableBigEmoji'),
|
||||
disableBigEmoji: content.msgtype === "m.emote" || !SettingsStore.getValue('TextualBody.enableBigEmoji'),
|
||||
// Part of Replies fallback support
|
||||
stripReplyFallback: stripReply,
|
||||
});
|
||||
|
||||
@@ -19,23 +19,30 @@ 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';
|
||||
|
||||
const GROUP_PHASES = [
|
||||
RightPanel.Phase.GroupMemberInfo,
|
||||
RightPanel.Phase.GroupMemberList,
|
||||
];
|
||||
const ROOM_PHASES = [
|
||||
RightPanel.Phase.GroupRoomList,
|
||||
RightPanel.Phase.GroupRoomInfo,
|
||||
];
|
||||
|
||||
export default class GroupHeaderButtons extends HeaderButtons {
|
||||
constructor(props) {
|
||||
super(props, RightPanel.Phase.GroupMemberList);
|
||||
this._onMembersClicked = this._onMembersClicked.bind(this);
|
||||
this._onRoomsClicked = this._onRoomsClicked.bind(this);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -54,27 +61,26 @@ export default class GroupHeaderButtons extends HeaderButtons {
|
||||
}
|
||||
}
|
||||
|
||||
renderButtons() {
|
||||
const groupPhases = [
|
||||
RightPanel.Phase.GroupMemberInfo,
|
||||
RightPanel.Phase.GroupMemberList,
|
||||
];
|
||||
const roomPhases = [
|
||||
RightPanel.Phase.GroupRoomList,
|
||||
RightPanel.Phase.GroupRoomInfo,
|
||||
];
|
||||
_onMembersClicked() {
|
||||
this.togglePhase(RightPanel.Phase.GroupMemberList, GROUP_PHASES);
|
||||
}
|
||||
|
||||
_onRoomsClicked() {
|
||||
this.togglePhase(RightPanel.Phase.GroupRoomList, ROOM_PHASES);
|
||||
}
|
||||
|
||||
renderButtons() {
|
||||
return [
|
||||
<HeaderButton key="groupMembersButton" name="groupMembersButton"
|
||||
title={_t('Members')}
|
||||
isHighlighted={this.isPhase(groupPhases)}
|
||||
clickPhase={RightPanel.Phase.GroupMemberList}
|
||||
isHighlighted={this.isPhase(GROUP_PHASES)}
|
||||
onClick={this._onMembersClicked}
|
||||
analytics={['Right Panel', 'Group Member List Button', 'click']}
|
||||
/>,
|
||||
<HeaderButton key="roomsButton" name="roomsButton"
|
||||
title={_t('Rooms')}
|
||||
isHighlighted={this.isPhase(roomPhases)}
|
||||
clickPhase={RightPanel.Phase.GroupRoomList}
|
||||
isHighlighted={this.isPhase(ROOM_PHASES)}
|
||||
onClick={this._onRoomsClicked}
|
||||
analytics={['Right Panel', 'Group Room List Button', 'click']}
|
||||
/>,
|
||||
];
|
||||
|
||||
@@ -20,7 +20,6 @@ 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';
|
||||
|
||||
@@ -32,11 +31,7 @@ export default class HeaderButton extends React.Component {
|
||||
|
||||
onClick(ev) {
|
||||
Analytics.trackEvent(...this.props.analytics);
|
||||
dis.dispatch({
|
||||
action: 'view_right_panel_phase',
|
||||
phase: this.props.clickPhase,
|
||||
fromHeader: true,
|
||||
});
|
||||
this.props.onClick();
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -59,9 +54,8 @@ export default class HeaderButton extends React.Component {
|
||||
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,
|
||||
|
||||
// click handler
|
||||
onClick: PropTypes.func.isRequired,
|
||||
// The badge to display above the icon
|
||||
badge: PropTypes.node,
|
||||
// The parameters to track the click event
|
||||
|
||||
@@ -40,14 +40,36 @@ export default class HeaderButtons extends React.Component {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!prevProps.collapsedRhs && this.props.collapsedRhs) {
|
||||
this.setState({
|
||||
phase: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setPhase(phase, extras) {
|
||||
// TODO: delay?
|
||||
if (this.props.collapsedRhs) {
|
||||
dis.dispatch({
|
||||
action: 'show_right_panel',
|
||||
});
|
||||
}
|
||||
dis.dispatch(Object.assign({
|
||||
action: 'view_right_panel_phase',
|
||||
phase: phase,
|
||||
}, extras));
|
||||
}
|
||||
|
||||
togglePhase(phase, validPhases = [phase]) {
|
||||
if (validPhases.includes(this.state.phase)) {
|
||||
dis.dispatch({
|
||||
action: 'hide_right_panel',
|
||||
});
|
||||
} else {
|
||||
this.setPhase(phase);
|
||||
}
|
||||
}
|
||||
|
||||
isPhase(phases) {
|
||||
if (this.props.collapsedRhs) {
|
||||
return false;
|
||||
@@ -61,28 +83,9 @@ export default class HeaderButtons extends React.Component {
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
this.setState({
|
||||
phase: payload.phase,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,55 +19,73 @@ 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';
|
||||
|
||||
const MEMBER_PHASES = [
|
||||
RightPanel.Phase.RoomMemberList,
|
||||
RightPanel.Phase.RoomMemberInfo,
|
||||
RightPanel.Phase.Room3pidMemberInfo,
|
||||
];
|
||||
|
||||
export default class RoomHeaderButtons extends HeaderButtons {
|
||||
constructor(props) {
|
||||
super(props, RightPanel.Phase.RoomMemberList);
|
||||
this._onMembersClicked = this._onMembersClicked.bind(this);
|
||||
this._onFilesClicked = this._onFilesClicked.bind(this);
|
||||
this._onNotificationsClicked = this._onNotificationsClicked.bind(this);
|
||||
}
|
||||
|
||||
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") {
|
||||
} else if (payload.action === "view_room" && !this.props.collapsedRhs) {
|
||||
this.setPhase(RightPanel.Phase.RoomMemberList);
|
||||
} else if (payload.action === "view_3pid_invite") {
|
||||
if (payload.event) {
|
||||
this.setPhase(RightPanel.Phase.Room3pidMemberInfo, {event: payload.event});
|
||||
} else {
|
||||
this.setPhase(RightPanel.Phase.RoomMemberList);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderButtons() {
|
||||
const membersPhases = [
|
||||
RightPanel.Phase.RoomMemberList,
|
||||
RightPanel.Phase.RoomMemberInfo,
|
||||
];
|
||||
_onMembersClicked() {
|
||||
this.togglePhase(RightPanel.Phase.RoomMemberList, MEMBER_PHASES);
|
||||
}
|
||||
|
||||
_onFilesClicked() {
|
||||
this.togglePhase(RightPanel.Phase.FilePanel);
|
||||
}
|
||||
|
||||
_onNotificationsClicked() {
|
||||
this.togglePhase(RightPanel.Phase.NotificationPanel);
|
||||
}
|
||||
|
||||
renderButtons() {
|
||||
return [
|
||||
<HeaderButton key="membersButton" name="membersButton"
|
||||
title={_t('Members')}
|
||||
isHighlighted={this.isPhase(membersPhases)}
|
||||
clickPhase={RightPanel.Phase.RoomMemberList}
|
||||
isHighlighted={this.isPhase(MEMBER_PHASES)}
|
||||
onClick={this._onMembersClicked}
|
||||
analytics={['Right Panel', 'Member List Button', 'click']}
|
||||
/>,
|
||||
<HeaderButton key="filesButton" name="filesButton"
|
||||
title={_t('Files')}
|
||||
isHighlighted={this.isPhase(RightPanel.Phase.FilePanel)}
|
||||
clickPhase={RightPanel.Phase.FilePanel}
|
||||
onClick={this._onFilesClicked}
|
||||
analytics={['Right Panel', 'File List Button', 'click']}
|
||||
/>,
|
||||
<HeaderButton key="notifsButton" name="notifsButton"
|
||||
title={_t('Notifications')}
|
||||
isHighlighted={this.isPhase(RightPanel.Phase.NotificationPanel)}
|
||||
clickPhase={RightPanel.Phase.NotificationPanel}
|
||||
onClick={this._onNotificationsClicked}
|
||||
analytics={['Right Panel', 'Notification List Button', 'click']}
|
||||
/>,
|
||||
];
|
||||
|
||||
@@ -34,14 +34,20 @@ export default class RoomProfileSettings extends React.Component {
|
||||
const client = MatrixClientPeg.get();
|
||||
const room = client.getRoom(props.roomId);
|
||||
if (!room) throw new Error("Expected a room for ID: ", props.roomId);
|
||||
|
||||
const avatarEvent = room.currentState.getStateEvents("m.room.avatar", "");
|
||||
let avatarUrl = avatarEvent && avatarEvent.getContent() ? avatarEvent.getContent()["url"] : null;
|
||||
if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false);
|
||||
|
||||
const topicEvent = room.currentState.getStateEvents("m.room.topic", "");
|
||||
const topic = topicEvent && topicEvent.getContent() ? topicEvent.getContent()['topic'] : '';
|
||||
|
||||
const nameEvent = room.currentState.getStateEvents('m.room.name', '');
|
||||
const name = nameEvent && nameEvent.getContent() ? nameEvent.getContent()['name'] : '';
|
||||
|
||||
this.state = {
|
||||
originalDisplayName: room.name,
|
||||
displayName: room.name,
|
||||
originalDisplayName: name,
|
||||
displayName: name,
|
||||
originalAvatarUrl: avatarUrl,
|
||||
avatarUrl: avatarUrl,
|
||||
avatarFile: null,
|
||||
|
||||
@@ -144,7 +144,7 @@ module.exports = React.createClass({
|
||||
|
||||
_launchManageIntegrations: function() {
|
||||
const IntegrationsManager = sdk.getComponent('views.settings.IntegrationsManager');
|
||||
this._scalarClient.connect().done(() => {
|
||||
this.scalarClient.connect().done(() => {
|
||||
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
|
||||
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room, 'add_integ') :
|
||||
null;
|
||||
|
||||
@@ -17,7 +17,6 @@ limitations under the License.
|
||||
|
||||
'use strict';
|
||||
|
||||
|
||||
import ReplyThread from "../elements/ReplyThread";
|
||||
|
||||
const React = require('react');
|
||||
@@ -30,7 +29,6 @@ const sdk = require('../../../index');
|
||||
const TextForEvent = require('../../../TextForEvent');
|
||||
import withMatrixClient from '../../../wrappers/withMatrixClient';
|
||||
|
||||
const ContextualMenu = require('../../structures/ContextualMenu');
|
||||
import dis from '../../../dispatcher';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {EventStatus} from 'matrix-js-sdk';
|
||||
@@ -172,8 +170,8 @@ module.exports = withMatrixClient(React.createClass({
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
// Whether the context menu is being displayed.
|
||||
menu: false,
|
||||
// Whether the action bar is focused.
|
||||
actionBarFocused: false,
|
||||
// Whether all read receipts are being displayed. If not, only display
|
||||
// a truncation of them.
|
||||
allReadAvatars: false,
|
||||
@@ -309,36 +307,6 @@ module.exports = withMatrixClient(React.createClass({
|
||||
return actions.tweaks.highlight;
|
||||
},
|
||||
|
||||
onEditClicked: function(e) {
|
||||
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
|
||||
const buttonRect = e.target.getBoundingClientRect();
|
||||
|
||||
// The window X and Y offsets are to adjust position when zoomed in to page
|
||||
const x = buttonRect.right + window.pageXOffset;
|
||||
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
|
||||
const self = this;
|
||||
|
||||
const {tile, replyThread} = this.refs;
|
||||
|
||||
let e2eInfoCallback = null;
|
||||
if (this.props.mxEvent.isEncrypted()) e2eInfoCallback = () => this.onCryptoClicked();
|
||||
|
||||
ContextualMenu.createMenu(MessageContextMenu, {
|
||||
chevronOffset: 10,
|
||||
mxEvent: this.props.mxEvent,
|
||||
left: x,
|
||||
top: y,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
eventTileOps: tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined,
|
||||
collapseReplyThread: replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined,
|
||||
e2eInfoCallback: e2eInfoCallback,
|
||||
onFinished: function() {
|
||||
self.setState({menu: false});
|
||||
},
|
||||
});
|
||||
this.setState({menu: true});
|
||||
},
|
||||
|
||||
toggleAllReadAvatars: function() {
|
||||
this.setState({
|
||||
allReadAvatars: !this.state.allReadAvatars,
|
||||
@@ -490,6 +458,20 @@ module.exports = withMatrixClient(React.createClass({
|
||||
return null;
|
||||
},
|
||||
|
||||
onActionBarFocusChange(focused) {
|
||||
this.setState({
|
||||
actionBarFocused: focused,
|
||||
});
|
||||
},
|
||||
|
||||
getTile() {
|
||||
return this.refs.tile;
|
||||
},
|
||||
|
||||
getReplyThread() {
|
||||
return this.refs.replyThread;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const MessageTimestamp = sdk.getComponent('messages.MessageTimestamp');
|
||||
const SenderProfile = sdk.getComponent('messages.SenderProfile');
|
||||
@@ -536,7 +518,7 @@ module.exports = withMatrixClient(React.createClass({
|
||||
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
|
||||
mx_EventTile_last: this.props.last,
|
||||
mx_EventTile_contextual: this.props.contextual,
|
||||
menu: this.state.menu,
|
||||
mx_EventTile_actionBarFocused: this.state.actionBarFocused,
|
||||
mx_EventTile_verified: this.state.verified === true,
|
||||
mx_EventTile_unverified: this.state.verified === false,
|
||||
mx_EventTile_bad: isEncryptionFailure,
|
||||
@@ -602,9 +584,14 @@ module.exports = withMatrixClient(React.createClass({
|
||||
}
|
||||
}
|
||||
|
||||
const editButton = (
|
||||
<span className="mx_EventTile_editButton" title={_t("Options")} onClick={this.onEditClicked} />
|
||||
);
|
||||
const MessageActionBar = sdk.getComponent('messages.MessageActionBar');
|
||||
const actionBar = <MessageActionBar
|
||||
mxEvent={this.props.mxEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
getTile={this.getTile}
|
||||
getReplyThread={this.getReplyThread}
|
||||
onFocusChange={this.onActionBarFocusChange}
|
||||
/>;
|
||||
|
||||
const timestamp = this.props.mxEvent.getTs() ?
|
||||
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
|
||||
@@ -643,6 +630,14 @@ module.exports = withMatrixClient(React.createClass({
|
||||
<ToolTipButton helpText={keyRequestHelpText} />
|
||||
</div> : null;
|
||||
|
||||
let reactions;
|
||||
if (SettingsStore.isFeatureEnabled("feature_reactions")) {
|
||||
const ReactionsRow = sdk.getComponent('messages.ReactionsRow');
|
||||
reactions = <ReactionsRow
|
||||
mxEvent={this.props.mxEvent}
|
||||
/>;
|
||||
}
|
||||
|
||||
switch (this.props.tileShape) {
|
||||
case 'notif': {
|
||||
const EmojiText = sdk.getComponent('elements.EmojiText');
|
||||
@@ -755,7 +750,8 @@ module.exports = withMatrixClient(React.createClass({
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
onHeightChanged={this.props.onHeightChanged} />
|
||||
{ keyRequestInfo }
|
||||
{ editButton }
|
||||
{ reactions }
|
||||
{ actionBar }
|
||||
</div>
|
||||
{
|
||||
// The avatar goes after the event tile as it's absolutly positioned to be over the
|
||||
|
||||
@@ -52,7 +52,7 @@ module.exports = React.createClass({
|
||||
this.props.onHeightChanged,
|
||||
);
|
||||
}, (error)=>{
|
||||
console.error("Failed to get preview for " + this.props.link + " " + error);
|
||||
console.error("Failed to get URL preview: " + error);
|
||||
}).done();
|
||||
},
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import SdkConfig from '../../../SdkConfig';
|
||||
import MultiInviter from "../../../utils/MultiInviter";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import E2EIcon from "./E2EIcon";
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
|
||||
module.exports = withMatrixClient(React.createClass({
|
||||
displayName: 'MemberInfo',
|
||||
@@ -1003,7 +1004,7 @@ module.exports = withMatrixClient(React.createClass({
|
||||
{ roomMemberDetails }
|
||||
</div>
|
||||
</div>
|
||||
<GeminiScrollbarWrapper autoshow={true} className="mx_MemberInfo_scrollContainer">
|
||||
<AutoHideScrollbar className="mx_MemberInfo_scrollContainer">
|
||||
<div className="mx_MemberInfo_container">
|
||||
{ this._renderUserOptions() }
|
||||
|
||||
@@ -1015,7 +1016,7 @@ module.exports = withMatrixClient(React.createClass({
|
||||
|
||||
{ spinner }
|
||||
</div>
|
||||
</GeminiScrollbarWrapper>
|
||||
</AutoHideScrollbar>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@ import React from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import dis from '../../../dispatcher';
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
import {isValid3pidInvite} from "../../../RoomInvite";
|
||||
const MatrixClientPeg = require("../../../MatrixClientPeg");
|
||||
const sdk = require('../../../index');
|
||||
const rate_limited_func = require('../../../ratelimitedfunc');
|
||||
@@ -347,6 +349,13 @@ module.exports = React.createClass({
|
||||
});
|
||||
},
|
||||
|
||||
_onPending3pidInviteClick: function(inviteEvent) {
|
||||
dis.dispatch({
|
||||
action: 'view_3pid_invite',
|
||||
event: inviteEvent,
|
||||
});
|
||||
},
|
||||
|
||||
_filterMembers: function(members, membership, query) {
|
||||
return members.filter((m) => {
|
||||
if (query) {
|
||||
@@ -372,11 +381,7 @@ module.exports = React.createClass({
|
||||
|
||||
if (room) {
|
||||
return room.currentState.getStateEvents("m.room.third_party_invite").filter(function(e) {
|
||||
// any events without these keys are not valid 3pid invites, so we ignore them
|
||||
const requiredKeys = ['key_validity_url', 'public_key', 'display_name'];
|
||||
for (let i = 0; i < requiredKeys.length; ++i) {
|
||||
if (e.getContent()[requiredKeys[i]] === undefined) return false;
|
||||
}
|
||||
if (!isValid3pidInvite(e)) return false;
|
||||
|
||||
// discard all invites which have a m.room.member event since we've
|
||||
// already added them.
|
||||
@@ -408,6 +413,7 @@ module.exports = React.createClass({
|
||||
return <EntityTile key={e.getStateKey()}
|
||||
name={e.getContent().display_name}
|
||||
suppressOnHover={true}
|
||||
onClick={() => this._onPending3pidInviteClick(e)}
|
||||
/>;
|
||||
}));
|
||||
}
|
||||
@@ -439,7 +445,6 @@ module.exports = React.createClass({
|
||||
|
||||
const SearchBox = sdk.getComponent('structures.SearchBox');
|
||||
const TruncatedList = sdk.getComponent("elements.TruncatedList");
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(this.props.roomId);
|
||||
@@ -466,7 +471,7 @@ module.exports = React.createClass({
|
||||
return (
|
||||
<div className="mx_MemberList">
|
||||
{ inviteButton }
|
||||
<GeminiScrollbarWrapper autoshow={true}>
|
||||
<AutoHideScrollbar>
|
||||
<div className="mx_MemberList_wrapper">
|
||||
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined}
|
||||
createOverflowElement={this._createOverflowTileJoined}
|
||||
@@ -475,7 +480,7 @@ module.exports = React.createClass({
|
||||
{ invitedHeader }
|
||||
{ invitedSection }
|
||||
</div>
|
||||
</GeminiScrollbarWrapper>
|
||||
</AutoHideScrollbar>
|
||||
|
||||
<SearchBox className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
|
||||
placeholder={ _t('Filter room members') }
|
||||
|
||||
@@ -56,7 +56,7 @@ module.exports = React.createClass({
|
||||
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
|
||||
},
|
||||
|
||||
componentWillUmount() {
|
||||
componentWillUnmount() {
|
||||
const { user } = this.props.member;
|
||||
if (!user) {
|
||||
return;
|
||||
|
||||
@@ -26,6 +26,7 @@ import RoomViewStore from '../../../stores/RoomViewStore';
|
||||
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
|
||||
import Stickerpicker from './Stickerpicker';
|
||||
import { makeRoomPermalink } from '../../../matrix-to';
|
||||
import ContentMessages from '../../../ContentMessages';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import E2EIcon from './E2EIcon';
|
||||
@@ -41,15 +42,159 @@ const formatButtonList = [
|
||||
_td("numbered-list"),
|
||||
];
|
||||
|
||||
function ComposerAvatar(props) {
|
||||
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
|
||||
return <div className="mx_MessageComposer_avatar">
|
||||
<MemberStatusMessageAvatar member={props.me} width={24} height={24} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
ComposerAvatar.propTypes = {
|
||||
me: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
function CallButton(props) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const onVoiceCallClick = (ev) => {
|
||||
dis.dispatch({
|
||||
action: 'place_call',
|
||||
type: "voice",
|
||||
room_id: props.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_voicecall"
|
||||
onClick={onVoiceCallClick}
|
||||
title={_t('Voice call')}
|
||||
/>
|
||||
}
|
||||
|
||||
CallButton.propTypes = {
|
||||
roomId: PropTypes.string.isRequired
|
||||
}
|
||||
|
||||
function VideoCallButton(props) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const onCallClick = (ev) => {
|
||||
dis.dispatch({
|
||||
action: 'place_call',
|
||||
type: ev.shiftKey ? "screensharing" : "video",
|
||||
room_id: props.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_videocall"
|
||||
onClick={onCallClick}
|
||||
title={_t('Video call')}
|
||||
/>;
|
||||
}
|
||||
|
||||
VideoCallButton.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function HangupButton(props) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const onHangupClick = () => {
|
||||
const call = CallHandler.getCallForRoom(props.roomId);
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
dis.dispatch({
|
||||
action: 'hangup',
|
||||
// hangup the call for this room, which may not be the room in props
|
||||
// (e.g. conferences which will hangup the 1:1 room instead)
|
||||
room_id: call.roomId,
|
||||
});
|
||||
};
|
||||
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup"
|
||||
onClick={onHangupClick}
|
||||
title={_t('Hangup')}
|
||||
/>;
|
||||
}
|
||||
|
||||
HangupButton.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
function FormattingButton(props) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return <AccessibleButton
|
||||
element="img"
|
||||
className="mx_MessageComposer_formatting"
|
||||
alt={_t("Show Text Formatting Toolbar")}
|
||||
title={_t("Show Text Formatting Toolbar")}
|
||||
src={require("../../../../res/img/button-text-formatting.svg")}
|
||||
style={{visibility: props.showFormatting ? 'hidden' : 'visible'}}
|
||||
onClick={props.onClickHandler}
|
||||
/>;
|
||||
}
|
||||
|
||||
FormattingButton.propTypes = {
|
||||
showFormatting: PropTypes.bool.isRequired,
|
||||
onClickHandler: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
class UploadButton extends React.Component {
|
||||
static propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
}
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.onUploadClick = this.onUploadClick.bind(this);
|
||||
this.onUploadFileInputChange = this.onUploadFileInputChange.bind(this);
|
||||
}
|
||||
|
||||
onUploadClick(ev) {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
dis.dispatch({action: 'require_registration'});
|
||||
return;
|
||||
}
|
||||
this.refs.uploadInput.click();
|
||||
}
|
||||
|
||||
onUploadFileInputChange(ev) {
|
||||
if (ev.target.files.length === 0) return;
|
||||
|
||||
// take a copy so we can safely reset the value of the form control
|
||||
// (Note it is a FileList: we can't use slice or sesnible iteration).
|
||||
const tfiles = [];
|
||||
for (let i = 0; i < ev.target.files.length; ++i) {
|
||||
tfiles.push(ev.target.files[i]);
|
||||
}
|
||||
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
tfiles, this.props.roomId, MatrixClientPeg.get(),
|
||||
);
|
||||
|
||||
// This is the onChange handler for a file form control, but we're
|
||||
// not keeping any state, so reset the value of the form control
|
||||
// to empty.
|
||||
// NB. we need to set 'value': the 'files' property is immutable.
|
||||
ev.target.value = '';
|
||||
}
|
||||
|
||||
render() {
|
||||
const uploadInputStyle = {display: 'none'};
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_upload"
|
||||
onClick={this.onUploadClick}
|
||||
title={_t('Upload file')}
|
||||
>
|
||||
<input ref="uploadInput" type="file"
|
||||
style={uploadInputStyle}
|
||||
multiple
|
||||
onChange={this.onUploadFileInputChange}
|
||||
/>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class MessageComposer extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.onCallClick = this.onCallClick.bind(this);
|
||||
this.onHangupClick = this.onHangupClick.bind(this);
|
||||
this.onUploadClick = this.onUploadClick.bind(this);
|
||||
this.onUploadFileSelected = this.onUploadFileSelected.bind(this);
|
||||
this.uploadFiles = this.uploadFiles.bind(this);
|
||||
this.onVoiceCallClick = this.onVoiceCallClick.bind(this);
|
||||
this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this);
|
||||
this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this);
|
||||
this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this);
|
||||
@@ -58,6 +203,8 @@ export default class MessageComposer extends React.Component {
|
||||
this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
|
||||
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
|
||||
this._onTombstoneClick = this._onTombstoneClick.bind(this);
|
||||
this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
|
||||
this.renderFormatBar = this.renderFormatBar.bind(this);
|
||||
|
||||
this.state = {
|
||||
inputState: {
|
||||
@@ -136,129 +283,6 @@ export default class MessageComposer extends React.Component {
|
||||
this.setState({ isQuoting });
|
||||
}
|
||||
|
||||
onUploadClick(ev) {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
dis.dispatch({action: 'require_registration'});
|
||||
return;
|
||||
}
|
||||
|
||||
this.refs.uploadInput.click();
|
||||
}
|
||||
|
||||
onUploadFileSelected(files) {
|
||||
const tfiles = files.target.files;
|
||||
this.uploadFiles(tfiles);
|
||||
}
|
||||
|
||||
uploadFiles(files) {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
|
||||
const fileList = [];
|
||||
const acceptedFiles = [];
|
||||
const failedFiles = [];
|
||||
|
||||
for (let i=0; i<files.length; i++) {
|
||||
const fileAcceptedOrError = this.props.uploadAllowed(files[i]);
|
||||
if (fileAcceptedOrError === true) {
|
||||
acceptedFiles.push(<li key={i}>
|
||||
<TintableSvg key={i} src={require("../../../../res/img/files.svg")} width="16" height="16" /> { files[i].name || _t('Attachment') }
|
||||
</li>);
|
||||
fileList.push(files[i]);
|
||||
} else {
|
||||
failedFiles.push(<li key={i}>
|
||||
<TintableSvg key={i} src={require("../../../../res/img/files.svg")} width="16" height="16" /> { files[i].name || _t('Attachment') } <p>{ _t('Reason') + ": " + fileAcceptedOrError}</p>
|
||||
</li>);
|
||||
}
|
||||
}
|
||||
|
||||
const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
|
||||
let replyToWarning = null;
|
||||
if (isQuoting) {
|
||||
replyToWarning = <p>{
|
||||
_t('At this time it is not possible to reply with a file so this will be sent without being a reply.')
|
||||
}</p>;
|
||||
}
|
||||
|
||||
const acceptedFilesPart = acceptedFiles.length === 0 ? null : (
|
||||
<div>
|
||||
<p>{ _t('Are you sure you want to upload the following files?') }</p>
|
||||
<ul style={{listStyle: 'none', textAlign: 'left'}}>
|
||||
{ acceptedFiles }
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
const failedFilesPart = failedFiles.length === 0 ? null : (
|
||||
<div>
|
||||
<p>{ _t('The following files cannot be uploaded:') }</p>
|
||||
<ul style={{listStyle: 'none', textAlign: 'left'}}>
|
||||
{ failedFiles }
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
let buttonText;
|
||||
if (acceptedFiles.length > 0 && failedFiles.length > 0) {
|
||||
buttonText = "Upload selected"
|
||||
} else if (failedFiles.length > 0) {
|
||||
buttonText = "Close"
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Upload Files confirmation', '', QuestionDialog, {
|
||||
title: _t('Upload Files'),
|
||||
description: (
|
||||
<div>
|
||||
{ acceptedFilesPart }
|
||||
{ failedFilesPart }
|
||||
{ replyToWarning }
|
||||
</div>
|
||||
),
|
||||
hasCancelButton: acceptedFiles.length > 0,
|
||||
button: buttonText,
|
||||
onFinished: (shouldUpload) => {
|
||||
if (shouldUpload) {
|
||||
// MessageComposer shouldn't have to rely on its parent passing in a callback to upload a file
|
||||
if (fileList) {
|
||||
for (let i=0; i<fileList.length; i++) {
|
||||
this.props.uploadFile(fileList[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.refs.uploadInput.value = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onHangupClick() {
|
||||
const call = CallHandler.getCallForRoom(this.props.room.roomId);
|
||||
//var call = CallHandler.getAnyActiveCall();
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
dis.dispatch({
|
||||
action: 'hangup',
|
||||
// hangup the call for this room, which may not be the room in props
|
||||
// (e.g. conferences which will hangup the 1:1 room instead)
|
||||
room_id: call.roomId,
|
||||
});
|
||||
}
|
||||
|
||||
onCallClick(ev) {
|
||||
dis.dispatch({
|
||||
action: 'place_call',
|
||||
type: ev.shiftKey ? "screensharing" : "video",
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
}
|
||||
|
||||
onVoiceCallClick(ev) {
|
||||
dis.dispatch({
|
||||
action: 'place_call',
|
||||
type: "voice",
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
}
|
||||
|
||||
onInputStateChanged(inputState) {
|
||||
// Merge the new input state with old to support partial updates
|
||||
@@ -309,137 +333,114 @@ export default class MessageComposer extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const uploadInputStyle = {display: 'none'};
|
||||
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
|
||||
const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput");
|
||||
|
||||
const controls = [];
|
||||
|
||||
if (this.state.me) {
|
||||
controls.push(
|
||||
<div key="controls_avatar" className="mx_MessageComposer_avatar">
|
||||
<MemberStatusMessageAvatar member={this.state.me} width={24} height={24} />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.e2eStatus) {
|
||||
controls.push(<E2EIcon
|
||||
status={this.props.e2eStatus}
|
||||
key="e2eIcon"
|
||||
className="mx_MessageComposer_e2eIcon" />
|
||||
);
|
||||
}
|
||||
|
||||
let callButton;
|
||||
let videoCallButton;
|
||||
let hangupButton;
|
||||
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
// Call buttons
|
||||
if (this.props.callState && this.props.callState !== 'ended') {
|
||||
hangupButton =
|
||||
<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup"
|
||||
key="controls_hangup"
|
||||
onClick={this.onHangupClick}
|
||||
title={_t('Hangup')}
|
||||
>
|
||||
</AccessibleButton>;
|
||||
renderPlaceholderText() {
|
||||
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
|
||||
if (this.state.isQuoting) {
|
||||
if (roomIsEncrypted) {
|
||||
return _t('Send an encrypted reply…');
|
||||
} else {
|
||||
return _t('Send a reply (unencrypted)…');
|
||||
}
|
||||
} else {
|
||||
callButton =
|
||||
<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_voicecall"
|
||||
key="controls_call"
|
||||
onClick={this.onVoiceCallClick}
|
||||
title={_t('Voice call')}
|
||||
>
|
||||
</AccessibleButton>;
|
||||
videoCallButton =
|
||||
<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_videocall"
|
||||
key="controls_videocall"
|
||||
onClick={this.onCallClick}
|
||||
title={_t('Video call')}
|
||||
>
|
||||
</AccessibleButton>;
|
||||
if (roomIsEncrypted) {
|
||||
return _t('Send an encrypted message…');
|
||||
} else {
|
||||
return _t('Send a message (unencrypted)…');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderFormatBar() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const {marks, blockType} = this.state.inputState;
|
||||
const formatButtons = formatButtonList.map((name) => {
|
||||
// special-case to match the md serializer and the special-case in MessageComposerInput.js
|
||||
const markName = name === 'inline-code' ? 'code' : name;
|
||||
const active = marks.some(mark => mark.type === markName) || blockType === name;
|
||||
const suffix = active ? '-on' : '';
|
||||
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
|
||||
const className = 'mx_MessageComposer_format_button mx_filterFlipColor';
|
||||
return (
|
||||
<img className={className}
|
||||
title={_t(name)}
|
||||
onMouseDown={onFormatButtonClicked}
|
||||
key={name}
|
||||
src={require(`../../../../res/img/button-text-${name}${suffix}.svg`)}
|
||||
height="17"
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mx_MessageComposer_formatbar_wrapper">
|
||||
<div className="mx_MessageComposer_formatbar">
|
||||
{ formatButtons }
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<AccessibleButton
|
||||
className="mx_MessageComposer_formatbar_markdown mx_MessageComposer_markdownDisabled"
|
||||
onClick={this.onToggleMarkdownClicked}
|
||||
title={_t("Markdown is disabled")}
|
||||
/>
|
||||
<AccessibleButton element="img" title={_t("Hide Text Formatting Toolbar")}
|
||||
onClick={this.onToggleFormattingClicked}
|
||||
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
|
||||
src={require("../../../../res/img/icon-text-cancel.svg")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const controls = [
|
||||
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
|
||||
this.props.e2eStatus ? <E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> : null,
|
||||
];
|
||||
|
||||
if (!this.state.tombstone && this.state.canSendMessages) {
|
||||
// This also currently includes the call buttons. Really we should
|
||||
// check separately for whether we can call, but this is slightly
|
||||
// complex because of conference calls.
|
||||
const uploadButton = (
|
||||
<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_upload"
|
||||
key="controls_upload"
|
||||
onClick={this.onUploadClick}
|
||||
title={_t('Upload file')}
|
||||
>
|
||||
<input ref="uploadInput" type="file"
|
||||
style={uploadInputStyle}
|
||||
multiple
|
||||
onChange={this.onUploadFileSelected} />
|
||||
</AccessibleButton>
|
||||
);
|
||||
|
||||
const formattingButton = this.state.inputState.isRichTextEnabled ? (
|
||||
<AccessibleButton element="img" className="mx_MessageComposer_formatting"
|
||||
alt={_t("Show Text Formatting Toolbar")}
|
||||
title={_t("Show Text Formatting Toolbar")}
|
||||
src={require("../../../../res/img/button-text-formatting.svg")}
|
||||
onClick={this.onToggleFormattingClicked}
|
||||
style={{visibility: this.state.showFormatting ? 'hidden' : 'visible'}}
|
||||
key="controls_formatting" />
|
||||
) : null;
|
||||
|
||||
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
|
||||
let placeholderText;
|
||||
if (this.state.isQuoting) {
|
||||
if (roomIsEncrypted) {
|
||||
placeholderText = _t('Send an encrypted reply…');
|
||||
} else {
|
||||
placeholderText = _t('Send a reply (unencrypted)…');
|
||||
}
|
||||
} else {
|
||||
if (roomIsEncrypted) {
|
||||
placeholderText = _t('Send an encrypted message…');
|
||||
} else {
|
||||
placeholderText = _t('Send a message (unencrypted)…');
|
||||
}
|
||||
}
|
||||
|
||||
const stickerpickerButton = <Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />;
|
||||
const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput");
|
||||
const showFormattingButton = this.state.inputState.isRichTextEnabled;
|
||||
const callInProgress = this.props.callState && this.props.callState !== 'ended';
|
||||
|
||||
controls.push(
|
||||
<MessageComposerInput
|
||||
ref={(c) => this.messageComposerInput = c}
|
||||
key="controls_input"
|
||||
onResize={this.props.onResize}
|
||||
room={this.props.room}
|
||||
placeholder={placeholderText}
|
||||
onFilesPasted={this.uploadFiles}
|
||||
placeholder={this.renderPlaceholderText()}
|
||||
onInputStateChanged={this.onInputStateChanged}
|
||||
permalinkCreator={this.props.permalinkCreator} />,
|
||||
formattingButton,
|
||||
stickerpickerButton,
|
||||
uploadButton,
|
||||
hangupButton,
|
||||
callButton,
|
||||
videoCallButton,
|
||||
showFormattingButton ? <FormattingButton key="controls_formatting"
|
||||
showFormatting={this.state.showFormatting} onClickHandler={this.onToggleFormattingClicked} /> : null,
|
||||
<Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />,
|
||||
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
|
||||
callInProgress ? <HangupButton key="controls_hangup" roomId={this.props.room.roomId} /> : null,
|
||||
callInProgress ? null : <CallButton key="controls_call" roomId={this.props.room.roomId} />,
|
||||
callInProgress ? null : <VideoCallButton key="controls_videocall" roomId={this.props.room.roomId} />,
|
||||
);
|
||||
} else if (this.state.tombstone) {
|
||||
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
|
||||
|
||||
const continuesLink = replacementRoomId ? (
|
||||
<a href={makeRoomPermalink(replacementRoomId)}
|
||||
className="mx_MessageComposer_roomReplaced_link"
|
||||
onClick={this._onTombstoneClick}
|
||||
>
|
||||
{_t("The conversation continues here.")}
|
||||
</a>
|
||||
) : '';
|
||||
|
||||
controls.push(<div className="mx_MessageComposer_replaced_wrapper">
|
||||
<div className="mx_MessageComposer_replaced_valign">
|
||||
<img className="mx_MessageComposer_roomReplaced_icon" src={require("../../../../res/img/room_replaced.svg")} />
|
||||
<span className="mx_MessageComposer_roomReplaced_header">
|
||||
{_t("This room has been replaced and is no longer active.")}
|
||||
</span><br />
|
||||
<a href={makeRoomPermalink(replacementRoomId)}
|
||||
className="mx_MessageComposer_roomReplaced_link"
|
||||
onClick={this._onTombstoneClick}
|
||||
>
|
||||
{_t("The conversation continues here.")}
|
||||
</a>
|
||||
{ continuesLink }
|
||||
</div>
|
||||
</div>);
|
||||
} else {
|
||||
@@ -450,42 +451,7 @@ export default class MessageComposer extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
let formatBar;
|
||||
if (this.state.showFormatting && this.state.inputState.isRichTextEnabled) {
|
||||
const {marks, blockType} = this.state.inputState;
|
||||
const formatButtons = formatButtonList.map((name) => {
|
||||
// special-case to match the md serializer and the special-case in MessageComposerInput.js
|
||||
const markName = name === 'inline-code' ? 'code' : name;
|
||||
const active = marks.some(mark => mark.type === markName) || blockType === name;
|
||||
const suffix = active ? '-on' : '';
|
||||
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
|
||||
const className = 'mx_MessageComposer_format_button mx_filterFlipColor';
|
||||
return <img className={className}
|
||||
title={_t(name)}
|
||||
onMouseDown={onFormatButtonClicked}
|
||||
key={name}
|
||||
src={require(`../../../../res/img/button-text-${name}${suffix}.svg`)}
|
||||
height="17" />;
|
||||
},
|
||||
);
|
||||
|
||||
formatBar =
|
||||
<div className="mx_MessageComposer_formatbar_wrapper">
|
||||
<div className="mx_MessageComposer_formatbar">
|
||||
{ formatButtons }
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<AccessibleButton className="mx_MessageComposer_formatbar_markdown mx_MessageComposer_markdownDisabled"
|
||||
onClick={this.onToggleMarkdownClicked}
|
||||
title={_t("Markdown is disabled")}
|
||||
/>
|
||||
<AccessibleButton element="img" title={_t("Hide Text Formatting Toolbar")}
|
||||
onClick={this.onToggleFormattingClicked}
|
||||
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
|
||||
src={require("../../../../res/img/icon-text-cancel.svg")}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
const showFormatBar = this.state.showFormatting && this.state.inputState.isRichTextEnabled;
|
||||
|
||||
const wrapperClasses = classNames({
|
||||
mx_MessageComposer_wrapper: true,
|
||||
@@ -498,29 +464,19 @@ export default class MessageComposer extends React.Component {
|
||||
{ controls }
|
||||
</div>
|
||||
</div>
|
||||
{ formatBar }
|
||||
{ showFormatBar ? this.renderFormatBar() : null }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MessageComposer.propTypes = {
|
||||
// a callback which is called when the height of the composer is
|
||||
// changed due to a change in content.
|
||||
onResize: PropTypes.func,
|
||||
|
||||
// js-sdk Room object
|
||||
room: PropTypes.object.isRequired,
|
||||
|
||||
// string representing the current voip call state
|
||||
callState: PropTypes.string,
|
||||
|
||||
// callback when a file to upload is chosen
|
||||
uploadFile: PropTypes.func.isRequired,
|
||||
|
||||
// function to test whether a file should be allowed to be uploaded.
|
||||
uploadAllowed: PropTypes.func.isRequired,
|
||||
|
||||
// string representing the current room app drawer state
|
||||
showApps: PropTypes.bool
|
||||
};
|
||||
|
||||
@@ -47,6 +47,7 @@ import {Completion} from "../../../autocomplete/Autocompleter";
|
||||
import Markdown from '../../../Markdown';
|
||||
import ComposerHistoryManager from '../../../ComposerHistoryManager';
|
||||
import MessageComposerStore from '../../../stores/MessageComposerStore';
|
||||
import ContentMessages from '../../../ContentMessages';
|
||||
|
||||
import {MATRIXTO_URL_PATTERN} from '../../../linkify-matrix';
|
||||
|
||||
@@ -135,15 +136,9 @@ function rangeEquals(a: Range, b: Range): boolean {
|
||||
*/
|
||||
export default class MessageComposerInput extends React.Component {
|
||||
static propTypes = {
|
||||
// a callback which is called when the height of the composer is
|
||||
// changed due to a change in content.
|
||||
onResize: PropTypes.func,
|
||||
|
||||
// js-sdk Room object
|
||||
room: PropTypes.object.isRequired,
|
||||
|
||||
onFilesPasted: PropTypes.func,
|
||||
|
||||
onInputStateChanged: PropTypes.func,
|
||||
};
|
||||
|
||||
@@ -1013,7 +1008,13 @@ export default class MessageComposerInput extends React.Component {
|
||||
|
||||
switch (transfer.type) {
|
||||
case 'files':
|
||||
return this.props.onFilesPasted(transfer.files);
|
||||
// This actually not so much for 'files' as such (at time of writing
|
||||
// neither chrome nor firefox let you paste a plain file copied
|
||||
// from Finder) but more images copied from a different website
|
||||
// / word processor etc.
|
||||
return ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
transfer.files, this.props.room.roomId, this.client,
|
||||
);
|
||||
case 'html': {
|
||||
if (this.state.isRichTextEnabled) {
|
||||
// FIXME: https://github.com/ianstormtaylor/slate/issues/1497 means
|
||||
@@ -1040,7 +1041,8 @@ export default class MessageComposerInput extends React.Component {
|
||||
};
|
||||
|
||||
handleReturn = (ev, change) => {
|
||||
if (ev.shiftKey) {
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
if (ev.shiftKey || (isMac && ev.altKey)) {
|
||||
return change.insertText('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,19 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
import React from "react";
|
||||
import dis from "../../../dispatcher";
|
||||
import MatrixClientPeg from "../../../MatrixClientPeg";
|
||||
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import RoomAvatar from '../avatars/RoomAvatar';
|
||||
import classNames from 'classnames';
|
||||
import sdk from "../../../index";
|
||||
import Analytics from "../../../Analytics";
|
||||
import * as RoomNotifs from '../../../RoomNotifs';
|
||||
import * as FormattingUtils from "../../../utils/FormattingUtils";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import {_t} from "../../../languageHandler";
|
||||
|
||||
const MAX_ROOMS = 20;
|
||||
|
||||
@@ -28,25 +34,58 @@ export default class RoomBreadcrumbs extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {rooms: []};
|
||||
|
||||
this.onAction = this.onAction.bind(this);
|
||||
this._previousRoomId = null;
|
||||
this._dispatcherRef = null;
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this._dispatcherRef = dis.register(this.onAction);
|
||||
|
||||
let storedRooms = SettingsStore.getValue("breadcrumb_rooms");
|
||||
if (!storedRooms || !storedRooms.length) {
|
||||
// Fallback to the rooms stored in localstorage for those who would have had this.
|
||||
// TODO: Remove this after a bit - the feature was only on develop, so a few weeks should be plenty time.
|
||||
const roomStr = localStorage.getItem("mx_breadcrumb_rooms");
|
||||
if (roomStr) {
|
||||
try {
|
||||
storedRooms = JSON.parse(roomStr);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse breadcrumbs:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
this._loadRoomIds(storedRooms || []);
|
||||
|
||||
this._settingWatchRef = SettingsStore.watchSetting("breadcrumb_rooms", null, this.onBreadcrumbsChanged);
|
||||
|
||||
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
|
||||
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
|
||||
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
|
||||
MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
|
||||
SettingsStore.unwatchSetting(this._settingWatchRef);
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
if (client) {
|
||||
client.removeListener("Room.myMembership", this.onMyMembership);
|
||||
client.removeListener("Room.receipt", this.onRoomReceipt);
|
||||
client.removeListener("Room.timeline", this.onRoomTimeline);
|
||||
client.removeListener("Event.decrypted", this.onEventDecrypted);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const rooms = this.state.rooms.slice();
|
||||
|
||||
if (rooms.length) {
|
||||
const {room, animated} = rooms[0];
|
||||
if (!animated) {
|
||||
rooms[0] = {room, animated: true};
|
||||
const roomModel = rooms[0];
|
||||
if (!roomModel.animated) {
|
||||
roomModel.animated = true;
|
||||
setTimeout(() => this.setState({rooms}), 0);
|
||||
}
|
||||
}
|
||||
@@ -55,55 +94,254 @@ export default class RoomBreadcrumbs extends React.Component {
|
||||
onAction(payload) {
|
||||
switch (payload.action) {
|
||||
case 'view_room':
|
||||
if (this._previousRoomId) {
|
||||
this._appendRoomId(this._previousRoomId);
|
||||
}
|
||||
this._previousRoomId = payload.room_id;
|
||||
this._appendRoomId(payload.room_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_appendRoomId(roomId) {
|
||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||
if (!room) {
|
||||
return;
|
||||
onMyMembership = (room, membership) => {
|
||||
if (membership === "leave" || membership === "ban") {
|
||||
const rooms = this.state.rooms.slice();
|
||||
const roomState = rooms.find((r) => r.room.roomId === room.roomId);
|
||||
if (roomState) {
|
||||
roomState.left = true;
|
||||
this.setState({rooms});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onRoomReceipt = (event, room) => {
|
||||
if (this.state.rooms.map(r => r.room.roomId).includes(room.roomId)) {
|
||||
this._calculateRoomBadges(room);
|
||||
}
|
||||
};
|
||||
|
||||
onRoomTimeline = (event, room) => {
|
||||
if (!room) return; // Can be null for the notification timeline, etc.
|
||||
if (this.state.rooms.map(r => r.room.roomId).includes(room.roomId)) {
|
||||
this._calculateRoomBadges(room);
|
||||
}
|
||||
};
|
||||
|
||||
onEventDecrypted = (event) => {
|
||||
if (this.state.rooms.map(r => r.room.roomId).includes(event.getRoomId())) {
|
||||
this._calculateRoomBadges(MatrixClientPeg.get().getRoom(event.getRoomId()));
|
||||
}
|
||||
};
|
||||
|
||||
onBreadcrumbsChanged = (settingName, roomId, level, valueAtLevel, value) => {
|
||||
if (!value) return;
|
||||
|
||||
const currentState = this.state.rooms.map((r) => r.room.roomId);
|
||||
if (currentState.length === value.length) {
|
||||
let changed = false;
|
||||
for (let i = 0; i < currentState.length; i++) {
|
||||
if (currentState[i] !== value[i]) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
}
|
||||
|
||||
this._loadRoomIds(value);
|
||||
};
|
||||
|
||||
_loadRoomIds(roomIds) {
|
||||
if (!roomIds || roomIds.length <= 0) return; // Skip updates with no rooms
|
||||
|
||||
// If we're here, the list changed.
|
||||
const rooms = roomIds.map((r) => MatrixClientPeg.get().getRoom(r)).filter((r) => r).map((r) => {
|
||||
const badges = this._calculateBadgesForRoom(r) || {};
|
||||
return {
|
||||
room: r,
|
||||
animated: false,
|
||||
...badges,
|
||||
};
|
||||
});
|
||||
this.setState({
|
||||
rooms: rooms,
|
||||
});
|
||||
}
|
||||
|
||||
_calculateBadgesForRoom(room) {
|
||||
if (!room) return null;
|
||||
|
||||
// Reset the notification variables for simplicity
|
||||
const roomModel = {
|
||||
redBadge: false,
|
||||
formattedCount: "0",
|
||||
showCount: false,
|
||||
};
|
||||
|
||||
const notifState = RoomNotifs.getRoomNotifsState(room.roomId);
|
||||
if (RoomNotifs.MENTION_BADGE_STATES.includes(notifState)) {
|
||||
const highlightNotifs = RoomNotifs.getUnreadNotificationCount(room, 'highlight');
|
||||
const unreadNotifs = RoomNotifs.getUnreadNotificationCount(room);
|
||||
|
||||
const redBadge = highlightNotifs > 0;
|
||||
const greyBadge = redBadge || (unreadNotifs > 0 && RoomNotifs.BADGE_STATES.includes(notifState));
|
||||
|
||||
if (redBadge || greyBadge) {
|
||||
const notifCount = redBadge ? highlightNotifs : unreadNotifs;
|
||||
const limitedCount = FormattingUtils.formatCount(notifCount);
|
||||
|
||||
roomModel.redBadge = redBadge;
|
||||
roomModel.formattedCount = limitedCount;
|
||||
roomModel.showCount = true;
|
||||
}
|
||||
}
|
||||
|
||||
return roomModel;
|
||||
}
|
||||
|
||||
_calculateRoomBadges(room) {
|
||||
if (!room) return;
|
||||
|
||||
const rooms = this.state.rooms.slice();
|
||||
const roomModel = rooms.find((r) => r.room.roomId === room.roomId);
|
||||
if (!roomModel) return; // No applicable room, so don't do math on it
|
||||
|
||||
const badges = this._calculateBadgesForRoom(room);
|
||||
if (!badges) return; // No badges for some reason
|
||||
|
||||
Object.assign(roomModel, badges);
|
||||
this.setState({rooms});
|
||||
}
|
||||
|
||||
_appendRoomId(roomId) {
|
||||
let room = MatrixClientPeg.get().getRoom(roomId);
|
||||
if (!room) return;
|
||||
|
||||
const rooms = this.state.rooms.slice();
|
||||
|
||||
// If the room is upgraded, use that room instead. We'll also splice out
|
||||
// any children of the room.
|
||||
const history = MatrixClientPeg.get().getRoomUpgradeHistory(roomId);
|
||||
if (history.length > 1) {
|
||||
room = history[history.length - 1]; // Last room is most recent
|
||||
|
||||
// Take out any room that isn't the most recent room
|
||||
for (let i = 0; i < history.length - 1; i++) {
|
||||
const idx = rooms.findIndex((r) => r.room.roomId === history[i].roomId);
|
||||
if (idx !== -1) rooms.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const existingIdx = rooms.findIndex((r) => r.room.roomId === room.roomId);
|
||||
if (existingIdx !== -1) {
|
||||
rooms.splice(existingIdx, 1);
|
||||
}
|
||||
|
||||
rooms.splice(0, 0, {room, animated: false});
|
||||
|
||||
if (rooms.length > MAX_ROOMS) {
|
||||
rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
|
||||
}
|
||||
this.setState({rooms});
|
||||
|
||||
if (this.refs.scroller) {
|
||||
this.refs.scroller.moveToOrigin();
|
||||
}
|
||||
|
||||
// We don't track room aesthetics (badges, membership, etc) over the wire so we
|
||||
// don't need to do this elsewhere in the file. Just where we alter the room IDs
|
||||
// and their order.
|
||||
const roomIds = rooms.map((r) => r.room.roomId);
|
||||
if (roomIds.length > 0) {
|
||||
SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
|
||||
}
|
||||
}
|
||||
|
||||
_viewRoom(room) {
|
||||
_viewRoom(room, index) {
|
||||
Analytics.trackEvent("Breadcrumbs", "click_node", index);
|
||||
dis.dispatch({action: "view_room", room_id: room.roomId});
|
||||
}
|
||||
|
||||
_onMouseEnter(room) {
|
||||
this._onHover(room);
|
||||
}
|
||||
|
||||
_onMouseLeave(room) {
|
||||
this._onHover(null); // clear hover states
|
||||
}
|
||||
|
||||
_onHover(room) {
|
||||
const rooms = this.state.rooms.slice();
|
||||
for (const r of rooms) {
|
||||
r.hover = room && r.room.roomId === room.roomId;
|
||||
}
|
||||
this.setState({rooms});
|
||||
}
|
||||
|
||||
_isDmRoom(room) {
|
||||
const dmRooms = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
return Boolean(dmRooms);
|
||||
}
|
||||
|
||||
render() {
|
||||
// check for collapsed here and
|
||||
// not at parent so we keep
|
||||
// rooms in our state
|
||||
const Tooltip = sdk.getComponent('elements.Tooltip');
|
||||
const IndicatorScrollbar = sdk.getComponent('structures.IndicatorScrollbar');
|
||||
|
||||
// check for collapsed here and not at parent so we keep rooms in our state
|
||||
// when collapsing and expanding
|
||||
if (this.props.collapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rooms = this.state.rooms;
|
||||
const avatars = rooms.map(({room, animated}, i) => {
|
||||
const avatars = rooms.map((r, i) => {
|
||||
const isFirst = i === 0;
|
||||
const classes = classNames({
|
||||
"mx_RoomBreadcrumbs_preAnimate": isFirst && !animated,
|
||||
"mx_RoomBreadcrumbs_crumb": true,
|
||||
"mx_RoomBreadcrumbs_preAnimate": isFirst && !r.animated,
|
||||
"mx_RoomBreadcrumbs_animate": isFirst,
|
||||
"mx_RoomBreadcrumbs_left": r.left,
|
||||
});
|
||||
|
||||
let tooltip = null;
|
||||
if (r.hover) {
|
||||
tooltip = <Tooltip label={r.room.name} />;
|
||||
}
|
||||
|
||||
let badge;
|
||||
if (r.showCount) {
|
||||
const badgeClasses = classNames({
|
||||
'mx_RoomTile_badge': true,
|
||||
'mx_RoomTile_badgeButton': true,
|
||||
'mx_RoomTile_badgeRed': r.redBadge,
|
||||
'mx_RoomTile_badgeUnread': !r.redBadge,
|
||||
});
|
||||
|
||||
badge = <div className={badgeClasses}>{r.formattedCount}</div>;
|
||||
}
|
||||
|
||||
let dmIndicator;
|
||||
if (this._isDmRoom(r.room)) {
|
||||
dmIndicator = <img
|
||||
src={require("../../../../res/img/icon_person.svg")}
|
||||
className="mx_RoomBreadcrumbs_dmIndicator"
|
||||
width="13"
|
||||
height="15"
|
||||
alt={_t("Direct Chat")}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessibleButton className={classes} key={room.roomId} title={room.name} onClick={() => this._viewRoom(room)}>
|
||||
<RoomAvatar room={room} width={32} height={32} />
|
||||
<AccessibleButton className={classes} key={r.room.roomId} onClick={() => this._viewRoom(r.room, i)}
|
||||
onMouseEnter={() => this._onMouseEnter(r.room)} onMouseLeave={() => this._onMouseLeave(r.room)}>
|
||||
<RoomAvatar room={r.room} width={32} height={32} />
|
||||
{badge}
|
||||
{dmIndicator}
|
||||
{tooltip}
|
||||
</AccessibleButton>
|
||||
);
|
||||
});
|
||||
return (<div className="mx_RoomBreadcrumbs">{ avatars }</div>);
|
||||
return (
|
||||
<IndicatorScrollbar ref="scroller" className="mx_RoomBreadcrumbs"
|
||||
trackHorizontalOverflow={true} verticalScrollsHorizontally={true}>
|
||||
{ avatars }
|
||||
</IndicatorScrollbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,9 @@ module.exports = React.createClass({
|
||||
this._checkSubListsOverflow();
|
||||
|
||||
this.resizer.attach();
|
||||
window.addEventListener("resize", this.onWindowResize);
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.on("leftPanelResized", this.onResize);
|
||||
}
|
||||
this.mounted = true;
|
||||
},
|
||||
|
||||
@@ -260,7 +262,6 @@ module.exports = React.createClass({
|
||||
componentWillUnmount: function() {
|
||||
this.mounted = false;
|
||||
|
||||
window.removeEventListener("resize", this.onWindowResize);
|
||||
dis.unregister(this.dispatcherRef);
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("Room", this.onRoom);
|
||||
@@ -273,6 +274,11 @@ module.exports = React.createClass({
|
||||
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
}
|
||||
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.removeListener("leftPanelResized", this.onResize);
|
||||
}
|
||||
|
||||
|
||||
if (this._tagStoreToken) {
|
||||
this._tagStoreToken.remove();
|
||||
}
|
||||
@@ -293,13 +299,14 @@ module.exports = React.createClass({
|
||||
this._delayedRefreshRoomList.cancelPendingCall();
|
||||
},
|
||||
|
||||
onWindowResize: function() {
|
||||
|
||||
onResize: function() {
|
||||
if (this.mounted && this._layout && this.resizeContainer &&
|
||||
Array.isArray(this._layoutSections)
|
||||
) {
|
||||
this._layout.update(
|
||||
this._layoutSections,
|
||||
this.resizeContainer.offsetHeight
|
||||
this.resizeContainer.offsetHeight,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,13 +17,29 @@ limitations under the License.
|
||||
|
||||
'use strict';
|
||||
|
||||
const React = require('react');
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
const sdk = require('../../../index');
|
||||
const MatrixClientPeg = require('../../../MatrixClientPeg');
|
||||
|
||||
import sdk from '../../../index';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import dis from '../../../dispatcher';
|
||||
import classNames from 'classnames';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
const MessageCase = Object.freeze({
|
||||
NotLoggedIn: "NotLoggedIn",
|
||||
Joining: "Joining",
|
||||
Loading: "Loading",
|
||||
Rejecting: "Rejecting",
|
||||
Kicked: "Kicked",
|
||||
Banned: "Banned",
|
||||
OtherThreePIDError: "OtherThreePIDError",
|
||||
InvitedEmailMismatch: "InvitedEmailMismatch",
|
||||
Invite: "Invite",
|
||||
ViewingRoom: "ViewingRoom",
|
||||
RoomNotFound: "RoomNotFound",
|
||||
OtherError: "OtherError",
|
||||
});
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'RoomPreviewBar',
|
||||
|
||||
@@ -31,7 +47,6 @@ module.exports = React.createClass({
|
||||
onJoinClick: PropTypes.func,
|
||||
onRejectClick: PropTypes.func,
|
||||
onForgetClick: PropTypes.func,
|
||||
|
||||
// if inviterName is specified, the preview bar will shown an invite to the room.
|
||||
// You should also specify onRejectClick if specifiying inviterName
|
||||
inviterName: PropTypes.string,
|
||||
@@ -50,7 +65,9 @@ module.exports = React.createClass({
|
||||
// purpose of the spinner.
|
||||
spinner: PropTypes.bool,
|
||||
spinnerState: PropTypes.oneOf(["joining"]),
|
||||
|
||||
loading: PropTypes.bool,
|
||||
joining: PropTypes.bool,
|
||||
rejecting: PropTypes.bool,
|
||||
// The alias that was used to access this room, if appropriate
|
||||
// If given, this will be how the room is referred to (eg.
|
||||
// in error messages).
|
||||
@@ -60,7 +77,6 @@ module.exports = React.createClass({
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onJoinClick: function() {},
|
||||
canPreview: true,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -72,7 +88,7 @@ module.exports = React.createClass({
|
||||
|
||||
componentWillMount: function() {
|
||||
// If this is an invite and we've been told what email
|
||||
// address was invited, fetch the user's list of 3pids
|
||||
// address was invited, fetch the user's list of Threepids
|
||||
// so we can check them against the one that was invited
|
||||
if (this.props.inviterName && this.props.invitedEmail) {
|
||||
this.setState({busy: true});
|
||||
@@ -88,157 +104,355 @@ module.exports = React.createClass({
|
||||
}
|
||||
},
|
||||
|
||||
_roomNameElement: function() {
|
||||
return this.props.room ? this.props.room.name : (this.props.room_alias || "");
|
||||
_getMessageCase() {
|
||||
const isGuest = MatrixClientPeg.get().isGuest();
|
||||
|
||||
if (isGuest) {
|
||||
return MessageCase.NotLoggedIn;
|
||||
}
|
||||
|
||||
const myMember = this._getMyMember();
|
||||
|
||||
if (myMember) {
|
||||
if (myMember.isKicked()) {
|
||||
return MessageCase.Kicked;
|
||||
} else if (myMember.membership === "ban") {
|
||||
return MessageCase.Banned;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.joining) {
|
||||
return MessageCase.Joining;
|
||||
} else if (this.props.rejecting) {
|
||||
return MessageCase.Rejecting;
|
||||
} else if (this.props.loading || this.state.busy) {
|
||||
return MessageCase.Loading;
|
||||
}
|
||||
|
||||
if (this.props.inviterName) {
|
||||
if (this.props.invitedEmail) {
|
||||
if (this.state.threePidFetchError) {
|
||||
return MessageCase.OtherThreePIDError;
|
||||
} else if (this.state.invitedEmailMxid != MatrixClientPeg.get().getUserId()) {
|
||||
return MessageCase.InvitedEmailMismatch;
|
||||
}
|
||||
}
|
||||
return MessageCase.Invite;
|
||||
} else if (this.props.error) {
|
||||
if (this.props.error.errcode == 'M_NOT_FOUND') {
|
||||
return MessageCase.RoomNotFound;
|
||||
} else {
|
||||
return MessageCase.OtherError;
|
||||
}
|
||||
} else {
|
||||
return MessageCase.ViewingRoom;
|
||||
}
|
||||
},
|
||||
|
||||
_getKickOrBanInfo() {
|
||||
const myMember = this._getMyMember();
|
||||
if (!myMember) {
|
||||
return {};
|
||||
}
|
||||
const kickerMember = this.props.room.currentState.getMember(
|
||||
myMember.events.member.getSender(),
|
||||
);
|
||||
const memberName = kickerMember ?
|
||||
kickerMember.name : myMember.events.member.getSender();
|
||||
const reason = myMember.events.member.getContent().reason;
|
||||
return {memberName, reason};
|
||||
},
|
||||
|
||||
_joinRule: function() {
|
||||
const room = this.props.room;
|
||||
if (room) {
|
||||
const joinRules = room.currentState.getStateEvents('m.room.join_rules', '');
|
||||
if (joinRules) {
|
||||
return joinRules.getContent().join_rule;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_roomName: function(atStart = false) {
|
||||
const name = this.props.room ? this.props.room.name : this.props.roomAlias;
|
||||
if (name) {
|
||||
return name;
|
||||
} else if (atStart) {
|
||||
return _t("This room");
|
||||
} else {
|
||||
return _t("this room");
|
||||
}
|
||||
},
|
||||
|
||||
_getMyMember() {
|
||||
return (
|
||||
this.props.room &&
|
||||
this.props.room.getMember(MatrixClientPeg.get().getUserId())
|
||||
);
|
||||
},
|
||||
|
||||
_getInviteMember: function() {
|
||||
const {room} = this.props;
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
const myUserId = MatrixClientPeg.get().getUserId();
|
||||
const inviteEvent = room.currentState.getMember(myUserId);
|
||||
if (!inviteEvent) {
|
||||
return;
|
||||
}
|
||||
const inviterUserId = inviteEvent.events.member.getSender();
|
||||
return room.currentState.getMember(inviterUserId);
|
||||
},
|
||||
|
||||
_isDMInvite() {
|
||||
const myMember = this._getMyMember();
|
||||
if (!myMember) {
|
||||
return false;
|
||||
}
|
||||
const memberEvent = myMember.events.member;
|
||||
const memberContent = memberEvent.getContent();
|
||||
return memberContent.membership === "invite" && memberContent.is_direct;
|
||||
},
|
||||
|
||||
onLoginClick: function() {
|
||||
dis.dispatch({ action: 'start_login' });
|
||||
},
|
||||
|
||||
onRegisterClick: function() {
|
||||
dis.dispatch({ action: 'start_registration' });
|
||||
},
|
||||
|
||||
render: function() {
|
||||
let joinBlock; let previewBlock;
|
||||
let showSpinner = false;
|
||||
let darkStyle = false;
|
||||
let title;
|
||||
let subTitle;
|
||||
let primaryActionHandler;
|
||||
let primaryActionLabel;
|
||||
let secondaryActionHandler;
|
||||
let secondaryActionLabel;
|
||||
|
||||
if (this.props.spinner || this.state.busy) {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
let spinnerIntro = "";
|
||||
if (this.props.spinnerState === "joining") {
|
||||
spinnerIntro = _t("Joining room...");
|
||||
const messageCase = this._getMessageCase();
|
||||
switch (messageCase) {
|
||||
case MessageCase.Joining: {
|
||||
title = _t("Joining room …");
|
||||
showSpinner = true;
|
||||
break;
|
||||
}
|
||||
case MessageCase.Loading: {
|
||||
title = _t("Loading …");
|
||||
showSpinner = true;
|
||||
break;
|
||||
}
|
||||
case MessageCase.Rejecting: {
|
||||
title = _t("Rejecting invite …");
|
||||
showSpinner = true;
|
||||
break;
|
||||
}
|
||||
case MessageCase.NotLoggedIn: {
|
||||
darkStyle = true;
|
||||
title = _t("Join the conversation with an account");
|
||||
primaryActionLabel = _t("Sign Up");
|
||||
primaryActionHandler = this.onRegisterClick;
|
||||
secondaryActionLabel = _t("Sign In");
|
||||
secondaryActionHandler = this.onLoginClick;
|
||||
break;
|
||||
}
|
||||
case MessageCase.Kicked: {
|
||||
const {memberName, reason} = this._getKickOrBanInfo();
|
||||
title = _t("You were kicked from %(roomName)s by %(memberName)s",
|
||||
{memberName, roomName: this._roomName()});
|
||||
subTitle = _t("Reason: %(reason)s", {reason});
|
||||
|
||||
if (this._joinRule() === "invite") {
|
||||
primaryActionLabel = _t("Forget this room");
|
||||
primaryActionHandler = this.props.onForgetClick;
|
||||
} else {
|
||||
primaryActionLabel = _t("Re-join");
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
secondaryActionLabel = _t("Forget this room");
|
||||
secondaryActionHandler = this.props.onForgetClick;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageCase.Banned: {
|
||||
const {memberName, reason} = this._getKickOrBanInfo();
|
||||
title = _t("You were banned from %(roomName)s by %(memberName)s",
|
||||
{memberName, roomName: this._roomName()});
|
||||
subTitle = _t("Reason: %(reason)s", {reason});
|
||||
primaryActionLabel = _t("Forget this room");
|
||||
primaryActionHandler = this.props.onForgetClick;
|
||||
break;
|
||||
}
|
||||
case MessageCase.OtherThreePIDError: {
|
||||
title = _t("Something went wrong with your invite to %(roomName)s",
|
||||
{roomName: this._roomName()});
|
||||
const joinRule = this._joinRule();
|
||||
const errCodeMessage = _t("%(errcode)s was returned while trying to valide your invite. You could try to pass this information on to a room admin.",
|
||||
{errcode: this.state.threePidFetchError.errcode},
|
||||
);
|
||||
switch (joinRule) {
|
||||
case "invite":
|
||||
subTitle = [
|
||||
_t("You can only join it with a working invite."),
|
||||
errCodeMessage,
|
||||
];
|
||||
break;
|
||||
case "public":
|
||||
subTitle = _t("You can still join it because this is a public room.");
|
||||
primaryActionLabel = _t("Join the discussion");
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
break;
|
||||
default:
|
||||
subTitle = errCodeMessage;
|
||||
primaryActionLabel = _t("Try to join anyway");
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageCase.InvitedEmailMismatch: {
|
||||
title = _t("This invite to %(roomName)s wasn't sent to your account",
|
||||
{roomName: this._roomName()});
|
||||
const joinRule = this._joinRule();
|
||||
if (joinRule === "public") {
|
||||
subTitle = _t("You can still join it because this is a public room.");
|
||||
primaryActionLabel = _t("Join the discussion");
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
} else {
|
||||
subTitle = _t(
|
||||
"Sign in with a different account, ask for another invite, or " +
|
||||
"add the e-mail address %(email)s to this account.",
|
||||
{email: this.props.invitedEmail},
|
||||
);
|
||||
if (joinRule !== "invite") {
|
||||
primaryActionLabel = _t("Try to join anyway");
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageCase.Invite: {
|
||||
const RoomAvatar = sdk.getComponent("views.avatars.RoomAvatar");
|
||||
const avatar = <RoomAvatar room={this.props.room} />;
|
||||
|
||||
const inviteMember = this._getInviteMember();
|
||||
let inviterElement;
|
||||
if (inviteMember) {
|
||||
inviterElement = <span>
|
||||
<span className="mx_RoomPreviewBar_inviter">
|
||||
{inviteMember.rawDisplayName}
|
||||
</span> ({inviteMember.userId})
|
||||
</span>;
|
||||
} else {
|
||||
inviterElement = (<span className="mx_RoomPreviewBar_inviter">{this.props.inviterName}</span>);
|
||||
}
|
||||
|
||||
const isDM = this._isDMInvite();
|
||||
if (isDM) {
|
||||
title = _t("Do you want to chat with %(user)s?",
|
||||
{ user: inviteMember.name });
|
||||
} else {
|
||||
title = _t("Do you want to join %(roomName)s?",
|
||||
{ roomName: this._roomName() });
|
||||
}
|
||||
subTitle = [
|
||||
avatar,
|
||||
_t("<userName/> invited you", {}, {userName: () => inviterElement}),
|
||||
];
|
||||
|
||||
primaryActionLabel = _t("Accept");
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
secondaryActionLabel = _t("Reject");
|
||||
secondaryActionHandler = this.props.onRejectClick;
|
||||
break;
|
||||
}
|
||||
case MessageCase.ViewingRoom: {
|
||||
if (this.props.canPreview) {
|
||||
title = _t("You're previewing %(roomName)s. Want to join it?",
|
||||
{roomName: this._roomName()});
|
||||
} else {
|
||||
title = _t("%(roomName)s can't be previewed. Do you want to join it?",
|
||||
{roomName: this._roomName(true)});
|
||||
}
|
||||
primaryActionLabel = _t("Join the discussion");
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
break;
|
||||
}
|
||||
case MessageCase.RoomNotFound: {
|
||||
title = _t("%(roomName)s does not exist.", {roomName: this._roomName(true)});
|
||||
subTitle = _t("This room doesn't exist. Are you sure you're at the right place?");
|
||||
break;
|
||||
}
|
||||
case MessageCase.OtherError: {
|
||||
title = _t("%(roomName)s is not accessible at this time.", {roomName: this._roomName(true)});
|
||||
subTitle = [
|
||||
_t("Try again later, or ask a room admin to check if you have access."),
|
||||
_t(
|
||||
"%(errcode)s was returned while trying to access the room. " +
|
||||
"If you think you're seeing this message in error, please " +
|
||||
"<issueLink>submit a bug report</issueLink>.",
|
||||
{ errcode: this.props.error.errcode },
|
||||
{ issueLink: label => <a href="https://github.com/vector-im/riot-web/issues/new/choose"
|
||||
target="_blank" rel="noopener">{ label }</a> },
|
||||
),
|
||||
];
|
||||
break;
|
||||
}
|
||||
return (<div className="mx_RoomPreviewBar">
|
||||
<p className="mx_RoomPreviewBar_spinnerIntro">{ spinnerIntro }</p>
|
||||
<Spinner />
|
||||
</div>);
|
||||
}
|
||||
|
||||
const myMember = this.props.room ?
|
||||
this.props.room.getMember(MatrixClientPeg.get().getUserId()) :
|
||||
null;
|
||||
const kicked = myMember && myMember.isKicked();
|
||||
const banned = myMember && myMember && myMember.membership == 'ban';
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
|
||||
if (this.props.inviterName) {
|
||||
let emailMatchBlock;
|
||||
if (this.props.invitedEmail) {
|
||||
if (this.state.threePidFetchError) {
|
||||
emailMatchBlock = <div className="error">
|
||||
{ _t("Unable to ascertain that the address this invite was sent to matches one associated with your account.") }
|
||||
</div>;
|
||||
} else if (this.state.invitedEmailMxid != MatrixClientPeg.get().credentials.userId) {
|
||||
emailMatchBlock =
|
||||
<div className="mx_RoomPreviewBar_warning">
|
||||
<div className="mx_RoomPreviewBar_warningIcon">
|
||||
<img src={require("../../../../res/img/warning.svg")} width="24" height="23" title= "/!\\" alt="/!\\" />
|
||||
</div>
|
||||
<div className="mx_RoomPreviewBar_warningText">
|
||||
{ _t("This invitation was sent to an email address which is not associated with this account:") }
|
||||
<b><span className="email">{ this.props.invitedEmail }</span></b>
|
||||
<br />
|
||||
{ _t("You may wish to login with a different account, or add this email to this account.") }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
joinBlock = (
|
||||
<div>
|
||||
<div className="mx_RoomPreviewBar_invite_text">
|
||||
{ _t('You have been invited to join this room by %(inviterName)s', {inviterName: this.props.inviterName}) }
|
||||
</div>
|
||||
<div className="mx_RoomPreviewBar_join_text">
|
||||
{ _t(
|
||||
'Would you like to <acceptText>accept</acceptText> or <declineText>decline</declineText> this invitation?',
|
||||
{},
|
||||
{
|
||||
'acceptText': (sub) => <a onClick={this.props.onJoinClick}>{ sub }</a>,
|
||||
'declineText': (sub) => <a onClick={this.props.onRejectClick}>{ sub }</a>,
|
||||
},
|
||||
) }
|
||||
</div>
|
||||
{ emailMatchBlock }
|
||||
</div>
|
||||
);
|
||||
} else if (kicked || banned) {
|
||||
const roomName = this._roomNameElement();
|
||||
const kickerMember = this.props.room.currentState.getMember(
|
||||
myMember.events.member.getSender(),
|
||||
);
|
||||
const kickerName = kickerMember ?
|
||||
kickerMember.name : myMember.events.member.getSender();
|
||||
let reason;
|
||||
if (myMember.events.member.getContent().reason) {
|
||||
reason = <div>{ _t("Reason: %(reasonText)s", {reasonText: myMember.events.member.getContent().reason}) }</div>;
|
||||
}
|
||||
let rejoinBlock;
|
||||
if (!banned) {
|
||||
rejoinBlock = <div><a onClick={this.props.onJoinClick}><b>{ _t("Rejoin") }</b></a></div>;
|
||||
let subTitleElements;
|
||||
if (subTitle) {
|
||||
if (!Array.isArray(subTitle)) {
|
||||
subTitle = [subTitle];
|
||||
}
|
||||
subTitleElements = subTitle.map((t, i) => <p key={`subTitle${i}`}>{t}</p>);
|
||||
}
|
||||
|
||||
let actionText;
|
||||
if (kicked) {
|
||||
if (roomName) {
|
||||
actionText = _t("You have been kicked from %(roomName)s by %(userName)s.", {roomName: roomName, userName: kickerName});
|
||||
} else {
|
||||
actionText = _t("You have been kicked from this room by %(userName)s.", {userName: kickerName});
|
||||
}
|
||||
} else if (banned) {
|
||||
if (roomName) {
|
||||
actionText = _t("You have been banned from %(roomName)s by %(userName)s.", {roomName: roomName, userName: kickerName});
|
||||
} else {
|
||||
actionText = _t("You have been banned from this room by %(userName)s.", {userName: kickerName});
|
||||
}
|
||||
} // no other options possible due to the kicked || banned check above.
|
||||
|
||||
joinBlock = (
|
||||
<div>
|
||||
<div className="mx_RoomPreviewBar_join_text">
|
||||
{ actionText }
|
||||
<br />
|
||||
{ reason }
|
||||
{ rejoinBlock }
|
||||
<a onClick={this.props.onForgetClick}><b>{ _t("Forget room") }</b></a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (this.props.error) {
|
||||
const name = this.props.roomAlias || _t("This room");
|
||||
let error;
|
||||
if (this.props.error.errcode == 'M_NOT_FOUND') {
|
||||
error = _t("%(roomName)s does not exist.", {roomName: name});
|
||||
} else {
|
||||
error = _t("%(roomName)s is not accessible at this time.", {roomName: name});
|
||||
}
|
||||
joinBlock = (
|
||||
<div>
|
||||
<div className="mx_RoomPreviewBar_join_text">
|
||||
{ error }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
let titleElement;
|
||||
if (showSpinner) {
|
||||
titleElement = <h3 className="mx_RoomPreviewBar_spinnerTitle"><Spinner />{ title }</h3>;
|
||||
} else {
|
||||
const name = this._roomNameElement();
|
||||
joinBlock = (
|
||||
<div>
|
||||
<div className="mx_RoomPreviewBar_join_text">
|
||||
{ name ? _t('You are trying to access %(roomName)s.', {roomName: name}) : _t('You are trying to access a room.') }
|
||||
<br />
|
||||
{ _t("<a>Click here</a> to join the discussion!",
|
||||
{},
|
||||
{ 'a': (sub) => <a onClick={this.props.onJoinClick}><b>{ sub }</b></a> },
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
titleElement = <h3>{ title }</h3>;
|
||||
}
|
||||
|
||||
let primaryButton;
|
||||
if (primaryActionHandler) {
|
||||
primaryButton = (
|
||||
<AccessibleButton kind="primary" onClick={primaryActionHandler}>
|
||||
{ primaryActionLabel }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.canPreview) {
|
||||
previewBlock = (
|
||||
<div className="mx_RoomPreviewBar_preview_text">
|
||||
{ _t('This is a preview of this room. Room interactions have been disabled') }.
|
||||
</div>
|
||||
let secondaryButton;
|
||||
if (secondaryActionHandler) {
|
||||
secondaryButton = (
|
||||
<AccessibleButton kind="secondary" onClick={secondaryActionHandler}>
|
||||
{ secondaryActionLabel }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
const classes = classNames("mx_RoomPreviewBar", "dark-panel", `mx_RoomPreviewBar_${messageCase}`, {
|
||||
"mx_RoomPreviewBar_panel": this.props.canPreview,
|
||||
"mx_RoomPreviewBar_dialog": !this.props.canPreview,
|
||||
"mx_RoomPreviewBar_dark": darkStyle,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx_RoomPreviewBar">
|
||||
<div className="mx_RoomPreviewBar_wrapper">
|
||||
{ joinBlock }
|
||||
{ previewBlock }
|
||||
<div className={classes}>
|
||||
<div className="mx_RoomPreviewBar_message">
|
||||
{ titleElement }
|
||||
{ subTitleElements }
|
||||
</div>
|
||||
<div className="mx_RoomPreviewBar_actions">
|
||||
{ secondaryButton }
|
||||
{ primaryButton }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -119,7 +119,7 @@ export default class RoomRecoveryReminder extends React.PureComponent {
|
||||
|
||||
let setupCaption;
|
||||
if (this.state.backupInfo) {
|
||||
setupCaption = _t("Use Key Backup");
|
||||
setupCaption = _t("Connect this device to Key Backup");
|
||||
} else {
|
||||
setupCaption = _t("Start using Key Backup");
|
||||
}
|
||||
|
||||
@@ -68,12 +68,11 @@ module.exports = React.createClass({
|
||||
},
|
||||
|
||||
_shouldShowNotifBadge: function() {
|
||||
const showBadgeInStates = [RoomNotifs.ALL_MESSAGES, RoomNotifs.ALL_MESSAGES_LOUD];
|
||||
return showBadgeInStates.indexOf(this.state.notifState) > -1;
|
||||
return RoomNotifs.BADGE_STATES.includes(this.state.notifState);
|
||||
},
|
||||
|
||||
_shouldShowMentionBadge: function() {
|
||||
return this.state.notifState !== RoomNotifs.MUTE;
|
||||
return RoomNotifs.MENTION_BADGE_STATES.includes(this.state.notifState);
|
||||
},
|
||||
|
||||
_isDirectMessageRoom: function(roomId) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import sdk from '../../../index';
|
||||
import Modal from '../../../Modal';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import MatrixClientPeg from "../../../MatrixClientPeg";
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'RoomUpgradeWarningBar',
|
||||
@@ -29,6 +30,24 @@ module.exports = React.createClass({
|
||||
recommendation: PropTypes.object.isRequired,
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
|
||||
this.setState({upgraded: tombstone && tombstone.getContent().replacement_room});
|
||||
|
||||
MatrixClientPeg.get().on("RoomState.events", this._onStateEvents);
|
||||
},
|
||||
|
||||
_onStateEvents: function(event, state) {
|
||||
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getType() !== "m.room.tombstone") return;
|
||||
|
||||
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
|
||||
this.setState({upgraded: tombstone && tombstone.getContent().replacement_room});
|
||||
},
|
||||
|
||||
onUpgradeClick: function() {
|
||||
const RoomUpgradeDialog = sdk.getComponent('dialogs.RoomUpgradeDialog');
|
||||
Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, {room: this.props.room});
|
||||
@@ -37,33 +56,40 @@ module.exports = React.createClass({
|
||||
render: function() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let upgradeText = (
|
||||
let doUpgradeWarnings = (
|
||||
<div>
|
||||
<div className="mx_RoomUpgradeWarningBar_body">
|
||||
{_t("This room is using an unstable room version. If you aren't expecting " +
|
||||
"this, please upgrade the room.")}
|
||||
<p>
|
||||
{_t(
|
||||
"Upgrading this room will shut down the current instance of the room and create " +
|
||||
"an upgraded room with the same name.",
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{_t(
|
||||
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members " +
|
||||
"to the new version of the room.</i> We'll post a link to the new room in the old " +
|
||||
"version of the room - room members will have to click this link to join the new room.",
|
||||
{}, {
|
||||
"b": (sub) => <b>{sub}</b>,
|
||||
"i": (sub) => <i>{sub}</i>,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mx_RoomUpgradeWarningBar_upgradelink">
|
||||
<AccessibleButton onClick={this.onUpgradeClick}>
|
||||
{_t("Click here to upgrade to the latest room version.")}
|
||||
{_t("Upgrade this room to the recommended room version")}
|
||||
</AccessibleButton>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
if (this.props.recommendation.urgent) {
|
||||
upgradeText = (
|
||||
<div>
|
||||
<div className="mx_RoomUpgradeWarningBar_header">
|
||||
{_t("There is a known vulnerability affecting this room.")}
|
||||
</div>
|
||||
<div className="mx_RoomUpgradeWarningBar_body">
|
||||
{_t("This room version is vulnerable to malicious modification of room state.")}
|
||||
</div>
|
||||
<p className="mx_RoomUpgradeWarningBar_upgradelink">
|
||||
<AccessibleButton onClick={this.onUpgradeClick}>
|
||||
{_t("Click here to upgrade to the latest room version and ensure room integrity " +
|
||||
"is protected.")}
|
||||
</AccessibleButton>
|
||||
|
||||
if (this.state.upgraded) {
|
||||
doUpgradeWarnings = (
|
||||
<div className="mx_RoomUpgradeWarningBar_body">
|
||||
<p>
|
||||
{_t("This room has already been upgraded.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -71,7 +97,18 @@ module.exports = React.createClass({
|
||||
|
||||
return (
|
||||
<div className="mx_RoomUpgradeWarningBar">
|
||||
{upgradeText}
|
||||
<div className="mx_RoomUpgradeWarningBar_header">
|
||||
{_t(
|
||||
"This room is running room version <roomVersion />, which this homeserver has " +
|
||||
"marked as <i>unstable</i>.",
|
||||
{},
|
||||
{
|
||||
"roomVersion": () => <code>{this.props.room.getVersion()}</code>,
|
||||
"i": (sub) => <i>{sub}</i>,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
{doUpgradeWarnings}
|
||||
<div className="mx_RoomUpgradeWarningBar_small">
|
||||
{_t("Only room administrators will see this warning")}
|
||||
</div>
|
||||
|
||||
@@ -25,14 +25,20 @@ import dis from '../../../dispatcher';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import WidgetUtils from '../../../utils/WidgetUtils';
|
||||
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
|
||||
import PersistedElement from "../elements/PersistedElement";
|
||||
|
||||
const widgetType = 'm.stickerpicker';
|
||||
|
||||
// We sit in a context menu, so the persisted element container needs to float
|
||||
// above it, so it needs a greater z-index than the ContextMenu
|
||||
const STICKERPICKER_Z_INDEX = 5000;
|
||||
// This should be below the dialog level (4000), but above the rest of the UI (1000-2000).
|
||||
// We sit in a context menu, so this should be given to the context menu.
|
||||
const STICKERPICKER_Z_INDEX = 3500;
|
||||
|
||||
// Key to store the widget's AppTile under in PersistedElement
|
||||
const PERSISTED_ELEMENT_KEY = "stickerPicker";
|
||||
|
||||
export default class Stickerpicker extends React.Component {
|
||||
static currentWidget;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._onShowStickersClick = this._onShowStickersClick.bind(this);
|
||||
@@ -126,6 +132,29 @@ export default class Stickerpicker extends React.Component {
|
||||
|
||||
_updateWidget() {
|
||||
const stickerpickerWidget = WidgetUtils.getStickerpickerWidgets()[0];
|
||||
if (!stickerpickerWidget) {
|
||||
Stickerpicker.currentWidget = null;
|
||||
this.setState({stickerpickerWidget: null, widgetId: null});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentWidget = Stickerpicker.currentWidget;
|
||||
let currentUrl = null;
|
||||
if (currentWidget && currentWidget.content && currentWidget.content.url) {
|
||||
currentUrl = currentWidget.content.url;
|
||||
}
|
||||
|
||||
let newUrl = null;
|
||||
if (stickerpickerWidget && stickerpickerWidget.content && stickerpickerWidget.content.url) {
|
||||
newUrl = stickerpickerWidget.content.url;
|
||||
}
|
||||
|
||||
if (newUrl !== currentUrl) {
|
||||
// Destroy the existing frame so a new one can be created
|
||||
PersistedElement.destroyElement(PERSISTED_ELEMENT_KEY);
|
||||
}
|
||||
|
||||
Stickerpicker.currentWidget = stickerpickerWidget;
|
||||
this.setState({
|
||||
stickerpickerWidget,
|
||||
widgetId: stickerpickerWidget ? stickerpickerWidget.id : null,
|
||||
@@ -211,7 +240,7 @@ export default class Stickerpicker extends React.Component {
|
||||
width: this.popoverWidth,
|
||||
}}
|
||||
>
|
||||
<PersistedElement persistKey="stickerPicker" style={{zIndex: STICKERPICKER_Z_INDEX}}>
|
||||
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} style={{zIndex: STICKERPICKER_Z_INDEX}}>
|
||||
<AppTile
|
||||
id={stickerpickerWidget.id}
|
||||
url={stickerpickerWidget.content.url}
|
||||
@@ -346,6 +375,7 @@ export default class Stickerpicker extends React.Component {
|
||||
menuPaddingTop={0}
|
||||
menuPaddingLeft={0}
|
||||
menuPaddingRight={0}
|
||||
zIndex={STICKERPICKER_Z_INDEX}
|
||||
/>;
|
||||
|
||||
if (this.state.showStickers) {
|
||||
|
||||
143
src/components/views/rooms/ThirdPartyMemberInfo.js
Normal file
143
src/components/views/rooms/ThirdPartyMemberInfo.js
Normal file
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
Copyright 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 PropTypes from 'prop-types';
|
||||
import MatrixClientPeg from "../../../MatrixClientPeg";
|
||||
import {MatrixEvent} from "matrix-js-sdk";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import dis from "../../../dispatcher";
|
||||
import sdk from "../../../index";
|
||||
import Modal from "../../../Modal";
|
||||
import {isValid3pidInvite} from "../../../RoomInvite";
|
||||
|
||||
export default class ThirdPartyMemberInfo extends React.Component {
|
||||
static propTypes = {
|
||||
event: PropTypes.instanceOf(MatrixEvent).isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const room = MatrixClientPeg.get().getRoom(this.props.event.getRoomId());
|
||||
const me = room.getMember(MatrixClientPeg.get().getUserId());
|
||||
const powerLevels = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
|
||||
let kickLevel = powerLevels ? powerLevels.getContent().kick : 50;
|
||||
if (typeof(kickLevel) !== 'number') kickLevel = 50;
|
||||
|
||||
const sender = room.getMember(this.props.event.getSender());
|
||||
|
||||
this.state = {
|
||||
stateKey: this.props.event.getStateKey(),
|
||||
roomId: this.props.event.getRoomId(),
|
||||
displayName: this.props.event.getContent().display_name,
|
||||
invited: true,
|
||||
canKick: me ? me.powerLevel > kickLevel : false,
|
||||
senderName: sender ? sender.name : this.props.event.getSender(),
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount(): void {
|
||||
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (client) {
|
||||
client.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
}
|
||||
}
|
||||
|
||||
onRoomStateEvents = (ev) => {
|
||||
if (ev.getType() === "m.room.third_party_invite" && ev.getStateKey() === this.state.stateKey) {
|
||||
const newDisplayName = ev.getContent().display_name;
|
||||
const isInvited = isValid3pidInvite(ev);
|
||||
|
||||
const newState = {invited: isInvited};
|
||||
if (newDisplayName) newState['displayName'] = newDisplayName;
|
||||
this.setState(newState);
|
||||
}
|
||||
};
|
||||
|
||||
onCancel = () => {
|
||||
dis.dispatch({
|
||||
action: "view_3pid_invite",
|
||||
event: null,
|
||||
});
|
||||
};
|
||||
|
||||
onKickClick = () => {
|
||||
MatrixClientPeg.get().sendStateEvent(this.state.roomId, "m.room.third_party_invite", {}, this.state.stateKey)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
// Revert echo because of error
|
||||
this.setState({invited: true});
|
||||
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Revoke 3pid invite failed', '', ErrorDialog, {
|
||||
title: _t("Failed to revoke invite"),
|
||||
description: _t(
|
||||
"Could not revoke the invite. The server may be experiencing a temporary problem or " +
|
||||
"you do not have sufficient permissions to revoke the invite.",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
// Local echo
|
||||
this.setState({invited: false});
|
||||
};
|
||||
|
||||
render() {
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
|
||||
let adminTools = null;
|
||||
if (this.state.canKick && this.state.invited) {
|
||||
adminTools = (
|
||||
<div className="mx_MemberInfo_container">
|
||||
<h3>{_t("Admin Tools")}</h3>
|
||||
<div className="mx_MemberInfo_buttons">
|
||||
<AccessibleButton className="mx_MemberInfo_field" onClick={this.onKickClick}>
|
||||
{_t("Revoke invite")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// We shamelessly rip off the MemberInfo styles here.
|
||||
return (
|
||||
<div className="mx_MemberInfo">
|
||||
<div className="mx_MemberInfo_name">
|
||||
<AccessibleButton className="mx_MemberInfo_cancel"
|
||||
onClick={this.onCancel}
|
||||
title={_t('Close')}
|
||||
/>
|
||||
<h2>{this.state.displayName}</h2>
|
||||
</div>
|
||||
<div className="mx_MemberInfo_container">
|
||||
<div className="mx_MemberInfo_profile">
|
||||
<div className="mx_MemberInfo_profileField">
|
||||
{_t("Invited by %(sender)s", {sender: this.state.senderName})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{adminTools}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@ module.exports = React.createClass({
|
||||
propTypes: {
|
||||
// the room this statusbar is representing.
|
||||
room: PropTypes.object.isRequired,
|
||||
onVisible: PropTypes.func,
|
||||
onShown: PropTypes.func,
|
||||
onHidden: 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,
|
||||
@@ -59,11 +60,12 @@ module.exports = React.createClass({
|
||||
},
|
||||
|
||||
componentDidUpdate: function(_, prevState) {
|
||||
if (this.props.onVisible &&
|
||||
!prevState.usersTyping.length &&
|
||||
this.state.usersTyping.length
|
||||
) {
|
||||
this.props.onVisible();
|
||||
const wasVisible = this._isVisible(prevState);
|
||||
const isVisible = this._isVisible(this.state);
|
||||
if (this.props.onShown && !wasVisible && isVisible) {
|
||||
this.props.onShown();
|
||||
} else if (this.props.onHidden && wasVisible && !isVisible) {
|
||||
this.props.onHidden();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -77,8 +79,12 @@ module.exports = React.createClass({
|
||||
Object.values(this.state.delayedStopTypingTimers).forEach((t) => t.abort());
|
||||
},
|
||||
|
||||
_isVisible: function(state) {
|
||||
return state.usersTyping.length !== 0 || Object.keys(state.delayedStopTypingTimers).length !== 0;
|
||||
},
|
||||
|
||||
isVisible: function() {
|
||||
return this.state.usersTyping.length !== 0 || Object.keys(this.state.delayedStopTypingTimers).length !== 0;
|
||||
return this._isVisible(this.state);
|
||||
},
|
||||
|
||||
onRoomTimeline: function(event, room) {
|
||||
|
||||
@@ -187,12 +187,17 @@ export default class KeyBackupPanel extends React.PureComponent {
|
||||
clientBackupStatus = <div>
|
||||
<p>{encryptedMessageAreEncrypted}</p>
|
||||
<p>{_t(
|
||||
"This device is <b>not backing up your keys</b>.", {},
|
||||
"This device is <b>not backing up your keys</b>, " +
|
||||
"but you do have an existing backup you can restore from " +
|
||||
"and add to going forward.", {},
|
||||
{b: sub => <b>{sub}</b>},
|
||||
)}</p>
|
||||
<p>{_t("Back up your keys before signing out to avoid losing them.")}</p>
|
||||
<p>{_t(
|
||||
"Connect this device to key backup before signing out to avoid " +
|
||||
"losing any keys that may only be on this device.",
|
||||
)}</p>
|
||||
</div>;
|
||||
restoreButtonCaption = _t("Use key backup");
|
||||
restoreButtonCaption = _t("Connect this device to Key Backup");
|
||||
}
|
||||
|
||||
let uploadStatus;
|
||||
@@ -221,17 +226,27 @@ export default class KeyBackupPanel extends React.PureComponent {
|
||||
{sub}
|
||||
</span>;
|
||||
const device = sub => <span className="mx_KeyBackupPanel_deviceName">{deviceName}</span>;
|
||||
const fromThisDevice = (
|
||||
sig.device &&
|
||||
sig.device.getFingerprint() === MatrixClientPeg.get().getDeviceEd25519Key()
|
||||
);
|
||||
let sigStatus;
|
||||
if (!sig.device) {
|
||||
sigStatus = _t(
|
||||
"Backup has a signature from <verify>unknown</verify> device with ID %(deviceId)s.",
|
||||
{ deviceId: sig.deviceId }, { verify },
|
||||
);
|
||||
} else if (sig.device.getFingerprint() === MatrixClientPeg.get().getDeviceEd25519Key()) {
|
||||
} else if (sig.valid && fromThisDevice) {
|
||||
sigStatus = _t(
|
||||
"Backup has a <validity>valid</validity> signature from this device",
|
||||
{}, { validity },
|
||||
);
|
||||
} else if (!sig.valid && fromThisDevice) {
|
||||
// it can happen...
|
||||
sigStatus = _t(
|
||||
"Backup has an <validity>invalid</validity> signature from this device",
|
||||
{}, { validity },
|
||||
);
|
||||
} else if (sig.valid && sig.device.isVerified()) {
|
||||
sigStatus = _t(
|
||||
"Backup has a <validity>valid</validity> signature from " +
|
||||
|
||||
@@ -507,6 +507,7 @@ module.exports = React.createClass({
|
||||
//'.m.rule.member_event': 'vector',
|
||||
'.m.rule.call': 'vector',
|
||||
'.m.rule.suppress_notices': 'vector',
|
||||
'.m.rule.tombstone': 'vector',
|
||||
|
||||
// Others go to others
|
||||
};
|
||||
@@ -562,6 +563,7 @@ module.exports = React.createClass({
|
||||
//'im.vector.rule.member_event',
|
||||
'.m.rule.call',
|
||||
'.m.rule.suppress_notices',
|
||||
'.m.rule.tombstone',
|
||||
];
|
||||
for (const i in vectorRuleIds) {
|
||||
const vectorRuleId = vectorRuleIds[i];
|
||||
@@ -702,6 +704,10 @@ module.exports = React.createClass({
|
||||
const rows = [];
|
||||
for (const i in this.state.vectorPushRules) {
|
||||
const rule = this.state.vectorPushRules[i];
|
||||
if (rule.rule === undefined && rule.vectorRuleId.startsWith(".m.")) {
|
||||
console.warn(`Skipping render of rule ${rule.vectorRuleId} due to no underlying rule`);
|
||||
continue;
|
||||
}
|
||||
//console.log("rendering: " + rule.description + ", " + rule.vectorRuleId + ", " + rule.vectorState);
|
||||
rows.push(this.renderNotifRulesTableRow(rule.description, rule.vectorRuleId, rule.vectorState));
|
||||
}
|
||||
@@ -837,7 +843,7 @@ module.exports = React.createClass({
|
||||
|
||||
<LabelledToggleSwitch value={SettingsStore.getValue("notificationsEnabled")}
|
||||
onChange={this.onEnableDesktopNotificationsChange}
|
||||
label={_t('Enable desktop notifications')} />
|
||||
label={_t('Enable desktop notifications for this device')} />
|
||||
|
||||
<LabelledToggleSwitch value={SettingsStore.getValue("notificationBodyEnabled")}
|
||||
onChange={this.onEnableDesktopNotificationBodyChange}
|
||||
@@ -845,7 +851,7 @@ module.exports = React.createClass({
|
||||
|
||||
<LabelledToggleSwitch value={SettingsStore.getValue("audioNotificationsEnabled")}
|
||||
onChange={this.onEnableAudioNotificationsChange}
|
||||
label={_t('Enable audible notifications in web client')} />
|
||||
label={_t('Enable audible notifications for this device')} />
|
||||
|
||||
{ emailNotificationsRows }
|
||||
|
||||
|
||||
@@ -21,10 +21,12 @@ import MatrixClientPeg from "../../../../../MatrixClientPeg";
|
||||
import sdk from "../../../../..";
|
||||
import AccessibleButton from "../../../elements/AccessibleButton";
|
||||
import Modal from "../../../../../Modal";
|
||||
import dis from "../../../../../dispatcher";
|
||||
|
||||
export default class AdvancedRoomSettingsTab extends React.Component {
|
||||
static propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
closeSettingsFn: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -38,8 +40,25 @@ export default class AdvancedRoomSettingsTab extends React.Component {
|
||||
|
||||
componentWillMount() {
|
||||
// we handle lack of this object gracefully later, so don't worry about it failing here.
|
||||
MatrixClientPeg.get().getRoom(this.props.roomId).getRecommendedVersion().then((v) => {
|
||||
this.setState({upgradeRecommendation: v});
|
||||
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
|
||||
room.getRecommendedVersion().then((v) => {
|
||||
const tombstone = room.currentState.getStateEvents("m.room.tombstone", "");
|
||||
|
||||
const additionalStateChanges = {};
|
||||
const createEvent = room.currentState.getStateEvents("m.room.create", "");
|
||||
const predecessor = createEvent ? createEvent.getContent().predecessor : null;
|
||||
if (predecessor && predecessor.room_id) {
|
||||
additionalStateChanges['oldRoomId'] = predecessor.room_id;
|
||||
additionalStateChanges['oldEventId'] = predecessor.event_id;
|
||||
additionalStateChanges['hasPreviousRoom'] = true;
|
||||
}
|
||||
|
||||
|
||||
this.setState({
|
||||
upgraded: tombstone && tombstone.getContent().replacement_room,
|
||||
upgradeRecommendation: v,
|
||||
...additionalStateChanges,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,6 +73,18 @@ export default class AdvancedRoomSettingsTab extends React.Component {
|
||||
Modal.createDialog(DevtoolsDialog, {roomId: this.props.roomId});
|
||||
};
|
||||
|
||||
_onOldRoomClicked = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: this.state.oldRoomId,
|
||||
event_id: this.state.oldEventId,
|
||||
});
|
||||
this.props.closeSettingsFn();
|
||||
};
|
||||
|
||||
render() {
|
||||
const client = MatrixClientPeg.get();
|
||||
const room = client.getRoom(this.props.roomId);
|
||||
@@ -65,10 +96,35 @@ export default class AdvancedRoomSettingsTab extends React.Component {
|
||||
}
|
||||
|
||||
let roomUpgradeButton;
|
||||
if (this.state.upgradeRecommendation && this.state.upgradeRecommendation.needsUpgrade) {
|
||||
if (this.state.upgradeRecommendation && this.state.upgradeRecommendation.needsUpgrade && !this.state.upgraded) {
|
||||
roomUpgradeButton = (
|
||||
<AccessibleButton onClick={this._upgradeRoom} kind='primary'>
|
||||
{_t("Upgrade room to version %(ver)s", {ver: this.state.upgradeRecommendation.version})}
|
||||
<div>
|
||||
<p className='mx_SettingsTab_warningText'>
|
||||
{_t(
|
||||
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members " +
|
||||
"to the new version of the room.</i> We'll post a link to the new room in the old " +
|
||||
"version of the room - room members will have to click this link to join the new room.",
|
||||
{}, {
|
||||
"b": (sub) => <b>{sub}</b>,
|
||||
"i": (sub) => <i>{sub}</i>,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<AccessibleButton onClick={this._upgradeRoom} kind='primary'>
|
||||
{_t("Upgrade this room to the recommended room version")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let oldRoomLink;
|
||||
if (this.state.hasPreviousRoom) {
|
||||
let name = _t("this room");
|
||||
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
|
||||
if (room && room.name) name = room.name;
|
||||
oldRoomLink = (
|
||||
<AccessibleButton element='a' onClick={this._onOldRoomClicked}>
|
||||
{_t("View older messages in %(roomName)s.", {roomName: name})}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +146,7 @@ export default class AdvancedRoomSettingsTab extends React.Component {
|
||||
<span>{_t("Room version:")}</span>
|
||||
{room.getVersion()}
|
||||
</div>
|
||||
{oldRoomLink}
|
||||
{roomUpgradeButton}
|
||||
</div>
|
||||
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
|
||||
|
||||
@@ -197,6 +197,10 @@ export default class SecurityRoomSettingsTab extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
_updateBlacklistDevicesFlag = (checked) => {
|
||||
MatrixClientPeg.get().getRoom(this.props.roomId).setBlacklistUnverifiedDevices(checked);
|
||||
};
|
||||
|
||||
_renderRoomAccess() {
|
||||
const client = MatrixClientPeg.get();
|
||||
const room = client.getRoom(this.props.roomId);
|
||||
@@ -318,6 +322,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
|
||||
let encryptionSettings = null;
|
||||
if (isEncrypted) {
|
||||
encryptionSettings = <SettingsFlag name="blacklistUnverifiedDevices" level={SettingLevel.ROOM_DEVICE}
|
||||
onChange={this._updateBlacklistDevicesFlag}
|
||||
roomId={this.props.roomId} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
<li>
|
||||
The <a href="themes/riot/img/backgrounds/valley.jpg" rel="noopener" target="_blank">
|
||||
default cover photo</a> is (C)
|
||||
<a href="https://www.flickr.com/golan" rel="noopener" target="_blank">Jesús Roncero</a>
|
||||
<a href="https://www.flickr.com/golan" rel="noopener" target="_blank">Jesús Roncero</a>{' '}
|
||||
used under the terms of
|
||||
<a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="noopener" target="_blank">
|
||||
CC-BY-SA 4.0</a>. No warranties are given.
|
||||
@@ -235,8 +235,8 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
|
||||
<span className='mx_SettingsTab_subheading'>{_t("Advanced")}</span>
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
{_t("Homeserver is")} {MatrixClientPeg.get().getHomeserverUrl()}<br />
|
||||
{_t("Identity Server is")} {MatrixClientPeg.get().getIdentityServerUrl()}<br />
|
||||
{_t("Homeserver is")} <code>{MatrixClientPeg.get().getHomeserverUrl()}</code><br />
|
||||
{_t("Identity Server is")} <code>{MatrixClientPeg.get().getIdentityServerUrl()}</code><br />
|
||||
{_t("Access Token:") + ' '}
|
||||
<AccessibleButton element="span" onClick={this._showSpoiler}
|
||||
data-spoiler={MatrixClientPeg.get().getAccessToken()}>
|
||||
|
||||
Reference in New Issue
Block a user