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/DeactivateAccountDialog.js
src/components/views/dialogs/SetPasswordDialog.js src/components/views/dialogs/SetPasswordDialog.js
src/components/views/dialogs/UnknownDeviceDialog.js src/components/views/dialogs/UnknownDeviceDialog.js
src/components/views/directory/NetworkDropdown.js
src/components/views/elements/AddressSelector.js src/components/views/elements/AddressSelector.js
src/components/views/elements/DirectorySearchBox.js src/components/views/elements/DirectorySearchBox.js
src/components/views/elements/ImageView.js src/components/views/elements/ImageView.js

View file

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

View file

@ -228,6 +228,17 @@ textarea {
color: $roomsublist-label-bg-color; 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 { .mx_Dialog_wrapper {
position: fixed; position: fixed;
z-index: 4000; z-index: 4000;
@ -252,7 +263,7 @@ textarea {
.mx_Dialog { .mx_Dialog {
background-color: $primary-bg-color; background-color: $primary-bg-color;
color: $light-fg-color; color: $light-fg-color;
z-index: 4010; z-index: 4012;
font-weight: 300; font-weight: 300;
font-size: 15px; font-size: 15px;
position: relative; position: relative;
@ -264,6 +275,10 @@ textarea {
overflow-y: auto; overflow-y: auto;
} }
.mx_Dialog_staticWrapper .mx_Dialog {
z-index: 4010;
}
.mx_Dialog_background { .mx_Dialog_background {
position: fixed; position: fixed;
top: 0; top: 0;
@ -272,6 +287,17 @@ textarea {
height: 100%; height: 100%;
background-color: $dialog-backdrop-color; background-color: $dialog-backdrop-color;
opacity: 0.8; 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 { .mx_Dialog_lightbox .mx_Dialog_background {

View file

@ -85,6 +85,7 @@
@import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_InlineSpinner.scss";
@import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_ManageIntegsButton.scss";
@import "./views/elements/_MemberEventListSummary.scss"; @import "./views/elements/_MemberEventListSummary.scss";
@import "./views/elements/_PowerSelector.scss";
@import "./views/elements/_ProgressBar.scss"; @import "./views/elements/_ProgressBar.scss";
@import "./views/elements/_ReplyThread.scss"; @import "./views/elements/_ReplyThread.scss";
@import "./views/elements/_ResizeHandle.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 { .mx_CreateEvent_image {
float: left; float: left;
padding-right: 20px; margin-right: 20px;
width: 72px; width: 72px;
height: 34px; height: 34px;
background-color: $primary-fg-color;
mask: url('$(res)/img/room-continuation.svg');
mask-repeat: no-repeat;
mask-position: center;
} }
.mx_CreateEvent_header { .mx_CreateEvent_header {

View file

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

View file

@ -7,4 +7,4 @@
set -ev set -ev
scripts/travis/build.sh 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'; import { _t } from './languageHandler';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; 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 * 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 // this modal. Remove all other modals from the stack when this modal
// is closed. // is closed.
this._priorityModal = null; 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] // 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 = [ this._modals = [
/* { /* {
elem: React component for this dialog elem: React component for this dialog
@ -130,6 +136,18 @@ class ModalManager {
return container; 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) { createTrackedDialog(analyticsAction, analyticsInfo, ...rest) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialog(...rest); return this.createDialog(...rest);
@ -166,8 +184,13 @@ class ModalManager {
* of other modals that are currently in the stack. * of other modals that are currently in the stack.
* Also, when closed, all modals will be removed * Also, when closed, all modals will be removed
* from the stack. * 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 self = this;
const modal = {}; const modal = {};
@ -188,6 +211,13 @@ class ModalManager {
self._modals = []; self._modals = [];
} }
if (self._staticModal === modal) {
self._staticModal = null;
// XXX: This is destructive
self._modals = [];
}
self._reRender(); self._reRender();
}; };
@ -207,6 +237,9 @@ class ModalManager {
if (isPriorityModal) { if (isPriorityModal) {
// XXX: This is destructive // XXX: This is destructive
this._priorityModal = modal; this._priorityModal = modal;
} else if (isStaticModal) {
// This is intentionally destructive
this._staticModal = modal;
} else { } else {
this._modals.unshift(modal); this._modals.unshift(modal);
} }
@ -216,12 +249,18 @@ class ModalManager {
} }
closeAll() { closeAll() {
const modals = this._modals; const modalsToClose = [...this._modals, this._priorityModal];
this._modals = []; this._modals = [];
this._priorityModal = null;
for (let i = 0; i < modals.length; i++) { if (this._staticModal && modalsToClose.length === 0) {
const m = modals[i]; modalsToClose.push(this._staticModal);
if (m.onFinished) { this._staticModal = null;
}
for (let i = 0; i < modalsToClose.length; i++) {
const m = modalsToClose[i];
if (m && m.onFinished) {
m.onFinished(false); m.onFinished(false);
} }
} }
@ -230,13 +269,14 @@ class ModalManager {
} }
_reRender() { _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 // If there is no modal to render, make all of Riot available
// to screen reader users again // to screen reader users again
dis.dispatch({ dis.dispatch({
action: 'aria_unhide_main_app', action: 'aria_unhide_main_app',
}); });
ReactDOM.unmountComponentAtNode(this.getOrCreateContainer()); ReactDOM.unmountComponentAtNode(this.getOrCreateContainer());
ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer());
return; return;
} }
@ -247,9 +287,33 @@ class ModalManager {
action: 'aria_hide_main_app', action: 'aria_hide_main_app',
}); });
if (this._staticModal) {
const classes = "mx_Dialog_wrapper mx_Dialog_staticWrapper "
+ (this._staticModal.className ? this._staticModal.className : '');
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]; 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 = ( const dialog = (
<div className={"mx_Dialog_wrapper " + (modal.className ? modal.className : '')}> <div className={classes}>
<div className="mx_Dialog"> <div className="mx_Dialog">
{modal.elem} {modal.elem}
</div> </div>
@ -258,6 +322,10 @@ class ModalManager {
); );
ReactDOM.render(dialog, this.getOrCreateContainer()); 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; break;
case 'view_user_settings': { case 'view_user_settings': {
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog"); 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 // View the welcome or home page if we need something to look at
this._viewSomethingBehindModal(); this._viewSomethingBehindModal();

View file

@ -635,9 +635,9 @@ module.exports = React.createClass({
_onTypingVisible: function() { _onTypingVisible: function() {
const scrollPanel = this.refs.scrollPanel; const scrollPanel = this.refs.scrollPanel;
if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
scrollPanel.blockShrinking();
// scroll down if at bottom // scroll down if at bottom
scrollPanel.checkScroll(); scrollPanel.checkScroll();
scrollPanel.blockShrinking();
} }
}, },
@ -648,12 +648,23 @@ module.exports = React.createClass({
const isAtBottom = scrollPanel.isAtBottom(); const isAtBottom = scrollPanel.isAtBottom();
const whoIsTyping = this.refs.whoIsTyping; const whoIsTyping = this.refs.whoIsTyping;
const isTypingVisible = whoIsTyping && whoIsTyping.isVisible(); 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) { if (isAtBottom && isTypingVisible) {
scrollPanel.blockShrinking(); scrollPanel.blockShrinking();
} }
} }
}, },
clearTimelineHeight: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.clearBlockShrinking();
}
},
onResize: function() { onResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true); 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 * scroll down further. If stickyBottom is disabled, we just save the scroll
* offset as normal. * 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({ module.exports = React.createClass({
displayName: 'ScrollPanel', displayName: 'ScrollPanel',
@ -160,6 +181,12 @@ module.exports = React.createClass({
componentDidMount: function() { componentDidMount: function() {
this.checkScroll(); this.checkScroll();
this._timelineSizeObserver = createTimelineResizeDetector(
this._getScrollNode(),
this.refs.itemlist,
() => { this._restoreSavedScrollState(); },
);
}, },
componentDidUpdate: function() { 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 will also re-check the fill state, in case the paginate was inadequate
this.checkScroll(); this.checkScroll();
if (!this.isAtBottom()) {
this.clearBlockShrinking();
}
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@ -181,6 +204,10 @@ module.exports = React.createClass({
// //
// (We could use isMounted(), but facebook have deprecated that.) // (We could use isMounted(), but facebook have deprecated that.)
this.unmounted = true; this.unmounted = true;
if (this._timelineSizeObserver) {
this._timelineSizeObserver.disconnect();
this._timelineSizeObserver = null;
}
}, },
onScroll: function(ev) { onScroll: function(ev) {
@ -211,23 +238,16 @@ module.exports = React.createClass({
// forget what we wanted, so don't overwrite the saved state unless // forget what we wanted, so don't overwrite the saved state unless
// this appears to be a user-initiated scroll. // this appears to be a user-initiated scroll.
if (sn.scrollTop != this._lastSetScroll) { 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(); this._saveScrollState();
} else { } else {
debuglog("Ignoring scroll echo"); debuglog("Ignoring scroll echo");
// only ignore the echo once, otherwise we'll get confused when the // only ignore the echo once, otherwise we'll get confused when the
// user scrolls away from, and back to, the autoscroll point. // user scrolls away from, and back to, the autoscroll point.
this._lastSetScroll = undefined; this._lastSetScroll = undefined;
} }
this._checkBlockShrinking();
this.props.onScroll(ev); this.props.onScroll(ev);
this.checkFillState(); this.checkFillState();
@ -235,8 +255,6 @@ module.exports = React.createClass({
onResize: function() { onResize: function() {
this.props.onResize(); this.props.onResize();
// clear min-height as the height might have changed
this.clearBlockShrinking();
this.checkScroll(); this.checkScroll();
if (this._gemScroll) this._gemScroll.forceUpdate(); 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. // where it ought to be, and set off pagination requests if necessary.
checkScroll: function() { checkScroll: function() {
this._restoreSavedScrollState(); this._restoreSavedScrollState();
this._checkBlockShrinking();
this.checkFillState(); this.checkFillState();
}, },
@ -386,8 +405,6 @@ module.exports = React.createClass({
} }
this._unfillDebouncer = setTimeout(() => { this._unfillDebouncer = setTimeout(() => {
this._unfillDebouncer = null; this._unfillDebouncer = null;
// if timeline shrinks, min-height should be cleared
this.clearBlockShrinking();
this.props.onUnfillRequest(backwards, markerScrollToken); this.props.onUnfillRequest(backwards, markerScrollToken);
}, UNFILL_REQUEST_DEBOUNCE_MS); }, UNFILL_REQUEST_DEBOUNCE_MS);
} }
@ -583,9 +600,10 @@ module.exports = React.createClass({
} }
const scrollNode = this._getScrollNode(); const scrollNode = this._getScrollNode();
const wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); const scrollBottom = scrollNode.scrollTop + scrollNode.clientHeight;
const boundingRect = node.getBoundingClientRect();
const scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom; const nodeBottom = node.offsetTop + node.clientHeight;
const scrollDelta = nodeBottom + pixelOffset - scrollBottom;
debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" + debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" +
pixelOffset + " (delta: "+scrollDelta+")"); pixelOffset + " (delta: "+scrollDelta+")");
@ -602,42 +620,43 @@ module.exports = React.createClass({
return; return;
} }
const scrollNode = this._getScrollNode();
const scrollBottom = scrollNode.scrollTop + scrollNode.clientHeight;
const itemlist = this.refs.itemlist; const itemlist = this.refs.itemlist;
const wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
const messages = itemlist.children; 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) { for (let i = messages.length-1; i >= 0; --i) {
const node = messages[i]; if (!messages[i].dataset.scrollTokens) {
if (!node.dataset.scrollTokens) continue; continue;
}
const boundingRect = node.getBoundingClientRect(); node = messages[i];
newScrollState = { // break at the first message (coming from the bottom)
stuckAtBottom: false, // that has it's offsetTop above the bottom of the viewport.
trackedScrollToken: node.dataset.scrollTokens.split(',')[0], if (node.offsetTop < scrollBottom) {
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) {
// Use this node as the scrollToken // Use this node as the scrollToken
break; break;
} }
} }
// This is only false if there were no nodes with `node.dataset.scrollTokens` set.
if (newScrollState) { if (!node) {
this.scrollState = newScrollState;
debuglog("ScrollPanel: saved scroll state", this.scrollState);
} else {
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport"); 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() { _restoreSavedScrollState: function() {
const scrollState = this.scrollState; const scrollState = this.scrollState;
const scrollNode = this._getScrollNode();
if (scrollState.stuckAtBottom) { if (scrollState.stuckAtBottom) {
this._setScrollTop(Number.MAX_VALUE); 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() { render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
// TODO: the classnames on the div and ol could do with being updated to // 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}); {windowLimit: this.props.timelineCap});
const onLoaded = () => { const onLoaded = () => {
// clear the timeline min-height when
// (re)loading the timeline
if (this.refs.messagePanel) {
this.refs.messagePanel.clearTimelineHeight();
}
this._reloadEvents(); this._reloadEvents();
// If we switched away from the room while there were pending // 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 = { this.state = {
expanded: false, expanded: false,
selectedServer: server, selectedServer: server,
selectedInstance: null, selectedInstanceId: null,
includeAllNetworks: false, includeAllNetworks: true,
}; };
} }
@ -52,7 +52,8 @@ export default class NetworkDropdown extends React.Component {
document.addEventListener('click', this.onDocumentClick, false); document.addEventListener('click', this.onDocumentClick, false);
// fire this now so the defaults can be set up // 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() { componentWillUnmount() {
@ -97,17 +98,18 @@ export default class NetworkDropdown extends React.Component {
expanded: false, expanded: false,
selectedServer: server, selectedServer: server,
selectedInstanceId: instance ? instance.instance_id : null, selectedInstanceId: instance ? instance.instance_id : null,
includeAll: includeAll, includeAllNetworks: includeAll,
}); });
this.props.onOptionChange(server, instance ? instance.instance_id : null, includeAll); this.props.onOptionChange(server, instance ? instance.instance_id : null, includeAll);
} }
onInputKeyUp(e) { onInputKeyUp(e) {
if (e.key == 'Enter') { if (e.key === 'Enter') {
this.setState({ this.setState({
expanded: false, expanded: false,
selectedServer: e.target.value, selectedServer: e.target.value,
selectedNetwork: null, selectedNetwork: null,
includeAllNetworks: true,
}); });
this.props.onOptionChange(e.target.value, null); 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); servers = servers.concat(this.props.config.roomDirectory.servers);
} }
if (servers.indexOf(MatrixClientPeg.getHomeServerName()) == -1) { if (!servers.includes(MatrixClientPeg.getHomeServerName())) {
servers.unshift(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. // we can only show the default room list.
for (const server of servers) { for (const server of servers) {
options.push(this._makeMenuOption(server, null, true)); options.push(this._makeMenuOption(server, null, true));
if (server == MatrixClientPeg.getHomeServerName()) { if (server === MatrixClientPeg.getHomeServerName()) {
options.push(this._makeMenuOption(server, null, false)); options.push(this._makeMenuOption(server, null, false));
if (this.props.protocols) { if (this.props.protocols) {
for (const proto of Object.keys(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 icon;
let name; let name;
let span_class;
let key; let key;
if (!instance && includeAll) { if (!instance && includeAll) {
key = server; key = server;
name = server; name = server;
span_class = 'mx_NetworkDropdown_menu_all';
} else if (!instance) { } else if (!instance) {
key = server + '_all'; key = server + '_all';
name = 'Matrix'; name = 'Matrix';
icon = <img src={require("../../../../res/img/network-matrix.svg")} />; icon = <img src={require("../../../../res/img/network-matrix.svg")} />;
span_class = 'mx_NetworkDropdown_menu_network';
} else { } else {
key = server + '_inst_' + instance.instance_id; key = server + '_inst_' + instance.instance_id;
const imgUrl = instance.icon ? const imgUrl = instance.icon ?
@ -200,41 +199,40 @@ export default class NetworkDropdown extends React.Component {
DEFAULT_ICON_URL; DEFAULT_ICON_URL;
icon = <img src={imgUrl} />; icon = <img src={imgUrl} />;
name = instance.desc; 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} {icon}
<span className="mx_NetworkDropdown_menu_network">{name}</span> <span className="mx_NetworkDropdown_menu_network">{name}</span>
</div>; </div>;
} }
render() { render() {
let current_value; let currentValue;
let menu; let menu;
if (this.state.expanded) { if (this.state.expanded) {
const menu_options = this._getMenuOptions(); const menuOptions = this._getMenuOptions();
menu = <div className="mx_NetworkDropdown_menu"> menu = <div className="mx_NetworkDropdown_menu">
{menu_options} {menuOptions}
</div>; </div>;
current_value = <input type="text" className="mx_NetworkDropdown_networkoption" currentValue = <input type="text" className="mx_NetworkDropdown_networkoption"
ref={this.collectInputTextBox} onKeyUp={this.onInputKeyUp} ref={this.collectInputTextBox} onKeyUp={this.onInputKeyUp}
placeholder="matrix.org" // 'matrix.org' as an example of an HS name placeholder="matrix.org" // 'matrix.org' as an example of an HS name
/>; />;
} else { } else {
const instance = instanceForInstanceId(this.props.protocols, this.state.selectedInstanceId); const instance = instanceForInstanceId(this.props.protocols, this.state.selectedInstanceId);
current_value = this._makeMenuOption( currentValue = this._makeMenuOption(
this.state.selectedServer, instance, this.state.includeAll, false, this.state.selectedServer, instance, this.state.includeAllNetworks, false,
); );
} }
return <div className="mx_NetworkDropdown" ref={this.collectRoot}> return <div className="mx_NetworkDropdown" ref={this.collectRoot}>
<div className="mx_NetworkDropdown_input mx_no_textinput" onClick={this.onInputClick}> <div className="mx_NetworkDropdown_input mx_no_textinput" onClick={this.onInputClick}>
{current_value} {currentValue}
<span className="mx_NetworkDropdown_arrow"></span> <span className="mx_NetworkDropdown_arrow" />
{menu} {menu}
</div> </div>
</div>; </div>;

View file

@ -20,6 +20,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import * as Roles from '../../../Roles'; import * as Roles from '../../../Roles';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import Field from "./Field";
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'PowerSelector', displayName: 'PowerSelector',
@ -32,19 +33,15 @@ module.exports = React.createClass({
// Default user power level for the room // Default user power level for the room
usersDefault: PropTypes.number.isRequired, 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. // should the user be able to change the value? false by default.
disabled: PropTypes.bool, disabled: PropTypes.bool,
onChange: PropTypes.func, onChange: PropTypes.func,
// Optional key to pass as the second argument to `onChange` // Optional key to pass as the second argument to `onChange`
powerLevelKey: PropTypes.string, powerLevelKey: PropTypes.string,
// The name to annotate the selector with
label: PropTypes.string,
}, },
getInitialState: function() { getInitialState: function() {
@ -52,6 +49,9 @@ module.exports = React.createClass({
levelRoleMap: {}, levelRoleMap: {},
// List of power levels to show in the drop-down // List of power levels to show in the drop-down
options: [], options: [],
customValue: this.props.value,
selectValue: 0,
}; };
}, },
@ -77,61 +77,61 @@ module.exports = React.createClass({
return l === undefined || l <= newProps.maxValue; return l === undefined || l <= newProps.maxValue;
}); });
const isCustom = levelRoleMap[newProps.value] === undefined;
this.setState({ this.setState({
levelRoleMap, levelRoleMap,
options, options,
custom: levelRoleMap[newProps.value] === undefined, custom: isCustom,
customLevel: newProps.value,
selectValue: isCustom ? "SELECT_VALUE_CUSTOM" : newProps.value,
}); });
}, },
onSelectChange: function(event) { onSelectChange: function(event) {
this.setState({ custom: event.target.value === "SELECT_VALUE_CUSTOM" }); const isCustom = event.target.value === "SELECT_VALUE_CUSTOM";
if (event.target.value !== "SELECT_VALUE_CUSTOM") { if (isCustom) {
this.setState({custom: true});
} else {
this.props.onChange(event.target.value, this.props.powerLevelKey); this.props.onChange(event.target.value, this.props.powerLevelKey);
this.setState({selectValue: event.target.value});
} }
}, },
onCustomBlur: function(event) { onCustomChange: function(event) {
this.props.onChange(parseInt(this.refs.custom.value), this.props.powerLevelKey); this.setState({customValue: event.target.value});
}, },
onCustomKeyDown: function(event) { onCustomBlur: function(event) {
if (event.key == "Enter") { event.preventDefault();
this.props.onChange(parseInt(this.refs.custom.value), this.props.powerLevelKey); 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() { render: function() {
let customPicker; let picker;
if (this.state.custom) { if (this.state.custom) {
if (this.props.disabled) { picker = (
customPicker = <span>{ _t( <Field id={`powerSelector_custom_${this.props.powerLevelKey}`} type="number"
"Custom of %(powerLevel)s", label={this.props.label || _t("Power level")} max={this.props.maxValue}
{ powerLevel: this.props.value }, onBlur={this.onCustomBlur} onKeyPress={this.onCustomKeyPress} onChange={this.onCustomChange}
) }</span>; value={this.state.customValue} disabled={this.props.disabled} />
} 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>;
} else { } else {
// Each level must have a definition in this.state.levelRoleMap // Each level must have a definition in this.state.levelRoleMap
let options = this.state.options.map((level) => { 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>; return <option value={op.value} key={op.value}>{ op.text }</option>;
}); });
select = picker = (
<select ref="select" <Field id={`powerSelector_notCustom_${this.props.powerLevelKey}`} element="select"
value={this.props.controlled ? selectValue : undefined} label={this.props.label || _t("Power level")} onChange={this.onSelectChange}
defaultValue={!this.props.controlled ? selectValue : undefined} value={this.state.selectValue} disabled={this.props.disabled}>
onChange={this.onSelectChange}>
{options} {options}
</select>; </Field>
);
} }
return ( return (
<span className="mx_PowerSelector"> <div className="mx_PowerSelector">
{ select } { picker }
{ customPicker } </div>
</span>
); );
}, },
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,7 +19,6 @@ import Promise from 'bluebird';
import sdk from '../../../index'; import sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import UserSettingsStore from '../../../UserSettingsStore';
import SettingsStore, {SettingLevel} from '../../../settings/SettingsStore'; import SettingsStore, {SettingLevel} from '../../../settings/SettingsStore';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { 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) { onEnableEmailNotificationsChange: function(address, checked) {
let emailPusherPromise; let emailPusherPromise;
if (checked) { if (checked) {
const data = {}; const data = {};
data['brand'] = SdkConfig.get().brand || 'Riot'; 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 { } else {
const emailPusher = UserSettingsStore.getEmailPusher(this.state.pushers, address); const emailPusher = this.getEmailPusher(this.state.pushers, address);
emailPusher.kind = null; emailPusher.kind = null;
emailPusherPromise = MatrixClientPeg.get().setPusher(emailPusher); emailPusherPromise = MatrixClientPeg.get().setPusher(emailPusher);
} }
@ -697,7 +723,7 @@ module.exports = React.createClass({
emailNotificationsRow: function(address, label) { emailNotificationsRow: function(address, label) {
return <LabelledToggleSwitch value={this.hasEmailPusher(this.state.pushers, address)} return <LabelledToggleSwitch value={this.hasEmailPusher(this.state.pushers, address)}
onChange={this.onEnableEmailNotificationsChange.bind(this, address)} onChange={this.onEnableEmailNotificationsChange.bind(this, address)}
label={label} />; label={label} key={`emailNotif_${label}`} />;
}, },
render: function() { render: function() {
@ -729,17 +755,15 @@ module.exports = React.createClass({
} }
const emailThreepids = this.state.threepids.filter((tp) => tp.medium === "email"); const emailThreepids = this.state.threepids.filter((tp) => tp.medium === "email");
let emailNotificationsRow; let emailNotificationsRows;
if (emailThreepids.length === 0) { if (emailThreepids.length === 0) {
emailNotificationsRow = <div> emailNotificationsRows = <div>
{ _t('Add an email address to configure email notifications') } { _t('Add an email address to configure email notifications') }
</div>; </div>;
} else { } else {
// This only supports the first email address in your profile for now emailNotificationsRows = emailThreepids.map((threePid) => this.emailNotificationsRow(
emailNotificationsRow = this.emailNotificationsRow( threePid.address, `${_t('Enable email notifications')} (${threePid.address})`,
emailThreepids[0].address, ));
`${_t('Enable email notifications')} (${emailThreepids[0].address})`,
);
} }
// Build external push rules // Build external push rules
@ -823,7 +847,7 @@ module.exports = React.createClass({
onChange={this.onEnableAudioNotificationsChange} onChange={this.onEnableAudioNotificationsChange}
label={_t('Enable audible notifications in web client')} /> label={_t('Enable audible notifications in web client')} />
{ emailNotificationsRow } { emailNotificationsRows }
<div className="mx_UserNotifSettings_pushRulesTableWrapper"> <div className="mx_UserNotifSettings_pushRulesTableWrapper">
<table className="mx_UserNotifSettings_pushRulesTable"> <table className="mx_UserNotifSettings_pushRulesTable">

View file

@ -20,13 +20,22 @@ import MatrixClientPeg from "../../../MatrixClientPeg";
import Field from "../elements/Field"; import Field from "../elements/Field";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import classNames from 'classnames'; import classNames from 'classnames';
import {User} from "matrix-js-sdk";
export default class ProfileSettings extends React.Component { export default class ProfileSettings extends React.Component {
constructor() { constructor() {
super(); super();
const client = MatrixClientPeg.get(); 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; let avatarUrl = user.avatarUrl;
if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false); if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false);
this.state = { this.state = {
@ -72,7 +81,6 @@ export default class ProfileSettings extends React.Component {
newState.avatarFile = null; newState.avatarFile = null;
} }
newState.enableProfileSave = true;
this.setState(newState); this.setState(newState);
}; };

View file

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

View file

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

View file

@ -238,8 +238,10 @@
"Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?", "Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?",
"Unrecognised address": "Unrecognised address", "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.", "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 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", "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", "Unknown server error": "Unknown server error",
"Use a few words, avoid common phrases": "Use a few words, avoid common phrases", "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", "No need for symbols, digits, or uppercase letters": "No need for symbols, digits, or uppercase letters",
@ -586,34 +588,34 @@
"Room Addresses": "Room Addresses", "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?", "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", "URL Previews": "URL Previews",
"To change the room's avatar, you must be a": "To change the room's avatar, you must be a", "Change room avatar": "Change room avatar",
"To change the room's name, you must be a": "To change the room's name, you must be a", "Change room name": "Change room name",
"To change the room's main address, you must be a": "To change the room's main address, you must be a", "Change main address for the room": "Change main address for the room",
"To change the room's history visibility, you must be a": "To change the room's history visibility, you must be a", "Change history visibility": "Change history visibility",
"To change the permissions in the room, you must be a": "To change the permissions in the room, you must be a", "Change permissions": "Change permissions",
"To change the topic, you must be a": "To change the topic, you must be a", "Change topic": "Change topic",
"To modify widgets in the room, you must be a": "To modify widgets in the room, you must be a", "Modify widgets": "Modify widgets",
"Failed to unban": "Failed to unban", "Failed to unban": "Failed to unban",
"Unban": "Unban", "Unban": "Unban",
"Banned by %(displayName)s": "Banned by %(displayName)s", "Banned by %(displayName)s": "Banned by %(displayName)s",
"The default role for new room members is": "The default role for new room members is", "Default role": "Default role",
"To send messages, you must be a": "To send messages, you must be a", "Send messages": "Send messages",
"To invite users into the room, you must be a": "To invite users into the room, you must be a", "Invite users": "Invite users",
"To configure the room, you must be a": "To configure the room, you must be a", "Change settings": "Change settings",
"To kick users, you must be a": "To kick users, you must be a", "Kick users": "Kick users",
"To ban users, you must be a": "To ban users, you must be a", "Ban users": "Ban users",
"To remove other users' messages, you must be a": "To remove other users' messages, you must be a", "Remove messages": "Remove messages",
"To notify everyone in the room, you must be a": "To notify everyone in the room, you must be a", "Notify everyone": "Notify everyone",
"No users have specific privileges in this room": "No users have specific privileges in this room", "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", "Privileged Users": "Privileged Users",
"Muted Users": "Muted Users", "Muted Users": "Muted Users",
"Banned users": "Banned 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", "Roles & Permissions": "Roles & Permissions",
"Permissions": "Permissions", "Permissions": "Permissions",
"Enable encryption?": "Enable encryption?", "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>", "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.", "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", "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.", "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", "Revoke Moderator": "Revoke Moderator",
"Make Moderator": "Make Moderator", "Make Moderator": "Make Moderator",
"Admin Tools": "Admin Tools", "Admin Tools": "Admin Tools",
"Level:": "Level:",
"Close": "Close", "Close": "Close",
"and %(count)s others...|other": "and %(count)s others...", "and %(count)s others...|other": "and %(count)s others...",
"and %(count)s others...|one": "and one other...", "and %(count)s others...|one": "and one other...",
@ -1000,7 +1001,7 @@
"%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s", "%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s",
"collapse": "collapse", "collapse": "collapse",
"expand": "expand", "expand": "expand",
"Custom of %(powerLevel)s": "Custom of %(powerLevel)s", "Power level": "Power level",
"Custom level": "Custom 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.", "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>", "<a>In reply to</a> <pill>": "<a>In reply to</a> <pill>",

View file

@ -71,6 +71,18 @@ class ConsoleLogger {
log(level, ...args) { log(level, ...args) {
// We don't know what locale the user may be running so use ISO strings // We don't know what locale the user may be running so use ISO strings
const ts = new Date().toISOString(); 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 // 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 // so the lines are a little more ugly but easy to implement / quick to
// run. // run.

View file

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

View file

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

View file

@ -101,6 +101,14 @@ export default class MultiInviter {
if (addrType === 'email') { if (addrType === 'email') {
return MatrixClientPeg.get().inviteByEmail(roomId, addr); return MatrixClientPeg.get().inviteByEmail(roomId, addr);
} else if (addrType === 'mx-user-id') { } 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)) { if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) {
try { try {
const profile = await MatrixClientPeg.get().getProfileInfo(addr); const profile = await MatrixClientPeg.get().getProfileInfo(addr);
@ -152,6 +160,8 @@ export default class MultiInviter {
if (err.errcode === 'M_FORBIDDEN') { if (err.errcode === 'M_FORBIDDEN') {
fatal = true; fatal = true;
errorText = _t('You do not have permission to invite people to this room.'); 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') { } else if (err.errcode === 'M_LIMIT_EXCEEDED') {
// we're being throttled so wait a bit & try again // we're being throttled so wait a bit & try again
setTimeout(() => { setTimeout(() => {
@ -166,6 +176,8 @@ export default class MultiInviter {
// Invite without the profile check // Invite without the profile check
console.warn(`User ${address} does not have a profile - inviting anyways automatically`); console.warn(`User ${address} does not have a profile - inviting anyways automatically`);
this._doInvite(address, true).then(resolve, reject); 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 { } else {
errorText = _t('Unknown server error'); errorText = _t('Unknown server error');
} }