Merge branch 'develop' into travis/encryption-warning

This commit is contained in:
Travis Ralston 2019-03-04 23:14:30 -07:00
commit 879fa22416
31 changed files with 486 additions and 296 deletions

View File

@ -18,7 +18,6 @@ src/components/views/create_room/RoomAlias.js
src/components/views/dialogs/DeactivateAccountDialog.js
src/components/views/dialogs/SetPasswordDialog.js
src/components/views/dialogs/UnknownDeviceDialog.js
src/components/views/directory/NetworkDropdown.js
src/components/views/elements/AddressSelector.js
src/components/views/elements/DirectorySearchBox.js
src/components/views/elements/ImageView.js

View File

@ -129,7 +129,7 @@
"file-loader": "^3.0.1",
"flow-parser": "^0.57.3",
"jest-mock": "^23.2.0",
"karma": "^3.0.0",
"karma": "^3.1.2",
"karma-chrome-launcher": "^0.2.3",
"karma-cli": "^1.0.1",
"karma-junit-reporter": "^0.4.2",

View File

@ -228,6 +228,17 @@ textarea {
color: $roomsublist-label-bg-color;
}
/* Expected z-indexes for dialogs:
4000 - Default wrapper index
4009 - Static dialog background
4010 - Static dialog itself
4011 - Standard dialog background
4012 - Standard dialog itself
These are set up such that the static dialog always appears
underneath the standard dialogs.
*/
.mx_Dialog_wrapper {
position: fixed;
z-index: 4000;
@ -252,7 +263,7 @@ textarea {
.mx_Dialog {
background-color: $primary-bg-color;
color: $light-fg-color;
z-index: 4010;
z-index: 4012;
font-weight: 300;
font-size: 15px;
position: relative;
@ -264,6 +275,10 @@ textarea {
overflow-y: auto;
}
.mx_Dialog_staticWrapper .mx_Dialog {
z-index: 4010;
}
.mx_Dialog_background {
position: fixed;
top: 0;
@ -272,6 +287,17 @@ textarea {
height: 100%;
background-color: $dialog-backdrop-color;
opacity: 0.8;
z-index: 4011;
}
.mx_Dialog_background.mx_Dialog_staticBackground {
z-index: 4009;
}
.mx_Dialog_wrapperWithStaticUnder .mx_Dialog_background {
// Roughly half of what it would normally be - we don't want to black out
// the app, just make it clear that the dialogs are stacked.
opacity: 0.4;
}
.mx_Dialog_lightbox .mx_Dialog_background {

View File

@ -85,6 +85,7 @@
@import "./views/elements/_InlineSpinner.scss";
@import "./views/elements/_ManageIntegsButton.scss";
@import "./views/elements/_MemberEventListSummary.scss";
@import "./views/elements/_PowerSelector.scss";
@import "./views/elements/_ProgressBar.scss";
@import "./views/elements/_ReplyThread.scss";
@import "./views/elements/_ResizeHandle.scss";

View File

@ -0,0 +1,25 @@
/*
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.
*/
.mx_PowerSelector {
width: 100%;
}
.mx_PowerSelector .mx_Field select,
.mx_PowerSelector .mx_Field input {
width: 100%;
box-sizing: border-box;
}

View File

@ -24,9 +24,14 @@ limitations under the License.
.mx_CreateEvent_image {
float: left;
padding-right: 20px;
margin-right: 20px;
width: 72px;
height: 34px;
background-color: $primary-fg-color;
mask: url('$(res)/img/room-continuation.svg');
mask-repeat: no-repeat;
mask-position: center;
}
.mx_CreateEvent_header {

View File

@ -27,7 +27,7 @@ limitations under the License.
}
.mx_MemberInfo_name > .mx_E2EIcon {
margin-left: 0;
margin-right: 0;
}
.mx_MemberInfo_cancel {

View File

@ -7,4 +7,4 @@
set -ev
scripts/travis/build.sh
npm run test
CHROME_BIN='/usr/bin/google-chrome-stable' npm run test

View File

@ -26,6 +26,7 @@ import dis from './dispatcher';
import { _t } from './languageHandler';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
/**
* Wrap an asynchronous loader function with a react component which shows a
@ -106,7 +107,12 @@ class ModalManager {
// this modal. Remove all other modals from the stack when this modal
// is closed.
this._priorityModal = null;
// The modal to keep open underneath other modals if possible. Useful
// for cases like Settings where the modal should remain open while the
// user is prompted for more information/errors.
this._staticModal = null;
// A list of the modals we have stacked up, with the most recent at [0]
// Neither the static nor priority modal will be in this list.
this._modals = [
/* {
elem: React component for this dialog
@ -130,6 +136,18 @@ class ModalManager {
return container;
}
getOrCreateStaticContainer() {
let container = document.getElementById(STATIC_DIALOG_CONTAINER_ID);
if (!container) {
container = document.createElement("div");
container.id = STATIC_DIALOG_CONTAINER_ID;
document.body.appendChild(container);
}
return container;
}
createTrackedDialog(analyticsAction, analyticsInfo, ...rest) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialog(...rest);
@ -166,8 +184,13 @@ class ModalManager {
* of other modals that are currently in the stack.
* Also, when closed, all modals will be removed
* from the stack.
* @param {boolean} isStaticModal if true, this modal will be displayed under other
* modals in the stack. When closed, all modals will
* also be removed from the stack. This is not compatible
* with being a priority modal. Only one modal can be
* static at a time.
*/
createDialogAsync(prom, props, className, isPriorityModal) {
createDialogAsync(prom, props, className, isPriorityModal, isStaticModal) {
const self = this;
const modal = {};
@ -188,6 +211,13 @@ class ModalManager {
self._modals = [];
}
if (self._staticModal === modal) {
self._staticModal = null;
// XXX: This is destructive
self._modals = [];
}
self._reRender();
};
@ -207,6 +237,9 @@ class ModalManager {
if (isPriorityModal) {
// XXX: This is destructive
this._priorityModal = modal;
} else if (isStaticModal) {
// This is intentionally destructive
this._staticModal = modal;
} else {
this._modals.unshift(modal);
}
@ -216,12 +249,18 @@ class ModalManager {
}
closeAll() {
const modals = this._modals;
const modalsToClose = [...this._modals, this._priorityModal];
this._modals = [];
this._priorityModal = null;
for (let i = 0; i < modals.length; i++) {
const m = modals[i];
if (m.onFinished) {
if (this._staticModal && modalsToClose.length === 0) {
modalsToClose.push(this._staticModal);
this._staticModal = null;
}
for (let i = 0; i < modalsToClose.length; i++) {
const m = modalsToClose[i];
if (m && m.onFinished) {
m.onFinished(false);
}
}
@ -230,13 +269,14 @@ class ModalManager {
}
_reRender() {
if (this._modals.length == 0 && !this._priorityModal) {
if (this._modals.length === 0 && !this._priorityModal && !this._staticModal) {
// If there is no modal to render, make all of Riot available
// to screen reader users again
dis.dispatch({
action: 'aria_unhide_main_app',
});
ReactDOM.unmountComponentAtNode(this.getOrCreateContainer());
ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer());
return;
}
@ -247,17 +287,45 @@ class ModalManager {
action: 'aria_hide_main_app',
});
const modal = this._priorityModal ? this._priorityModal : this._modals[0];
const dialog = (
<div className={"mx_Dialog_wrapper " + (modal.className ? modal.className : '')}>
<div className="mx_Dialog">
{ modal.elem }
</div>
<div className="mx_Dialog_background" onClick={this.closeAll}></div>
</div>
);
if (this._staticModal) {
const classes = "mx_Dialog_wrapper mx_Dialog_staticWrapper "
+ (this._staticModal.className ? this._staticModal.className : '');
ReactDOM.render(dialog, this.getOrCreateContainer());
const staticDialog = (
<div className={classes}>
<div className="mx_Dialog">
{ this._staticModal.elem }
</div>
<div className="mx_Dialog_background mx_Dialog_staticBackground" onClick={this.closeAll}></div>
</div>
);
ReactDOM.render(staticDialog, this.getOrCreateStaticContainer());
} else {
// This is safe to call repeatedly if we happen to do that
ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer());
}
const modal = this._priorityModal ? this._priorityModal : this._modals[0];
if (modal) {
const classes = "mx_Dialog_wrapper "
+ (this._staticModal ? "mx_Dialog_wrapperWithStaticUnder " : '')
+ (modal.className ? modal.className : '');
const dialog = (
<div className={classes}>
<div className="mx_Dialog">
{modal.elem}
</div>
<div className="mx_Dialog_background" onClick={this.closeAll}></div>
</div>
);
ReactDOM.render(dialog, this.getOrCreateContainer());
} else {
// This is safe to call repeatedly if we happen to do that
ReactDOM.unmountComponentAtNode(this.getOrCreateContainer());
}
}
}

View File

@ -1,53 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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 MatrixClientPeg from './MatrixClientPeg';
// TODO: Decommission.
// Ref: https://github.com/vector-im/riot-web/issues/8424
export default {
/*
* Returns the email pusher (pusher of type 'email') for a given
* email address. Email pushers all have the same app ID, so since
* pushers are unique over (app ID, pushkey), there will be at most
* one such pusher.
*/
getEmailPusher: function(pushers, address) {
if (pushers === undefined) {
return undefined;
}
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
return pushers[i];
}
}
return undefined;
},
addEmailPusher: function(address, data) {
return MatrixClientPeg.get().setPusher({
kind: 'email',
app_id: 'm.email',
pushkey: address,
app_display_name: 'Email Notifications',
device_display_name: address,
lang: navigator.language,
data: data,
append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address
});
},
};

View File

@ -584,7 +584,8 @@ export default React.createClass({
break;
case 'view_user_settings': {
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {}, 'mx_SettingsDialog');
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {}, 'mx_SettingsDialog',
/*isPriority=*/false, /*isStatic=*/true);
// View the welcome or home page if we need something to look at
this._viewSomethingBehindModal();

View File

@ -635,9 +635,9 @@ module.exports = React.createClass({
_onTypingVisible: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
scrollPanel.blockShrinking();
// scroll down if at bottom
scrollPanel.checkScroll();
scrollPanel.blockShrinking();
}
},
@ -648,12 +648,23 @@ module.exports = React.createClass({
const isAtBottom = scrollPanel.isAtBottom();
const whoIsTyping = this.refs.whoIsTyping;
const isTypingVisible = whoIsTyping && whoIsTyping.isVisible();
// when messages get added to the timeline,
// but somebody else is still typing,
// update the min-height, so once the last
// person stops typing, no jumping occurs
if (isAtBottom && isTypingVisible) {
scrollPanel.blockShrinking();
}
}
},
clearTimelineHeight: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.clearBlockShrinking();
}
},
onResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true);
},

View File

@ -78,6 +78,27 @@ if (DEBUG_SCROLL) {
* scroll down further. If stickyBottom is disabled, we just save the scroll
* offset as normal.
*/
function createTimelineResizeDetector(scrollNode, itemlist, callback) {
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(callback);
ro.observe(itemlist);
return ro;
} else if (typeof IntersectionObserver !== "undefined") {
const threshold = [];
for (let i = 0; i <= 1000; ++i) {
threshold.push(i / 1000);
}
const io = new IntersectionObserver(
callback,
{root: scrollNode, threshold},
);
io.observe(itemlist);
return io;
}
}
module.exports = React.createClass({
displayName: 'ScrollPanel',
@ -160,6 +181,12 @@ module.exports = React.createClass({
componentDidMount: function() {
this.checkScroll();
this._timelineSizeObserver = createTimelineResizeDetector(
this._getScrollNode(),
this.refs.itemlist,
() => { this._restoreSavedScrollState(); },
);
},
componentDidUpdate: function() {
@ -169,10 +196,6 @@ module.exports = React.createClass({
//
// This will also re-check the fill state, in case the paginate was inadequate
this.checkScroll();
if (!this.isAtBottom()) {
this.clearBlockShrinking();
}
},
componentWillUnmount: function() {
@ -181,6 +204,10 @@ module.exports = React.createClass({
//
// (We could use isMounted(), but facebook have deprecated that.)
this.unmounted = true;
if (this._timelineSizeObserver) {
this._timelineSizeObserver.disconnect();
this._timelineSizeObserver = null;
}
},
onScroll: function(ev) {
@ -211,23 +238,16 @@ module.exports = React.createClass({
// 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) {
// when scrolling, we don't care about disappearing typing notifs shrinking the timeline
// this might cause the scrollbar to resize in case the max-height was not correct
// but that's better than ending up with a lot of whitespace at the bottom of the timeline.
// we need to above check because when showing the typing notifs, an onScroll event is also triggered
if (!this.isAtBottom()) {
this.clearBlockShrinking();
}
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();
this.props.onScroll(ev);
this.checkFillState();
@ -235,8 +255,6 @@ module.exports = React.createClass({
onResize: function() {
this.props.onResize();
// clear min-height as the height might have changed
this.clearBlockShrinking();
this.checkScroll();
if (this._gemScroll) this._gemScroll.forceUpdate();
},
@ -245,6 +263,7 @@ module.exports = React.createClass({
// where it ought to be, and set off pagination requests if necessary.
checkScroll: function() {
this._restoreSavedScrollState();
this._checkBlockShrinking();
this.checkFillState();
},
@ -386,8 +405,6 @@ module.exports = React.createClass({
}
this._unfillDebouncer = setTimeout(() => {
this._unfillDebouncer = null;
// if timeline shrinks, min-height should be cleared
this.clearBlockShrinking();
this.props.onUnfillRequest(backwards, markerScrollToken);
}, UNFILL_REQUEST_DEBOUNCE_MS);
}
@ -583,9 +600,10 @@ module.exports = React.createClass({
}
const scrollNode = this._getScrollNode();
const wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
const boundingRect = node.getBoundingClientRect();
const scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
const scrollBottom = scrollNode.scrollTop + scrollNode.clientHeight;
const nodeBottom = node.offsetTop + node.clientHeight;
const scrollDelta = nodeBottom + pixelOffset - scrollBottom;
debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" +
pixelOffset + " (delta: "+scrollDelta+")");
@ -602,42 +620,43 @@ module.exports = React.createClass({
return;
}
const scrollNode = this._getScrollNode();
const scrollBottom = scrollNode.scrollTop + scrollNode.clientHeight;
const itemlist = this.refs.itemlist;
const wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
const messages = itemlist.children;
let newScrollState = null;
let node = null;
// loop backwards, from bottom-most message (as that is the most common case)
for (let i = messages.length-1; i >= 0; --i) {
const node = messages[i];
if (!node.dataset.scrollTokens) continue;
const boundingRect = node.getBoundingClientRect();
newScrollState = {
stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollTokens.split(',')[0],
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
};
// If the bottom of the panel intersects the ClientRect of node, use this node
// as the scrollToken.
// If this is false for the entire for-loop, we default to the last node
// (which is why newScrollState is set on every iteration).
if (boundingRect.top < wrapperRect.bottom) {
if (!messages[i].dataset.scrollTokens) {
continue;
}
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 < scrollBottom) {
// Use this node as the scrollToken
break;
}
}
// This is only false if there were no nodes with `node.dataset.scrollTokens` set.
if (newScrollState) {
this.scrollState = newScrollState;
debuglog("ScrollPanel: saved scroll state", this.scrollState);
} else {
if (!node) {
debuglog("ScrollPanel: 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);
this.scrollState = {
stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollTokens.split(',')[0],
pixelOffset: scrollBottom - nodeBottom,
};
},
_restoreSavedScrollState: function() {
const scrollState = this.scrollState;
const scrollNode = this._getScrollNode();
if (scrollState.stuckAtBottom) {
this._setScrollTop(Number.MAX_VALUE);
@ -717,6 +736,21 @@ module.exports = React.createClass({
}
},
_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();
}
}
},
render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
// TODO: the classnames on the div and ol could do with being updated to

View File

@ -935,6 +935,11 @@ var TimelinePanel = React.createClass({
{windowLimit: this.props.timelineCap});
const onLoaded = () => {
// clear the timeline min-height when
// (re)loading the timeline
if (this.refs.messagePanel) {
this.refs.messagePanel.clearTimelineHeight();
}
this._reloadEvents();
// If we switched away from the room while there were pending

View File

@ -41,8 +41,8 @@ export default class NetworkDropdown extends React.Component {
this.state = {
expanded: false,
selectedServer: server,
selectedInstance: null,
includeAllNetworks: false,
selectedInstanceId: null,
includeAllNetworks: true,
};
}
@ -52,7 +52,8 @@ export default class NetworkDropdown extends React.Component {
document.addEventListener('click', this.onDocumentClick, false);
// fire this now so the defaults can be set up
this.props.onOptionChange(this.state.selectedServer, this.state.selectedInstance, this.state.includeAllNetworks);
const {selectedServer, selectedInstanceId, includeAllNetworks} = this.state;
this.props.onOptionChange(selectedServer, selectedInstanceId, includeAllNetworks);
}
componentWillUnmount() {
@ -97,17 +98,18 @@ export default class NetworkDropdown extends React.Component {
expanded: false,
selectedServer: server,
selectedInstanceId: instance ? instance.instance_id : null,
includeAll: includeAll,
includeAllNetworks: includeAll,
});
this.props.onOptionChange(server, instance ? instance.instance_id : null, includeAll);
}
onInputKeyUp(e) {
if (e.key == 'Enter') {
if (e.key === 'Enter') {
this.setState({
expanded: false,
selectedServer: e.target.value,
selectedNetwork: null,
includeAllNetworks: true,
});
this.props.onOptionChange(e.target.value, null);
}
@ -135,7 +137,7 @@ export default class NetworkDropdown extends React.Component {
servers = servers.concat(this.props.config.roomDirectory.servers);
}
if (servers.indexOf(MatrixClientPeg.getHomeServerName()) == -1) {
if (!servers.includes(MatrixClientPeg.getHomeServerName())) {
servers.unshift(MatrixClientPeg.getHomeServerName());
}
@ -145,7 +147,7 @@ export default class NetworkDropdown extends React.Component {
// we can only show the default room list.
for (const server of servers) {
options.push(this._makeMenuOption(server, null, true));
if (server == MatrixClientPeg.getHomeServerName()) {
if (server === MatrixClientPeg.getHomeServerName()) {
options.push(this._makeMenuOption(server, null, false));
if (this.props.protocols) {
for (const proto of Object.keys(this.props.protocols)) {
@ -181,18 +183,15 @@ export default class NetworkDropdown extends React.Component {
let icon;
let name;
let span_class;
let key;
if (!instance && includeAll) {
key = server;
name = server;
span_class = 'mx_NetworkDropdown_menu_all';
} else if (!instance) {
key = server + '_all';
name = 'Matrix';
icon = <img src={require("../../../../res/img/network-matrix.svg")} />;
span_class = 'mx_NetworkDropdown_menu_network';
} else {
key = server + '_inst_' + instance.instance_id;
const imgUrl = instance.icon ?
@ -200,41 +199,40 @@ export default class NetworkDropdown extends React.Component {
DEFAULT_ICON_URL;
icon = <img src={imgUrl} />;
name = instance.desc;
span_class = 'mx_NetworkDropdown_menu_network';
}
const click_handler = handleClicks ? this.onMenuOptionClick.bind(this, server, instance, includeAll) : null;
const clickHandler = handleClicks ? this.onMenuOptionClick.bind(this, server, instance, includeAll) : null;
return <div key={key} className="mx_NetworkDropdown_networkoption" onClick={click_handler}>
return <div key={key} className="mx_NetworkDropdown_networkoption" onClick={clickHandler}>
{icon}
<span className="mx_NetworkDropdown_menu_network">{name}</span>
</div>;
}
render() {
let current_value;
let currentValue;
let menu;
if (this.state.expanded) {
const menu_options = this._getMenuOptions();
const menuOptions = this._getMenuOptions();
menu = <div className="mx_NetworkDropdown_menu">
{menu_options}
{menuOptions}
</div>;
current_value = <input type="text" className="mx_NetworkDropdown_networkoption"
currentValue = <input type="text" className="mx_NetworkDropdown_networkoption"
ref={this.collectInputTextBox} onKeyUp={this.onInputKeyUp}
placeholder="matrix.org" // 'matrix.org' as an example of an HS name
/>;
} else {
const instance = instanceForInstanceId(this.props.protocols, this.state.selectedInstanceId);
current_value = this._makeMenuOption(
this.state.selectedServer, instance, this.state.includeAll, false,
currentValue = this._makeMenuOption(
this.state.selectedServer, instance, this.state.includeAllNetworks, false,
);
}
return <div className="mx_NetworkDropdown" ref={this.collectRoot}>
<div className="mx_NetworkDropdown_input mx_no_textinput" onClick={this.onInputClick}>
{current_value}
<span className="mx_NetworkDropdown_arrow"></span>
{currentValue}
<span className="mx_NetworkDropdown_arrow" />
{menu}
</div>
</div>;

View File

@ -20,6 +20,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import * as Roles from '../../../Roles';
import { _t } from '../../../languageHandler';
import Field from "./Field";
module.exports = React.createClass({
displayName: 'PowerSelector',
@ -32,19 +33,15 @@ module.exports = React.createClass({
// Default user power level for the room
usersDefault: PropTypes.number.isRequired,
// if true, the <select/> should be a 'controlled' form element and updated by React
// to reflect the current value, rather than left freeform.
// MemberInfo uses controlled; RoomSettings uses non-controlled.
//
// ignored if disabled is truthy. false by default.
controlled: PropTypes.bool,
// should the user be able to change the value? false by default.
disabled: PropTypes.bool,
onChange: PropTypes.func,
// Optional key to pass as the second argument to `onChange`
powerLevelKey: PropTypes.string,
// The name to annotate the selector with
label: PropTypes.string,
},
getInitialState: function() {
@ -52,6 +49,9 @@ module.exports = React.createClass({
levelRoleMap: {},
// List of power levels to show in the drop-down
options: [],
customValue: this.props.value,
selectValue: 0,
};
},
@ -77,61 +77,61 @@ module.exports = React.createClass({
return l === undefined || l <= newProps.maxValue;
});
const isCustom = levelRoleMap[newProps.value] === undefined;
this.setState({
levelRoleMap,
options,
custom: levelRoleMap[newProps.value] === undefined,
custom: isCustom,
customLevel: newProps.value,
selectValue: isCustom ? "SELECT_VALUE_CUSTOM" : newProps.value,
});
},
onSelectChange: function(event) {
this.setState({ custom: event.target.value === "SELECT_VALUE_CUSTOM" });
if (event.target.value !== "SELECT_VALUE_CUSTOM") {
const isCustom = event.target.value === "SELECT_VALUE_CUSTOM";
if (isCustom) {
this.setState({custom: true});
} else {
this.props.onChange(event.target.value, this.props.powerLevelKey);
this.setState({selectValue: event.target.value});
}
},
onCustomBlur: function(event) {
this.props.onChange(parseInt(this.refs.custom.value), this.props.powerLevelKey);
onCustomChange: function(event) {
this.setState({customValue: event.target.value});
},
onCustomKeyDown: function(event) {
if (event.key == "Enter") {
this.props.onChange(parseInt(this.refs.custom.value), this.props.powerLevelKey);
onCustomBlur: function(event) {
event.preventDefault();
event.stopPropagation();
this.props.onChange(parseInt(this.state.customValue), this.props.powerLevelKey);
},
onCustomKeyPress: function(event) {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
// Do not call the onChange handler directly here - it can cause an infinite loop.
// Long story short, a user hits Enter to submit the value which onChange handles as
// raising a dialog which causes a blur which causes a dialog which causes a blur and
// so on. By not causing the onChange to be called here, we avoid the loop because we
// handle the onBlur safely.
event.target.blur();
}
},
render: function() {
let customPicker;
let picker;
if (this.state.custom) {
if (this.props.disabled) {
customPicker = <span>{ _t(
"Custom of %(powerLevel)s",
{ powerLevel: this.props.value },
) }</span>;
} else {
customPicker = <span> = <input
ref="custom"
type="text"
size="3"
defaultValue={this.props.value}
onBlur={this.onCustomBlur}
onKeyDown={this.onCustomKeyDown}
/>
</span>;
}
}
let selectValue;
if (this.state.custom) {
selectValue = "SELECT_VALUE_CUSTOM";
} else {
selectValue = this.state.levelRoleMap[this.props.value] ?
this.props.value : "SELECT_VALUE_CUSTOM";
}
let select;
if (this.props.disabled) {
select = <span>{ this.state.levelRoleMap[selectValue] }</span>;
picker = (
<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} />
);
} else {
// Each level must have a definition in this.state.levelRoleMap
let options = this.state.options.map((level) => {
@ -145,20 +145,19 @@ module.exports = React.createClass({
return <option value={op.value} key={op.value}>{ op.text }</option>;
});
select =
<select ref="select"
value={this.props.controlled ? selectValue : undefined}
defaultValue={!this.props.controlled ? selectValue : undefined}
onChange={this.onSelectChange}>
{ options }
</select>;
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}>
{options}
</Field>
);
}
return (
<span className="mx_PowerSelector">
{ select }
{ customPicker }
</span>
<div className="mx_PowerSelector">
{ picker }
</div>
);
},
});

View File

@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import {wantsDateSeparator} from '../../../DateUtils';
import {MatrixEvent, MatrixClient} from 'matrix-js-sdk';
import {makeUserPermalink} from "../../../matrix-to";
import {makeUserPermalink, RoomPermalinkCreator} from "../../../matrix-to";
import SettingsStore from "../../../settings/SettingsStore";
// This component does no cycle detection, simply because the only way to make such a cycle would be to
@ -32,7 +32,7 @@ export default class ReplyThread extends React.Component {
parentEv: PropTypes.instanceOf(MatrixEvent),
// called when the ReplyThread contents has changed, including EventTiles thereof
onWidgetLoad: PropTypes.func.isRequired,
permalinkCreator: PropTypes.object.isRequired,
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
};
static contextTypes = {

View File

@ -53,7 +53,7 @@ module.exports = React.createClass({
permalinkCreator.load();
const predecessorPermalink = permalinkCreator.forEvent(predecessor['event_id']);
return <div className="mx_CreateEvent">
<img className="mx_CreateEvent_image" src={require("../../../../res/img/room-continuation.svg")} />
<div className="mx_CreateEvent_image" />
<div className="mx_CreateEvent_header">
{_t("This room is a continuation of another conversation.")}
</div>

View File

@ -91,7 +91,6 @@ export default class RoomProfileSettings extends React.Component {
newState.originalTopic = this.state.topic;
}
newState.enableProfileSave = true;
this.setState(newState);
};

View File

@ -947,14 +947,12 @@ module.exports = withMatrixClient(React.createClass({
const PowerSelector = sdk.getComponent('elements.PowerSelector');
roomMemberDetails = <div>
<div className="mx_MemberInfo_profileField">
{ _t("Level:") } <b>
<PowerSelector controlled={true}
value={parseInt(this.props.member.powerLevel)}
maxValue={this.state.can.modifyLevelMax}
disabled={!this.state.can.modifyLevel}
usersDefault={powerLevelUsersDefault}
onChange={this.onPowerChange} />
</b>
<PowerSelector
value={parseInt(this.props.member.powerLevel)}
maxValue={this.state.can.modifyLevelMax}
disabled={!this.state.can.modifyLevel}
usersDefault={powerLevelUsersDefault}
onChange={this.onPowerChange} />
</div>
<div className="mx_MemberInfo_profileField">
{presenceLabel}

View File

@ -1592,7 +1592,7 @@ export default class MessageComposerInput extends React.Component {
return (
<div className="mx_MessageComposer_input_wrapper" onClick={this.focusComposer}>
<div className="mx_MessageComposer_autocomplete_wrapper">
<ReplyPreview />
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
<Autocomplete
ref={(e) => this.autocomplete = e}
room={this.props.room}

View File

@ -20,6 +20,8 @@ import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
import PropTypes from "prop-types";
import {RoomPermalinkCreator} from "../../../matrix-to";
function cancelQuoting() {
dis.dispatch({
@ -29,6 +31,10 @@ function cancelQuoting() {
}
export default class ReplyPreview extends React.Component {
static propTypes = {
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
};
constructor(props, context) {
super(props, context);
@ -75,6 +81,7 @@ export default class ReplyPreview extends React.Component {
<EventTile last={true}
tileShape="reply_preview"
mxEvent={this.state.event}
permalinkCreator={this.props.permalinkCreator}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} />
</div>
</div>;

View File

@ -19,7 +19,6 @@ import Promise from 'bluebird';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import UserSettingsStore from '../../../UserSettingsStore';
import SettingsStore, {SettingLevel} from '../../../settings/SettingsStore';
import Modal from '../../../Modal';
import {
@ -132,14 +131,41 @@ module.exports = React.createClass({
});
},
/*
* Returns the email pusher (pusher of type 'email') for a given
* email address. Email pushers all have the same app ID, so since
* pushers are unique over (app ID, pushkey), there will be at most
* one such pusher.
*/
getEmailPusher: function(pushers, address) {
if (pushers === undefined) {
return undefined;
}
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
return pushers[i];
}
}
return undefined;
},
onEnableEmailNotificationsChange: function(address, checked) {
let emailPusherPromise;
if (checked) {
const data = {};
data['brand'] = SdkConfig.get().brand || 'Riot';
emailPusherPromise = UserSettingsStore.addEmailPusher(address, data);
emailPusherPromise = MatrixClientPeg.get().setPusher({
kind: 'email',
app_id: 'm.email',
pushkey: address,
app_display_name: 'Email Notifications',
device_display_name: address,
lang: navigator.language,
data: data,
append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address
});
} else {
const emailPusher = UserSettingsStore.getEmailPusher(this.state.pushers, address);
const emailPusher = this.getEmailPusher(this.state.pushers, address);
emailPusher.kind = null;
emailPusherPromise = MatrixClientPeg.get().setPusher(emailPusher);
}
@ -697,7 +723,7 @@ module.exports = React.createClass({
emailNotificationsRow: function(address, label) {
return <LabelledToggleSwitch value={this.hasEmailPusher(this.state.pushers, address)}
onChange={this.onEnableEmailNotificationsChange.bind(this, address)}
label={label} />;
label={label} key={`emailNotif_${label}`} />;
},
render: function() {
@ -729,17 +755,15 @@ module.exports = React.createClass({
}
const emailThreepids = this.state.threepids.filter((tp) => tp.medium === "email");
let emailNotificationsRow;
let emailNotificationsRows;
if (emailThreepids.length === 0) {
emailNotificationsRow = <div>
emailNotificationsRows = <div>
{ _t('Add an email address to configure email notifications') }
</div>;
} else {
// This only supports the first email address in your profile for now
emailNotificationsRow = this.emailNotificationsRow(
emailThreepids[0].address,
`${_t('Enable email notifications')} (${emailThreepids[0].address})`,
);
emailNotificationsRows = emailThreepids.map((threePid) => this.emailNotificationsRow(
threePid.address, `${_t('Enable email notifications')} (${threePid.address})`,
));
}
// Build external push rules
@ -823,7 +847,7 @@ module.exports = React.createClass({
onChange={this.onEnableAudioNotificationsChange}
label={_t('Enable audible notifications in web client')} />
{ emailNotificationsRow }
{ emailNotificationsRows }
<div className="mx_UserNotifSettings_pushRulesTableWrapper">
<table className="mx_UserNotifSettings_pushRulesTable">

View File

@ -20,13 +20,22 @@ import MatrixClientPeg from "../../../MatrixClientPeg";
import Field from "../elements/Field";
import AccessibleButton from "../elements/AccessibleButton";
import classNames from 'classnames';
import {User} from "matrix-js-sdk";
export default class ProfileSettings extends React.Component {
constructor() {
super();
const client = MatrixClientPeg.get();
const user = client.getUser(client.getUserId());
let user = client.getUser(client.getUserId());
if (!user) {
// XXX: We shouldn't have to do this.
// There seems to be a condition where the User object won't exist until a room
// exists on the account. To work around this, we'll just create a temporary User
// and use that.
console.warn("User object not found - creating one for ProfileSettings");
user = new User(client.getUserId());
}
let avatarUrl = user.avatarUrl;
if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false);
this.state = {
@ -72,7 +81,6 @@ export default class ProfileSettings extends React.Component {
newState.avatarFile = null;
}
newState.enableProfileSave = true;
this.setState(newState);
};

View File

@ -24,14 +24,14 @@ import Modal from "../../../../../Modal";
const plEventsToLabels = {
// These will be translated for us later.
"m.room.avatar": _td("To change the room's avatar, you must be a"),
"m.room.name": _td("To change the room's name, you must be a"),
"m.room.canonical_alias": _td("To change the room's main address, you must be a"),
"m.room.history_visibility": _td("To change the room's history visibility, you must be a"),
"m.room.power_levels": _td("To change the permissions in the room, you must be a"),
"m.room.topic": _td("To change the topic, you must be a"),
"m.room.avatar": _td("Change room avatar"),
"m.room.name": _td("Change room name"),
"m.room.canonical_alias": _td("Change main address for the room"),
"m.room.history_visibility": _td("Change history visibility"),
"m.room.power_levels": _td("Change permissions"),
"m.room.topic": _td("Change topic"),
"im.vector.modular.widgets": _td("To modify widgets in the room, you must be a"),
"im.vector.modular.widgets": _td("Modify widgets"),
};
const plEventsToShow = {
@ -158,35 +158,35 @@ export default class RolesRoomSettingsTab extends React.Component {
const powerLevelDescriptors = {
"users_default": {
desc: _t('The default role for new room members is'),
desc: _t('Default role'),
defaultValue: 0,
},
"events_default": {
desc: _t('To send messages, you must be a'),
desc: _t('Send messages'),
defaultValue: 0,
},
"invite": {
desc: _t('To invite users into the room, you must be a'),
desc: _t('Invite users'),
defaultValue: 50,
},
"state_default": {
desc: _t('To configure the room, you must be a'),
desc: _t('Change settings'),
defaultValue: 50,
},
"kick": {
desc: _t('To kick users, you must be a'),
desc: _t('Kick users'),
defaultValue: 50,
},
"ban": {
desc: _t('To ban users, you must be a'),
desc: _t('Ban users'),
defaultValue: 50,
},
"redact": {
desc: _t('To remove other users\' messages, you must be a'),
desc: _t('Remove messages'),
defaultValue: 50,
},
"notifications.room": {
desc: _t('To notify everyone in the room, you must be a'),
desc: _t('Notify everyone'),
defaultValue: 50,
},
};
@ -217,20 +217,15 @@ export default class RolesRoomSettingsTab extends React.Component {
const mutedUsers = [];
Object.keys(userLevels).forEach(function(user) {
const canChange = userLevels[user] < currentUserLevel && canChangeLevels;
if (userLevels[user] > defaultUserLevel) { // privileged
privilegedUsers.push(<li key={user}>
{ _t("%(user)s is a %(userRole)s", {
user: user,
userRole: <PowerSelector value={userLevels[user]} disabled={true} />,
}) }
</li>);
privilegedUsers.push(
<PowerSelector value={userLevels[user]} disabled={!canChange} label={user} key={user} />,
);
} else if (userLevels[user] < defaultUserLevel) { // muted
mutedUsers.push(<li key={user}>
{ _t("%(user)s is a %(userRole)s", {
user: user,
userRole: <PowerSelector value={userLevels[user]} disabled={true} />,
}) }
</li>);
mutedUsers.push(
<PowerSelector value={userLevels[user]} disabled={!canChange} label={user} key={user} />,
);
}
});
@ -247,18 +242,14 @@ export default class RolesRoomSettingsTab extends React.Component {
privilegedUsersSection =
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<div className='mx_SettingsTab_subheading'>{ _t('Privileged Users') }</div>
<ul>
{privilegedUsers}
</ul>
{privilegedUsers}
</div>;
}
if (mutedUsers.length) {
mutedUsersSection =
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<div className='mx_SettingsTab_subheading'>{ _t('Muted Users') }</div>
<ul>
{mutedUsers}
</ul>
{mutedUsers}
</div>;
}
}
@ -300,11 +291,10 @@ export default class RolesRoomSettingsTab extends React.Component {
const value = parseIntWithDefault(currentObj, descriptor.defaultValue);
return <div key={index} className="">
<span>{descriptor.desc}&nbsp;</span>
<PowerSelector
label={descriptor.desc}
value={value}
usersDefault={defaultUserLevel}
controlled={false}
disabled={!canChangeLevels || currentUserLevel < value}
powerLevelKey={key} // Will be sent as the second parameter to `onChange`
onChange={this._onPowerLevelsChanged}
@ -317,18 +307,14 @@ export default class RolesRoomSettingsTab extends React.Component {
if (label) {
label = _t(label);
} else {
label = _t(
"To send events of type <eventType/>, you must be a", {},
{ 'eventType': <code>{ eventType }</code> },
);
label = _t("Send %(eventType)s events", {eventType});
}
return (
<div className="" key={eventType}>
<span>{label}&nbsp;</span>
<PowerSelector
label={label}
value={eventsLevels[eventType]}
usersDefault={defaultUserLevel}
controlled={false}
disabled={!canChangeLevels || currentUserLevel < eventsLevels[eventType]}
powerLevelKey={"event_levels_" + eventType}
onChange={this._onPowerLevelsChanged}
@ -345,6 +331,7 @@ export default class RolesRoomSettingsTab extends React.Component {
{bannedUsersSection}
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<span className='mx_SettingsTab_subheading'>{_t("Permissions")}</span>
<p>{_t('Select the roles required to change various parts of the room')}</p>
{powerSelectors}
{eventPowerSelectors}
</div>

View File

@ -76,14 +76,23 @@ export default class VoiceUserSettingsTab extends React.Component {
_setAudioOutput = (e) => {
CallMediaHandler.setAudioOutput(e.target.value);
this.setState({
activeAudioOutput: e.target.value,
});
};
_setAudioInput = (e) => {
CallMediaHandler.setAudioInput(e.target.value);
this.setState({
activeAudioInput: e.target.value,
});
};
_setVideoInput = (e) => {
CallMediaHandler.setVideoInput(e.target.value);
this.setState({
activeVideoInput: e.target.value,
});
};
_changeWebRtcMethod = (p2p) => {

View File

@ -238,8 +238,10 @@
"Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?",
"Unrecognised address": "Unrecognised address",
"You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.",
"User %(userId)s is already in the room": "User %(userId)s is already in the room",
"User %(user_id)s does not exist": "User %(user_id)s does not exist",
"User %(user_id)s may or may not exist": "User %(user_id)s may or may not exist",
"The user must be unbanned before they can be invited.": "The user must be unbanned before they can be invited.",
"Unknown server error": "Unknown server error",
"Use a few words, avoid common phrases": "Use a few words, avoid common phrases",
"No need for symbols, digits, or uppercase letters": "No need for symbols, digits, or uppercase letters",
@ -586,34 +588,34 @@
"Room Addresses": "Room Addresses",
"Publish this room to the public in %(domain)s's room directory?": "Publish this room to the public in %(domain)s's room directory?",
"URL Previews": "URL Previews",
"To change the room's avatar, you must be a": "To change the room's avatar, you must be a",
"To change the room's name, you must be a": "To change the room's name, you must be a",
"To change the room's main address, you must be a": "To change the room's main address, you must be a",
"To change the room's history visibility, you must be a": "To change the room's history visibility, you must be a",
"To change the permissions in the room, you must be a": "To change the permissions in the room, you must be a",
"To change the topic, you must be a": "To change the topic, you must be a",
"To modify widgets in the room, you must be a": "To modify widgets in the room, you must be a",
"Change room avatar": "Change room avatar",
"Change room name": "Change room name",
"Change main address for the room": "Change main address for the room",
"Change history visibility": "Change history visibility",
"Change permissions": "Change permissions",
"Change topic": "Change topic",
"Modify widgets": "Modify widgets",
"Failed to unban": "Failed to unban",
"Unban": "Unban",
"Banned by %(displayName)s": "Banned by %(displayName)s",
"The default role for new room members is": "The default role for new room members is",
"To send messages, you must be a": "To send messages, you must be a",
"To invite users into the room, you must be a": "To invite users into the room, you must be a",
"To configure the room, you must be a": "To configure the room, you must be a",
"To kick users, you must be a": "To kick users, you must be a",
"To ban users, you must be a": "To ban users, you must be a",
"To remove other users' messages, you must be a": "To remove other users' messages, you must be a",
"To notify everyone in the room, you must be a": "To notify everyone in the room, you must be a",
"Default role": "Default role",
"Send messages": "Send messages",
"Invite users": "Invite users",
"Change settings": "Change settings",
"Kick users": "Kick users",
"Ban users": "Ban users",
"Remove messages": "Remove messages",
"Notify everyone": "Notify everyone",
"No users have specific privileges in this room": "No users have specific privileges in this room",
"%(user)s is a %(userRole)s": "%(user)s is a %(userRole)s",
"Privileged Users": "Privileged Users",
"Muted Users": "Muted Users",
"Banned users": "Banned users",
"To send events of type <eventType/>, you must be a": "To send events of type <eventType/>, you must be a",
"Send %(eventType)s events": "Send %(eventType)s events",
"Roles & Permissions": "Roles & Permissions",
"Permissions": "Permissions",
"Enable encryption?": "Enable encryption?",
"Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>": "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>",
"Select the roles required to change various parts of the room": "Select the roles required to change various parts of the room",
"Guests cannot join this room even if explicitly invited.": "Guests cannot join this room even if explicitly invited.",
"Click here to fix": "Click here to fix",
"To link to this room, please add an alias.": "To link to this room, please add an alias.",
@ -687,7 +689,6 @@
"Revoke Moderator": "Revoke Moderator",
"Make Moderator": "Make Moderator",
"Admin Tools": "Admin Tools",
"Level:": "Level:",
"Close": "Close",
"and %(count)s others...|other": "and %(count)s others...",
"and %(count)s others...|one": "and one other...",
@ -1000,7 +1001,7 @@
"%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s",
"collapse": "collapse",
"expand": "expand",
"Custom of %(powerLevel)s": "Custom of %(powerLevel)s",
"Power level": "Power level",
"Custom level": "Custom level",
"Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.",
"<a>In reply to</a> <pill>": "<a>In reply to</a> <pill>",

View File

@ -71,6 +71,18 @@ class ConsoleLogger {
log(level, ...args) {
// We don't know what locale the user may be running so use ISO strings
const ts = new Date().toISOString();
// Convert objects and errors to helpful things
args = args.map((arg) => {
if (arg instanceof Error) {
return arg.message + (arg.stack ? `\n${arg.stack}` : '');
} else if (typeof(arg) === 'object') {
return JSON.stringify(arg);
} else {
return arg;
}
});
// Some browsers support string formatting which we're not doing here
// so the lines are a little more ugly but easy to implement / quick to
// run.

View File

@ -319,7 +319,9 @@ class RoomListStore extends Store {
const dmRoomMap = DMRoomMap.shared();
if (myMembership === 'invite') {
tags.push("im.vector.fake.invite");
} else if (dmRoomMap.getUserIdForRoomId(room.roomId)) {
} else if (dmRoomMap.getUserIdForRoomId(room.roomId) && tags.length === 0) {
// We intentionally don't duplicate rooms in other tags into the people list
// as a feature.
tags.push("im.vector.fake.direct");
} else if (tags.length === 0) {
tags.push("im.vector.fake.recent");
@ -429,6 +431,13 @@ class RoomListStore extends Store {
newList.push(entry);
}
if (!pushedEntry && desiredCategoryBoundaryIndex >= 0) {
console.warn(`!! Room ${room.roomId} nearly lost: Ran off the end of the list`);
console.warn(`!! Inserting at position ${desiredCategoryBoundaryIndex} with category ${category}`);
newList.splice(desiredCategoryBoundaryIndex, 0, {room, category});
pushedEntry = true;
}
return pushedEntry;
}
@ -477,22 +486,27 @@ class RoomListStore extends Store {
room, category, this._state.lists[key], listsClone[key], lastTimestamp);
if (!pushedEntry) {
// Special case invites: they don't really have timelines and can easily get lost when
// the user has multiple pending invites. Pushing them is the least worst option.
if (listsClone[key].length === 0 || key === "im.vector.fake.invite") {
listsClone[key].push({room, category});
insertedIntoTags.push(key);
} else {
// In theory, this should never happen
console.warn(`!! Room ${room.roomId} lost: No position available`);
}
} else {
insertedIntoTags.push(key);
// This should rarely happen: _slotRoomIntoList has several checks which attempt
// to make sure that a room is not lost in the list. If we do lose the room though,
// we shouldn't throw it on the floor and forget about it. Instead, we should insert
// it somewhere. We'll insert it at the top for a couple reasons: 1) it is probably
// an important room for the user and 2) if this does happen, we'd want a bug report.
console.warn(`!! Room ${room.roomId} nearly lost: Failed to find a position`);
console.warn(`!! Inserting at position 0 in the list and flagging as inserted`);
console.warn("!! Additional info: ", {
category,
key,
upToIndex: listsClone[key].length,
expectedCount: this._state.lists[key].length,
});
listsClone[key].splice(0, 0, {room, category});
}
insertedIntoTags.push(key);
}
}
// Double check that we inserted the room in the right places
// Double check that we inserted the room in the right places.
// There should never be a discrepancy.
for (const targetTag of targetTags) {
let count = 0;
for (const insertedTag of insertedIntoTags) {

View File

@ -120,7 +120,7 @@ class RoomViewStore extends Store {
const RoomSettingsDialog = sdk.getComponent("dialogs.RoomSettingsDialog");
Modal.createTrackedDialog('Room settings', '', RoomSettingsDialog, {
roomId: payload.room_id || this._state.roomId,
}, 'mx_SettingsDialog');
}, 'mx_SettingsDialog', /*isPriority=*/false, /*isStatic=*/true);
break;
}
}

View File

@ -101,6 +101,14 @@ export default class MultiInviter {
if (addrType === 'email') {
return MatrixClientPeg.get().inviteByEmail(roomId, addr);
} else if (addrType === 'mx-user-id') {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) throw new Error("Room not found");
const member = room.getMember(addr);
if (member && ['join', 'invite'].includes(member.membership)) {
throw {errcode: "RIOT.ALREADY_IN_ROOM", error: "Member already invited"};
}
if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) {
try {
const profile = await MatrixClientPeg.get().getProfileInfo(addr);
@ -152,6 +160,8 @@ export default class MultiInviter {
if (err.errcode === 'M_FORBIDDEN') {
fatal = true;
errorText = _t('You do not have permission to invite people to this room.');
} else if (err.errcode === "RIOT.ALREADY_IN_ROOM") {
errorText = _t("User %(userId)s is already in the room", {userId: address});
} else if (err.errcode === 'M_LIMIT_EXCEEDED') {
// we're being throttled so wait a bit & try again
setTimeout(() => {
@ -166,6 +176,8 @@ export default class MultiInviter {
// Invite without the profile check
console.warn(`User ${address} does not have a profile - inviting anyways automatically`);
this._doInvite(address, true).then(resolve, reject);
} else if (err.errcode === "M_BAD_STATE") {
errorText = _t("The user must be unbanned before they can be invited.");
} else {
errorText = _t('Unknown server error');
}