Merge remote-tracking branch 'upstream/develop' into hs/upload-limits

This commit is contained in:
Will Hunt 2018-10-15 19:20:55 +01:00
commit 5a72a5863f
232 changed files with 13386 additions and 11189 deletions

View file

@ -64,7 +64,9 @@ export default class ContextualMenu extends React.Component {
// The component to render as the context menu
elementClass: PropTypes.element.isRequired,
// on resize callback
windowResize: PropTypes.func
windowResize: PropTypes.func,
// method to close menu
closeMenu: PropTypes.func,
};
constructor() {
@ -73,6 +75,7 @@ export default class ContextualMenu extends React.Component {
contextMenuRect: null,
};
this.onContextMenu = this.onContextMenu.bind(this);
this.collectContextMenuRect = this.collectContextMenuRect.bind(this);
}
@ -85,6 +88,28 @@ export default class ContextualMenu extends React.Component {
});
}
onContextMenu(e) {
if (this.props.closeMenu) {
this.props.closeMenu();
e.preventDefault();
const x = e.clientX;
const y = e.clientY;
// XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst
// a context menu and its click-guard are up without completely rewriting how the context menus work.
setImmediate(() => {
const clickEvent = document.createEvent('MouseEvents');
clickEvent.initMouseEvent(
'contextmenu', true, true, window, 0,
0, 0, x, y, false, false,
false, false, 0, null,
);
document.elementFromPoint(x, y).dispatchEvent(clickEvent);
});
}
}
render() {
const position = {};
let chevronFace = null;
@ -195,7 +220,8 @@ export default class ContextualMenu extends React.Component {
{ chevron }
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
</div>
{ props.hasBackground && <div className="mx_ContextualMenu_background" onClick={props.closeMenu} /> }
{ props.hasBackground && <div className="mx_ContextualMenu_background"
onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
<style>{ chevronCSS }</style>
</div>;
}

View file

@ -480,7 +480,7 @@ export default React.createClass({
group_id: groupId,
},
});
dis.dispatch({action: 'view_set_mxid'});
dis.dispatch({action: 'require_registration'});
willDoOnboarding = true;
}
this.setState({
@ -723,6 +723,11 @@ export default React.createClass({
},
_onJoinClick: async function() {
if (this._matrixClient.isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
this.setState({membershipBusy: true});
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -30,10 +30,16 @@ import dis from '../../dispatcher';
import sessionStore from '../../stores/SessionStore';
import MatrixClientPeg from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "../../stores/RoomListStore";
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
// We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity.
// NB. this is just for server notices rather than pinned messages in general.
const MAX_PINNED_NOTICES_PER_ROOM = 2;
/**
* This is what our MatrixChat shows when we are logged in. The precise view is
* determined by the page_type property.
@ -80,6 +86,8 @@ const LoggedInView = React.createClass({
return {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
// any currently active server notice events
serverNoticeEvents: [],
};
},
@ -97,12 +105,18 @@ const LoggedInView = React.createClass({
);
this._setStateFromSessionStore();
this._updateServerNoticeEvents();
this._matrixClient.on("accountData", this.onAccountData);
this._matrixClient.on("sync", this.onSync);
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
},
componentWillUnmount: function() {
document.removeEventListener('keydown', this._onKeyDown);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
if (this._sessionStoreToken) {
this._sessionStoreToken.remove();
}
@ -142,6 +156,56 @@ const LoggedInView = React.createClass({
}
},
onSync: function(syncState, oldSyncState, data) {
const oldErrCode = this.state.syncErrorData && this.state.syncErrorData.error && this.state.syncErrorData.error.errcode;
const newErrCode = data && data.error && data.error.errcode;
if (syncState === oldSyncState && oldErrCode === newErrCode) return;
if (syncState === 'ERROR') {
this.setState({
syncErrorData: data,
});
} else {
this.setState({
syncErrorData: null,
});
}
if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') {
this._updateServerNoticeEvents();
}
},
onRoomStateEvents: function(ev, state) {
const roomLists = RoomListStore.getRoomLists();
if (roomLists['m.server_notice'] && roomLists['m.server_notice'].some(r => r.roomId === ev.getRoomId())) {
this._updateServerNoticeEvents();
}
},
_updateServerNoticeEvents: async function() {
const roomLists = RoomListStore.getRoomLists();
if (!roomLists['m.server_notice']) return [];
const pinnedEvents = [];
for (const room of roomLists['m.server_notice']) {
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
for (const eventId of pinnedEventIds) {
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0);
const ev = timeline.getEvents().find(ev => ev.getId() === eventId);
if (ev) pinnedEvents.push(ev);
}
}
this.setState({
serverNoticeEvents: pinnedEvents,
});
},
_onKeyDown: function(ev) {
/*
// Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers
@ -255,20 +319,46 @@ const LoggedInView = React.createClass({
), true);
},
_onClick: function(ev) {
_onMouseDown: function(ev) {
// When the panels are disabled, clicking on them results in a mouse event
// which bubbles to certain elements in the tree. When this happens, close
// any settings page that is currently open (user/room/group).
if (this.props.leftDisabled &&
this.props.rightDisabled &&
(
ev.target.className === 'mx_MatrixChat' ||
ev.target.className === 'mx_MatrixChat_middlePanel' ||
ev.target.className === 'mx_RoomView'
)
) {
if (this.props.leftDisabled && this.props.rightDisabled) {
const targetClasses = new Set(ev.target.className.split(' '));
if (
targetClasses.has('mx_MatrixChat') ||
targetClasses.has('mx_MatrixChat_middlePanel') ||
targetClasses.has('mx_RoomView')
) {
this.setState({
mouseDown: {
x: ev.pageX,
y: ev.pageY,
},
});
}
}
},
_onMouseUp: function(ev) {
if (!this.state.mouseDown) return;
const deltaX = ev.pageX - this.state.mouseDown.x;
const deltaY = ev.pageY - this.state.mouseDown.y;
const distance = Math.sqrt((deltaX * deltaX) + (deltaY + deltaY));
const maxRadius = 5; // People shouldn't be straying too far, hopefully
// Note: we track how far the user moved their mouse to help
// combat against https://github.com/vector-im/riot-web/issues/7158
if (distance < maxRadius) {
// This is probably a real click, and not a drag
dis.dispatch({ action: 'close_settings' });
}
// Always clear the mouseDown state to ensure we don't accidentally
// use stale values due to the mouseDown checks.
this.setState({mouseDown: null});
},
render: function() {
@ -286,6 +376,7 @@ const LoggedInView = React.createClass({
const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
const UpdateCheckBar = sdk.getComponent('globals.UpdateCheckBar');
const PasswordNagBar = sdk.getComponent('globals.PasswordNagBar');
const ServerLimitBar = sdk.getComponent('globals.ServerLimitBar');
let page_element;
let right_panel = '';
@ -368,9 +459,26 @@ const LoggedInView = React.createClass({
break;
}
const usageLimitEvent = this.state.serverNoticeEvents.find((e) => {
return (
e && e.getType() === 'm.room.message' &&
e.getContent()['server_notice_type'] === 'm.server_notice.usage_limit_reached'
);
});
let topBar;
const isGuest = this.props.matrixClient.isGuest();
if (this.props.showCookieBar &&
if (this.state.syncErrorData && this.state.syncErrorData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
topBar = <ServerLimitBar kind='hard'
adminContact={this.state.syncErrorData.error.data.admin_contact}
limitType={this.state.syncErrorData.error.data.limit_type}
/>;
} else if (usageLimitEvent) {
topBar = <ServerLimitBar kind='soft'
adminContact={usageLimitEvent.getContent().admin_contact}
limitType={usageLimitEvent.getContent().limit_type}
/>;
} else if (this.props.showCookieBar &&
this.props.config.piwik
) {
const policyUrl = this.props.config.piwik.policyUrl || null;
@ -396,7 +504,7 @@ const LoggedInView = React.createClass({
}
return (
<div className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} onClick={this._onClick}>
<div className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} onMouseDown={this._onMouseDown} onMouseUp={this._onMouseUp}>
{ topBar }
<DragDropContext onDragEnd={this._onDragEnd}>
<div className={bodyClasses}>

View file

@ -23,7 +23,7 @@ import PropTypes from 'prop-types';
import Matrix from "matrix-js-sdk";
import Analytics from "../../Analytics";
import DecryptionFailureTracker from "../../DecryptionFailureTracker";
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import MatrixClientPeg from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
@ -45,6 +45,12 @@ import createRoom from "../../createRoom";
import KeyRequestHandler from '../../KeyRequestHandler';
import { _t, getCurrentLanguage } from '../../languageHandler';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils';
// Disable warnings for now: we use deprecated bluebird functions
// and need to migrate, but they spam the console with warnings.
Promise.config({warnings: false});
/** constants for MatrixChat.state.view */
const VIEWS = {
@ -178,6 +184,8 @@ export default React.createClass({
// When showing Modal dialogs we need to set aria-hidden on the root app element
// and disable it when there are no dialogs
hideToSRUsers: false,
syncError: null, // If the current syncing status is ERROR, the error object, otherwise null.
};
return s;
},
@ -282,6 +290,14 @@ export default React.createClass({
register_hs_url: paramHs,
});
}
// Set a default IS with query param `is_url`
const paramIs = this.props.startingFragmentQueryParams.is_url;
if (paramIs) {
console.log('Setting register_is_url ', paramIs);
this.setState({
register_is_url: paramIs,
});
}
// a thing to call showScreen with once login completes. this is kept
// outside this.state because updating it should never trigger a
@ -471,7 +487,7 @@ export default React.createClass({
action: 'do_after_sync_prepared',
deferred_action: payload,
});
dis.dispatch({action: 'view_set_mxid'});
dis.dispatch({action: 'require_registration'});
return;
}
@ -479,7 +495,11 @@ export default React.createClass({
case 'logout':
Lifecycle.logout();
break;
case 'require_registration':
startAnyRegistrationFlow(payload);
break;
case 'start_registration':
// This starts the full registration flow
this._startRegistration(payload.params || {});
break;
case 'start_login':
@ -945,7 +965,7 @@ export default React.createClass({
});
}
dis.dispatch({
action: 'view_set_mxid',
action: 'require_registration',
// If the set_mxid dialog is cancelled, view /home because if the browser
// was pointing at /user/@someone:domain?action=chat, the URL needs to be
// reset so that they can revisit /user/.. // (and trigger
@ -1132,7 +1152,7 @@ export default React.createClass({
*
* @param {string} teamToken
*/
_onLoggedIn: function(teamToken) {
_onLoggedIn: async function(teamToken) {
this.setState({
view: VIEWS.LOGGED_IN,
});
@ -1145,12 +1165,17 @@ export default React.createClass({
this._is_registered = false;
if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) {
createRoom({
const roomId = await createRoom({
dmUserId: this.props.config.welcomeUserId,
// Only view the welcome user if we're NOT looking at a room
andView: !this.state.currentRoomId,
});
return;
// if successful, return because we're already
// viewing the welcomeUserId room
// else, if failed, fall through to view_home_page
if (roomId) {
return;
}
}
// The user has just logged in after registering
dis.dispatch({action: 'view_home_page'});
@ -1232,13 +1257,23 @@ export default React.createClass({
return self._loggedInView.child.canResetTimelineInRoom(roomId);
});
cli.on('sync', function(state, prevState) {
cli.on('sync', function(state, prevState, data) {
// LifecycleStore and others cannot directly subscribe to matrix client for
// events because flux only allows store state changes during flux dispatches.
// So dispatch directly from here. Ideally we'd use a SyncStateStore that
// would do this dispatch and expose the sync state itself (by listening to
// its own dispatch).
dis.dispatch({action: 'sync_state', prevState, state});
if (state === "ERROR" || state === "RECONNECTING") {
if (data.error instanceof Matrix.InvalidStoreError) {
Lifecycle.handleInvalidStoreError(data.error);
}
self.setState({syncError: data.error || true});
} else if (self.state.syncError) {
self.setState({syncError: null});
}
self.updateStatusIndicator(state, prevState);
if (state === "SYNCING" && prevState === "SYNCING") {
return;
@ -1262,6 +1297,7 @@ export default React.createClass({
}, true);
});
cli.on('Session.logged_out', function(call) {
if (Lifecycle.isLoggingOut()) return;
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Signed out', '', ErrorDialog, {
title: _t('Signed Out'),
@ -1304,9 +1340,20 @@ export default React.createClass({
}
});
const dft = new DecryptionFailureTracker((failure) => {
// TODO: Pass reason for failure as third argument to trackEvent
Analytics.trackEvent('E2E', 'Decryption failure');
const dft = new DecryptionFailureTracker((total, errorCode) => {
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total);
}, (errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation
switch (errorCode) {
case 'MEGOLM_UNKNOWN_INBOUND_SESSION_ID':
return 'olm_keys_not_sent_error';
case 'OLM_UNKNOWN_MESSAGE_INDEX':
return 'olm_index_error';
case undefined:
return 'unexpected_error';
default:
return 'unspecified_error';
}
});
// Shelved for later date when we have time to think about persisting history of
@ -1317,7 +1364,7 @@ export default React.createClass({
// When logging out, stop tracking failures and destroy state
cli.on("Session.logged_out", () => dft.stop());
cli.on("Event.decrypted", (e) => dft.eventDecrypted(e));
cli.on("Event.decrypted", (e, err) => dft.eventDecrypted(e, err));
const krh = new KeyRequestHandler(cli);
cli.on("crypto.roomKeyRequest", (req) => {
@ -1406,7 +1453,7 @@ export default React.createClass({
} else if (screen == 'start') {
this.showScreen('home');
dis.dispatch({
action: 'view_set_mxid',
action: 'require_registration',
});
} else if (screen == 'directory') {
dis.dispatch({
@ -1698,10 +1745,14 @@ export default React.createClass({
}
if (this.state.view === VIEWS.LOGGED_IN) {
// store errors stop the client syncing and require user intervention, so we'll
// be showing a dialog. Don't show anything else.
const isStoreError = this.state.syncError && this.state.syncError instanceof Matrix.InvalidStoreError;
// `ready` and `view==LOGGED_IN` may be set before `page_type` (because the
// latter is set via the dispatcher). If we don't yet have a `page_type`,
// keep showing the spinner for now.
if (this.state.ready && this.state.page_type) {
if (this.state.ready && this.state.page_type && !isStoreError) {
/* for now, we stuff the entirety of our props and state into the LoggedInView.
* we should go through and figure out what we actually need to pass down, as well
* as using something like redux to avoid having a billion bits of state kicking around.
@ -1722,8 +1773,15 @@ export default React.createClass({
} else {
// we think we are logged in, but are still waiting for the /sync to complete
const Spinner = sdk.getComponent('elements.Spinner');
let errorBox;
if (this.state.syncError && !isStoreError) {
errorBox = <div className="mx_MatrixChat_syncError">
{messageForSyncError(this.state.syncError)}
</div>;
}
return (
<div className="mx_MatrixChat_splash">
{errorBox}
<Spinner />
<a href="#" className="mx_MatrixChat_splashButtons" onClick={this.onLogoutClick}>
{ _t('Logout') }

View file

@ -542,7 +542,7 @@ module.exports = React.createClass({
},
// get a list of read receipts that should be shown next to this event
// Receipts are objects which have a 'roomMember' and 'ts'.
// Receipts are objects which have a 'userId', 'roomMember' and 'ts'.
_getReadReceiptsForEvent: function(event) {
const myUserId = MatrixClientPeg.get().credentials.userId;
@ -560,10 +560,8 @@ module.exports = React.createClass({
return; // ignore ignored users
}
const member = room.getMember(r.userId);
if (!member) {
return; // ignore unknown user IDs
}
receipts.push({
userId: r.userId,
roomMember: member,
ts: r.data ? r.data.ts : 0,
});

View file

@ -51,6 +51,7 @@ class HeaderButton extends React.Component {
return <AccessibleButton
aria-label={this.props.title}
aria-expanded={this.props.isHighlighted}
title={this.props.title}
className="mx_RightPanel_headerButton"
onClick={this.onClick} >
@ -160,7 +161,7 @@ module.exports = React.createClass({
onInviteButtonClick: function() {
if (this.context.matrixClient.isGuest()) {
dis.dispatch({action: 'view_set_mxid'});
dis.dispatch({action: 'require_registration'});
return;
}
@ -186,6 +187,9 @@ module.exports = React.createClass({
},
onRoomStateMember: function(ev, state, member) {
if (member.roomId !== this.props.roomId) {
return;
}
// redraw the badge on the membership list
if (this.state.phase === this.Phase.RoomMemberList && member.roomId === this.props.roomId) {
this._delayedUpdate();
@ -280,7 +284,7 @@ module.exports = React.createClass({
const room = cli.getRoom(this.props.roomId);
let isUserInRoom;
if (room) {
const numMembers = room.getJoinedMembers().length;
const numMembers = room.getJoinedMemberCount();
membersTitle = _t('%(count)s Members', { count: numMembers });
membersBadge = <div title={membersTitle}>{ formatCount(numMembers) }</div>;
isUserInRoom = room.hasMembershipState(this.context.matrixClient.credentials.userId, 'join');
@ -342,11 +346,11 @@ module.exports = React.createClass({
// being put in the RoomHeader or GroupView header, so only show the minimise
// button on these 2 screens or you won't be able to re-expand the panel.
headerButtons.push(
<div className="mx_RightPanel_headerButton mx_RightPanel_collapsebutton" key="_minimizeButton"
<AccessibleButton className="mx_RightPanel_headerButton mx_RightPanel_collapsebutton" key="_minimizeButton"
title={_t("Hide panel")} aria-label={_t("Hide panel")} onClick={this.onCollapseClick}
>
<TintableSvg src="img/minimise.svg" width="10" height="16" />
</div>,
<TintableSvg src="img/minimise.svg" width="10" height="16" alt="" />
</AccessibleButton>,
);
}

View file

@ -354,7 +354,7 @@ module.exports = React.createClass({
// to the directory.
if (MatrixClientPeg.get().isGuest()) {
if (!room.world_readable && !room.guest_can_join) {
dis.dispatch({action: 'view_set_mxid'});
dis.dispatch({action: 'require_registration'});
return;
}
}

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,13 +18,15 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
import { _t } from '../../languageHandler';
import { _t, _td } from '../../languageHandler';
import sdk from '../../index';
import WhoIsTyping from '../../WhoIsTyping';
import MatrixClientPeg from '../../MatrixClientPeg';
import MemberAvatar from '../views/avatars/MemberAvatar';
import Resend from '../../Resend';
import * as cryptodevices from '../../cryptodevices';
import dis from '../../dispatcher';
import { messageForResourceLimitError } from '../../utils/ErrorUtils';
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
@ -106,6 +108,7 @@ module.exports = React.createClass({
getInitialState: function() {
return {
syncState: MatrixClientPeg.get().getSyncState(),
syncStateData: MatrixClientPeg.get().getSyncStateData(),
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
unsentMessages: getUnsentMessages(this.props.room),
};
@ -133,12 +136,13 @@ module.exports = React.createClass({
}
},
onSyncStateChange: function(state, prevState) {
onSyncStateChange: function(state, prevState, data) {
if (state === "SYNCING" && prevState === "SYNCING") {
return;
}
this.setState({
syncState: state,
syncStateData: data,
});
},
@ -157,10 +161,12 @@ module.exports = React.createClass({
_onResendAllClick: function() {
Resend.resendUnsentEvents(this.props.room);
dis.dispatch({action: 'focus_composer'});
},
_onCancelAllClick: function() {
Resend.cancelUnsentEvents(this.props.room);
dis.dispatch({action: 'focus_composer'});
},
_onShowDevicesClick: function() {
@ -188,7 +194,7 @@ module.exports = React.createClass({
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
_getSize: function() {
if (this.state.syncState === "ERROR" ||
if (this._shouldShowConnectionError() ||
(this.state.usersTyping.length > 0) ||
this.props.numUnreadMessages ||
!this.props.atEndOfLiveTimeline ||
@ -217,14 +223,15 @@ module.exports = React.createClass({
);
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
if (!this.props.atEndOfLiveTimeline) {
return (
<div className="mx_RoomStatusBar_scrollDownIndicator"
<AccessibleButton className="mx_RoomStatusBar_scrollDownIndicator"
onClick={this.props.onScrollToBottomClick}>
<img src="img/scrolldown.svg" width="24" height="24"
alt={_t("Scroll to bottom of page")}
title={_t("Scroll to bottom of page")} />
</div>
</AccessibleButton>
);
}
@ -235,7 +242,7 @@ module.exports = React.createClass({
);
}
if (this.state.syncState === "ERROR") {
if (this._shouldShowConnectionError()) {
return null;
}
@ -282,6 +289,21 @@ module.exports = React.createClass({
return avatars;
},
_shouldShowConnectionError: function() {
// no conn bar trumps unread count since you can't get unread messages
// without a connection! (technically may already have some but meh)
// It also trumps the "some not sent" msg since you can't resend without
// a connection!
// There's one situation in which we don't show this 'no connection' bar, and that's
// if it's a resource limit exceeded error: those are shown in the top bar.
const errorIsMauError = Boolean(
this.state.syncStateData &&
this.state.syncStateData.error &&
this.state.syncStateData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED'
);
return this.state.syncState === "ERROR" && !errorIsMauError;
},
_getUnsentMessageContent: function() {
const unsentMessages = this.state.unsentMessages;
if (!unsentMessages.length) return null;
@ -305,7 +327,43 @@ module.exports = React.createClass({
},
);
} else {
if (
let consentError = null;
let resourceLimitError = null;
for (const m of unsentMessages) {
if (m.error && m.error.errcode === 'M_CONSENT_NOT_GIVEN') {
consentError = m.error;
break;
} else if (m.error && m.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
resourceLimitError = m.error;
break;
}
}
if (consentError) {
title = _t(
"You can't send any messages until you review and agree to " +
"<consentLink>our terms and conditions</consentLink>.",
{},
{
'consentLink': (sub) =>
<a href={consentError.data && consentError.data.consent_uri} target="_blank">
{ sub }
</a>,
},
);
} else if (resourceLimitError) {
title = messageForResourceLimitError(
resourceLimitError.data.limit_type,
resourceLimitError.data.admin_contact, {
'monthly_active_user': _td(
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
'': _td(
"Your message wasn't sent because this homeserver has exceeded a resource limit. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
});
} else if (
unsentMessages.length === 1 &&
unsentMessages[0].error &&
unsentMessages[0].error.data &&
@ -328,12 +386,14 @@ module.exports = React.createClass({
}
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title={_t("Warning")} alt={_t("Warning")} />
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
<img src="img/warning.svg" width="24" height="23" title={_t("Warning")} alt="" />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
</div>
</div>
</div>;
},
@ -342,19 +402,17 @@ module.exports = React.createClass({
_getContent: function() {
const EmojiText = sdk.getComponent('elements.EmojiText');
// no conn bar trumps unread count since you can't get unread messages
// without a connection! (technically may already have some but meh)
// It also trumps the "some not sent" msg since you can't resend without
// a connection!
if (this.state.syncState === "ERROR") {
if (this._shouldShowConnectionError()) {
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ " />
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
</div>
</div>
</div>
);
@ -428,7 +486,9 @@ module.exports = React.createClass({
<div className="mx_RoomStatusBar_indicator">
{ indicator }
</div>
{ content }
<div role="alert">
{ content }
</div>
</div>
);
},

View file

@ -1,6 +1,7 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,56 +16,53 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
var ReactDOM = require('react-dom');
var classNames = require('classnames');
var sdk = require('../../index');
import React from 'react';
import classNames from 'classnames';
import sdk from '../../index';
import { Droppable } from 'react-beautiful-dnd';
import { _t } from '../../languageHandler';
var dis = require('../../dispatcher');
var Unread = require('../../Unread');
var MatrixClientPeg = require('../../MatrixClientPeg');
var RoomNotifs = require('../../RoomNotifs');
var FormattingUtils = require('../../utils/FormattingUtils');
var AccessibleButton = require('../../components/views/elements/AccessibleButton');
import Modal from '../../Modal';
import dis from '../../dispatcher';
import Unread from '../../Unread';
import * as RoomNotifs from '../../RoomNotifs';
import * as FormattingUtils from '../../utils/FormattingUtils';
import { KeyCode } from '../../Keyboard';
import { Group } from 'matrix-js-sdk';
import PropTypes from 'prop-types';
// turn this on for drop & drag console debugging galore
var debug = false;
const debug = false;
const TRUNCATE_AT = 10;
var RoomSubList = React.createClass({
const RoomSubList = React.createClass({
displayName: 'RoomSubList',
debug: debug,
propTypes: {
list: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
label: React.PropTypes.string.isRequired,
tagName: React.PropTypes.string,
editable: React.PropTypes.bool,
list: PropTypes.arrayOf(PropTypes.object).isRequired,
label: PropTypes.string.isRequired,
tagName: PropTypes.string,
editable: PropTypes.bool,
order: React.PropTypes.string.isRequired,
order: PropTypes.string.isRequired,
// passed through to RoomTile and used to highlight room with `!` regardless of notifications count
isInvite: React.PropTypes.bool,
isInvite: PropTypes.bool,
startAsHidden: React.PropTypes.bool,
showSpinner: React.PropTypes.bool, // true to show a spinner if 0 elements when expanded
collapsed: React.PropTypes.bool.isRequired, // is LeftPanel collapsed?
onHeaderClick: React.PropTypes.func,
alwaysShowHeader: React.PropTypes.bool,
incomingCall: React.PropTypes.object,
onShowMoreRooms: React.PropTypes.func,
searchFilter: React.PropTypes.string,
emptyContent: React.PropTypes.node, // content shown if the list is empty
headerItems: React.PropTypes.node, // content shown in the sublist header
extraTiles: React.PropTypes.arrayOf(React.PropTypes.node), // extra elements added beneath tiles
startAsHidden: PropTypes.bool,
showSpinner: PropTypes.bool, // true to show a spinner if 0 elements when expanded
collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed?
onHeaderClick: PropTypes.func,
alwaysShowHeader: PropTypes.bool,
incomingCall: PropTypes.object,
onShowMoreRooms: PropTypes.func,
searchFilter: PropTypes.string,
emptyContent: PropTypes.node, // content shown if the list is empty
headerItems: PropTypes.node, // content shown in the sublist header
extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles
showEmpty: PropTypes.bool,
},
getInitialState: function() {
@ -77,10 +75,13 @@ var RoomSubList = React.createClass({
getDefaultProps: function() {
return {
onHeaderClick: function() {}, // NOP
onShowMoreRooms: function() {}, // NOP
onHeaderClick: function() {
}, // NOP
onShowMoreRooms: function() {
}, // NOP
extraTiles: [],
isInvite: false,
showEmpty: true,
};
},
@ -115,7 +116,7 @@ var RoomSubList = React.createClass({
// The header is collapsable if it is hidden or not stuck
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
isCollapsableOnClick: function() {
var stuck = this.refs.header.dataset.stuck;
const stuck = this.refs.header.dataset.stuck;
if (this.state.hidden || stuck === undefined || stuck === "none") {
return true;
} else {
@ -141,12 +142,12 @@ var RoomSubList = React.createClass({
onClick: function(ev) {
if (this.isCollapsableOnClick()) {
// The header isCollapsable, so the click is to be interpreted as collapse and truncation logic
var isHidden = !this.state.hidden;
this.setState({ hidden : isHidden });
const isHidden = !this.state.hidden;
this.setState({hidden: isHidden});
if (isHidden) {
// as good a way as any to reset the truncate state
this.setState({ truncateAt : TRUNCATE_AT });
this.setState({truncateAt: TRUNCATE_AT});
}
this.props.onShowMoreRooms();
@ -161,7 +162,7 @@ var RoomSubList = React.createClass({
dis.dispatch({
action: 'view_room',
room_id: roomId,
clear_search: (ev && (ev.keyCode == KeyCode.ENTER || ev.keyCode == KeyCode.SPACE)),
clear_search: (ev && (ev.keyCode === KeyCode.ENTER || ev.keyCode === KeyCode.SPACE)),
});
},
@ -171,17 +172,17 @@ var RoomSubList = React.createClass({
},
_shouldShowMentionBadge: function(roomNotifState) {
return roomNotifState != RoomNotifs.MUTE;
return roomNotifState !== RoomNotifs.MUTE;
},
/**
* Total up all the notification counts from the rooms
*
* @param {Number} If supplied will only total notifications for rooms outside the truncation number
* @param {Number} truncateAt If supplied will only total notifications for rooms outside the truncation number
* @returns {Array} The array takes the form [total, highlight] where highlight is a bool
*/
roomNotificationCount: function(truncateAt) {
var self = this;
const self = this;
if (this.props.isInvite) {
return [0, true];
@ -189,9 +190,9 @@ var RoomSubList = React.createClass({
return this.props.list.reduce(function(result, room, index) {
if (truncateAt === undefined || index >= truncateAt) {
var roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
var highlight = room.getUnreadNotificationCount('highlight') > 0;
var notificationCount = room.getUnreadNotificationCount();
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && self._shouldShowMentionBadge(roomNotifState);
@ -240,38 +241,83 @@ var RoomSubList = React.createClass({
});
},
_onNotifBadgeClick: function(e) {
// prevent the roomsublist collapsing
e.preventDefault();
e.stopPropagation();
// find first room which has notifications and switch to it
for (const room of this.state.sortedList) {
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && this._shouldShowMentionBadge(roomNotifState);
if (notifBadges || mentionBadges) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
});
return;
}
}
},
_onInviteBadgeClick: function(e) {
// prevent the roomsublist collapsing
e.preventDefault();
e.stopPropagation();
// switch to first room in sortedList as that'll be the top of the list for the user
if (this.state.sortedList && this.state.sortedList.length > 0) {
dis.dispatch({
action: 'view_room',
room_id: this.state.sortedList[0].roomId,
});
} else if (this.props.extraTiles && this.props.extraTiles.length > 0) {
// Group Invites are different in that they are all extra tiles and not rooms
// XXX: this is a horrible special case because Group Invite sublist is a hack
if (this.props.extraTiles[0].props && this.props.extraTiles[0].props.group instanceof Group) {
dis.dispatch({
action: 'view_group',
group_id: this.props.extraTiles[0].props.group.groupId,
});
}
}
},
_getHeaderJsx: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg");
const subListNotifications = this.roomNotificationCount();
const subListNotifCount = subListNotifications[0];
const subListNotifHighlight = subListNotifications[1];
var subListNotifications = this.roomNotificationCount();
var subListNotifCount = subListNotifications[0];
var subListNotifHighlight = subListNotifications[1];
const totalTiles = this.props.list.length + (this.props.extraTiles || []).length;
const roomCount = totalTiles > 0 ? totalTiles : '';
var totalTiles = this.props.list.length + (this.props.extraTiles || []).length;
var roomCount = totalTiles > 0 ? totalTiles : '';
var chevronClasses = classNames({
const chevronClasses = classNames({
'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': this.state.hidden,
'mx_RoomSubList_chevronDown': !this.state.hidden,
});
var badgeClasses = classNames({
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
var badge;
let badge;
if (subListNotifCount > 0) {
badge = <div className={badgeClasses}>{ FormattingUtils.formatCount(subListNotifCount) }</div>;
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>;
} else if (this.props.isInvite) {
// no notifications but highlight anyway because this is an invite badge
badge = <div className={badgeClasses}>!</div>;
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>!</div>;
}
// When collapsed, allow a long hover on the header to show user
// the full tag name and room count
var title;
let title;
if (this.props.collapsed) {
title = this.props.label;
if (roomCount !== '') {
@ -279,63 +325,66 @@ var RoomSubList = React.createClass({
}
}
var incomingCall;
let incomingCall;
if (this.props.incomingCall) {
var self = this;
const self = this;
// Check if the incoming call is for this section
var incomingCallRoom = this.props.list.filter(function(room) {
const incomingCallRoom = this.props.list.filter(function(room) {
return self.props.incomingCall.roomId === room.roomId;
});
if (incomingCallRoom.length === 1) {
var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
incomingCall = <IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={ this.props.incomingCall }/>;
const IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
incomingCall =
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
}
}
var tabindex = this.props.searchFilter === "" ? "0" : "-1";
const tabindex = this.props.searchFilter === "" ? "0" : "-1";
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header">
<AccessibleButton onClick={ this.onClick } className="mx_RoomSubList_label" tabIndex={tabindex}>
{ this.props.collapsed ? '' : this.props.label }
<div className="mx_RoomSubList_roomCount">{ roomCount }</div>
<div className={chevronClasses}></div>
{ badge }
{ incomingCall }
<div className="mx_RoomSubList_labelContainer" title={title} ref="header">
<AccessibleButton onClick={this.onClick} className="mx_RoomSubList_label" tabIndex={tabindex}>
{this.props.collapsed ? '' : this.props.label}
<div className="mx_RoomSubList_roomCount">{roomCount}</div>
<div className={chevronClasses} />
{badge}
{incomingCall}
</AccessibleButton>
</div>
);
},
_createOverflowTile: function(overflowCount, totalCount) {
var content = <div className="mx_RoomSubList_chevronDown"></div>;
let content = <div className="mx_RoomSubList_chevronDown" />;
var overflowNotifications = this.roomNotificationCount(TRUNCATE_AT);
var overflowNotifCount = overflowNotifications[0];
var overflowNotifHighlight = overflowNotifications[1];
const overflowNotifications = this.roomNotificationCount(TRUNCATE_AT);
const overflowNotifCount = overflowNotifications[0];
const overflowNotifHighlight = overflowNotifications[1];
if (overflowNotifCount && !this.props.collapsed) {
content = FormattingUtils.formatCount(overflowNotifCount);
}
var badgeClasses = classNames({
const badgeClasses = classNames({
'mx_RoomSubList_moreBadge': true,
'mx_RoomSubList_moreBadgeNotify': overflowNotifCount && !this.props.collapsed,
'mx_RoomSubList_moreBadgeHighlight': overflowNotifHighlight && !this.props.collapsed,
});
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton className="mx_RoomSubList_ellipsis" onClick={this._showFullMemberList}>
<div className="mx_RoomSubList_line"></div>
<div className="mx_RoomSubList_more">{ _t("more") }</div>
<div className={ badgeClasses }>{ content }</div>
<div className="mx_RoomSubList_line" />
<div className="mx_RoomSubList_more">{_t("more")}</div>
<div className={badgeClasses}>{content}</div>
</AccessibleButton>
);
},
_showFullMemberList: function() {
this.setState({
truncateAt: -1
truncateAt: -1,
});
this.props.onShowMoreRooms();
@ -343,37 +392,51 @@ var RoomSubList = React.createClass({
},
render: function() {
var connectDropTarget = this.props.connectDropTarget;
var TruncatedList = sdk.getComponent('elements.TruncatedList');
var label = this.props.collapsed ? null : this.props.label;
const TruncatedList = sdk.getComponent('elements.TruncatedList');
let content;
if (this.state.sortedList.length === 0 && !this.props.searchFilter && this.props.extraTiles.length === 0) {
content = this.props.emptyContent;
if (this.props.showEmpty) {
// this is new behaviour with still controversial UX in that in hiding RoomSubLists the drop zones for DnD
// are also gone so when filtering users can't DnD rooms to some tags but is a lot cleaner otherwise.
if (this.state.sortedList.length === 0 && !this.props.searchFilter && this.props.extraTiles.length === 0) {
content = this.props.emptyContent;
} else {
content = this.makeRoomTiles();
content.push(...this.props.extraTiles);
}
} else {
content = this.makeRoomTiles();
content.push(...this.props.extraTiles);
if (this.state.sortedList.length === 0 && this.props.extraTiles.length === 0) {
// if no search filter is applied and there is a placeholder defined then show it, otherwise show nothing
if (!this.props.searchFilter && this.props.emptyContent) {
content = this.props.emptyContent;
} else {
// don't show an empty sublist
return null;
}
} else {
content = this.makeRoomTiles();
content.push(...this.props.extraTiles);
}
}
if (this.state.sortedList.length > 0 || this.props.extraTiles.length > 0 || this.props.editable) {
var subList;
var classes = "mx_RoomSubList";
let subList;
const classes = "mx_RoomSubList";
if (!this.state.hidden) {
subList = <TruncatedList className={ classes } truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile} >
{ content }
</TruncatedList>;
}
else {
subList = <TruncatedList className={ classes }>
</TruncatedList>;
subList = <TruncatedList className={classes} truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}>
{content}
</TruncatedList>;
} else {
subList = <TruncatedList className={classes}>
</TruncatedList>;
}
const subListContent = <div>
{ this._getHeaderJsx() }
{ subList }
{this._getHeaderJsx()}
{subList}
</div>;
return this.props.editable ?
@ -381,23 +444,26 @@ var RoomSubList = React.createClass({
droppableId={"room-sub-list-droppable_" + this.props.tagName}
type="draggable-RoomTile"
>
{ (provided, snapshot) => (
{(provided, snapshot) => (
<div ref={provided.innerRef}>
{ subListContent }
{subListContent}
</div>
) }
)}
</Droppable> : subListContent;
}
else {
var Loader = sdk.getComponent("elements.Spinner");
} else {
const Loader = sdk.getComponent("elements.Spinner");
if (this.props.showSpinner) {
content = <Loader />;
}
return (
<div className="mx_RoomSubList">
{ this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined }
{ (this.props.showSpinner && !this.state.hidden) ? <Loader /> : undefined }
{this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined}
{ this.state.hidden ? undefined : content }
</div>
);
}
}
},
});
module.exports = RoomSubList;

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -45,7 +46,9 @@ import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import WidgetUtils from '../../utils/WidgetUtils';
const DEBUG = false;
let debuglog = function() {};
@ -89,6 +92,7 @@ module.exports = React.createClass({
},
getInitialState: function() {
const llMembers = MatrixClientPeg.get().hasLazyLoadMembersEnabled();
return {
room: null,
roomId: null,
@ -99,6 +103,9 @@ module.exports = React.createClass({
// Media limits for uploading.
mediaConfig: undefined,
// used to trigger a rerender in TimelinePanel once the members are loaded,
// so RR are rendered again (now with the members available), ...
membersLoaded: !llMembers,
// The event to be scrolled to initially
initialEventId: null,
// The offset in pixels from the event with which to scroll vertically
@ -149,12 +156,14 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("Room.name", this.onRoomName);
MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("RoomMember.membership", this.onRoomMemberMembership);
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().on("accountData", this.onAccountData);
this._fetchMediaConfig();
// Start listening for RoomViewStore updates
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._onRoomViewStoreUpdate(true);
WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate);
},
_fetchMediaConfig: function(invalidateCache: boolean = false) {
@ -212,6 +221,8 @@ module.exports = React.createClass({
editingRoomSettings: RoomViewStore.isEditingSettings(),
};
if (this.state.editingRoomSettings && !newState.editingRoomSettings) dis.dispatch({action: 'focus_composer'});
// Temporary logging to diagnose https://github.com/vector-im/riot-web/issues/4307
console.log(
'RVS update:',
@ -266,6 +277,12 @@ module.exports = React.createClass({
}
},
_onWidgetEchoStoreUpdate: function() {
this.setState({
showApps: this._shouldShowApps(this.state.room),
});
},
_setupRoom: function(room, roomId, joining, shouldPeek) {
// if this is an unknown room then we're in one of three states:
// - This is a room we can peek into (search engine) (we can /peek)
@ -322,11 +339,13 @@ module.exports = React.createClass({
throw err;
}
});
} else if (room) {
//viewing a previously joined room, try to lazy load members
// Stop peeking because we have joined this room previously
MatrixClientPeg.get().stopPeeking();
this.setState({isPeeking: false});
}
} else if (room) {
// Stop peeking because we have joined this room previously
MatrixClientPeg.get().stopPeeking();
this.setState({isPeeking: false});
}
},
@ -342,14 +361,9 @@ module.exports = React.createClass({
return false;
}
const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets');
// any valid widget = show apps
for (let i = 0; i < appsStateEvents.length; i++) {
if (appsStateEvents[i].getContent().type && appsStateEvents[i].getContent().url) {
return true;
}
}
return false;
const widgets = WidgetEchoStore.getEchoedRoomWidgets(room.roomId, WidgetUtils.getRoomWidgets(room));
return widgets.length > 0 || WidgetEchoStore.roomHasPendingWidgets(room.roomId, WidgetUtils.getRoomWidgets(room));
},
componentDidMount: function() {
@ -370,7 +384,7 @@ module.exports = React.createClass({
// XXX: EVIL HACK to autofocus inviting on empty rooms.
// We use the setTimeout to avoid racing with focus_composer.
if (this.state.room &&
this.state.room.getJoinedMembers().length == 1 &&
this.state.room.getJoinedMemberCount() == 1 &&
this.state.room.getLiveTimeline() &&
this.state.room.getLiveTimeline().getEvents() &&
this.state.room.getLiveTimeline().getEvents().length <= 6) {
@ -429,8 +443,8 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().removeListener("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("RoomMember.membership", this.onRoomMemberMembership);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
}
@ -444,6 +458,8 @@ module.exports = React.createClass({
this._roomStoreToken.remove();
}
WidgetEchoStore.removeListener('update', this._onWidgetEchoStoreUpdate);
// cancel any pending calls to the rate_limited_funcs
this._updateRoomMembers.cancelPendingCall();
@ -601,6 +617,27 @@ module.exports = React.createClass({
this._warnAboutEncryption(room);
this._calculatePeekRules(room);
this._updatePreviewUrlVisibility(room);
this._loadMembersIfJoined(room);
},
_loadMembersIfJoined: async function(room) {
// lazy load members if enabled
const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) {
if (room && room.getMyMembership() === 'join') {
try {
await room.loadMembersIfNeeded();
if (!this.unmounted) {
this.setState({membersLoaded: true});
}
} catch (err) {
const errorMessage = `Fetching room members for ${room.roomId} failed.` +
" Room members will appear incomplete.";
console.error(errorMessage);
console.error(err);
}
}
}
},
_warnAboutEncryption: function(room) {
@ -647,9 +684,11 @@ module.exports = React.createClass({
}
},
_updatePreviewUrlVisibility: function(room) {
_updatePreviewUrlVisibility: function({roomId}) {
// URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit
const key = MatrixClientPeg.get().isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled';
this.setState({
showUrlPreview: SettingsStore.getValue("urlPreviewsEnabled", room.roomId),
showUrlPreview: SettingsStore.getValue(key, roomId),
});
},
@ -674,19 +713,23 @@ module.exports = React.createClass({
},
onAccountData: function(event) {
if (event.getType() === "org.matrix.preview_urls" && this.state.room) {
const type = event.getType();
if ((type === "org.matrix.preview_urls" || type === "im.vector.web.settings") && this.state.room) {
// non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls`
this._updatePreviewUrlVisibility(this.state.room);
}
},
onRoomAccountData: function(event, room) {
if (room.roomId == this.state.roomId) {
if (event.getType() === "org.matrix.room.color_scheme") {
const type = event.getType();
if (type === "org.matrix.room.color_scheme") {
const color_scheme = event.getContent();
// XXX: we should validate the event
console.log("Tinter.tint from onRoomAccountData");
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
} else if (event.getType() === "org.matrix.room.preview_urls") {
} else if (type === "org.matrix.room.preview_urls" || type === "im.vector.web.settings") {
// non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls`
this._updatePreviewUrlVisibility(room);
}
}
@ -704,12 +747,12 @@ module.exports = React.createClass({
}
this._updateRoomMembers();
this._checkIfAlone(this.state.room);
},
onRoomMemberMembership: function(ev, member, oldMembership) {
if (member.userId == MatrixClientPeg.get().credentials.userId) {
onMyMembership: function(room, membership, oldMembership) {
if (room.roomId === this.state.roomId) {
this.forceUpdate();
this._loadMembersIfJoined(room);
}
},
@ -720,6 +763,7 @@ module.exports = React.createClass({
// refresh the conf call notification state
this._updateConfCallNotification();
this._updateDMState();
this._checkIfAlone(this.state.room);
}, 500),
_checkIfAlone: function(room) {
@ -732,8 +776,8 @@ module.exports = React.createClass({
return;
}
const joinedMembers = room.currentState.getMembers().filter((m) => m.membership === "join" || m.membership === "invite");
this.setState({isAlone: joinedMembers.length === 1});
const joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount();
this.setState({isAlone: joinedOrInvitedMemberCount === 1});
},
_updateConfCallNotification: function() {
@ -761,40 +805,13 @@ module.exports = React.createClass({
},
_updateDMState() {
const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId);
if (!me || me.membership !== "join") {
const room = this.state.room;
if (room.getMyMembership() != "join") {
return;
}
// The user may have accepted an invite with is_direct set
if (me.events.member.getPrevContent().membership === "invite" &&
me.events.member.getPrevContent().is_direct
) {
// This is a DM with the sender of the invite event (which we assume
// preceded the join event)
Rooms.setDMRoom(
this.state.room.roomId,
me.events.member.getUnsigned().prev_sender,
);
return;
}
const invitedMembers = this.state.room.getMembersWithMembership("invite");
const joinedMembers = this.state.room.getMembersWithMembership("join");
// There must be one invited member and one joined member
if (invitedMembers.length !== 1 || joinedMembers.length !== 1) {
return;
}
// The user may have sent an invite with is_direct sent
const other = invitedMembers[0];
if (other &&
other.membership === "invite" &&
other.events.member.getContent().is_direct
) {
Rooms.setDMRoom(this.state.room.roomId, other.userId);
return;
const dmInviter = room.getDMInviter();
if (dmInviter) {
Rooms.setDMRoom(room.roomId, dmInviter);
}
},
@ -954,7 +971,7 @@ module.exports = React.createClass({
dis.dispatch({action: 'focus_composer'});
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_set_mxid'});
dis.dispatch({action: 'require_registration'});
return;
}
@ -985,7 +1002,7 @@ module.exports = React.createClass({
injectSticker: function(url, info, text) {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_set_mxid'});
dis.dispatch({action: 'require_registration'});
return;
}
@ -1500,6 +1517,7 @@ module.exports = React.createClass({
const RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar");
const Loader = sdk.getComponent("elements.Spinner");
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
const RoomUpgradeWarningBar = sdk.getComponent("rooms.RoomUpgradeWarningBar");
if (!this.state.room) {
if (this.state.roomLoading || this.state.peekLoading) {
@ -1546,9 +1564,8 @@ module.exports = React.createClass({
}
}
const myUserId = MatrixClientPeg.get().credentials.userId;
const myMember = this.state.room.getMember(myUserId);
if (myMember && myMember.membership == 'invite') {
const myMembership = this.state.room.getMyMembership();
if (myMembership == 'invite') {
if (this.state.joining || this.state.rejecting) {
return (
<div className="mx_RoomView">
@ -1556,6 +1573,8 @@ module.exports = React.createClass({
</div>
);
} else {
const myUserId = MatrixClientPeg.get().credentials.userId;
const myMember = this.state.room.getMember(myUserId);
const inviteEvent = myMember.events.member;
var inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender();
@ -1625,6 +1644,11 @@ module.exports = React.createClass({
/>;
}
const showRoomUpgradeBar = (
this.state.room.shouldUpgradeToVersion() &&
this.state.room.userMayUpgradeRoom(MatrixClientPeg.get().credentials.userId)
);
let aux = null;
let hideCancel = false;
if (this.state.editingRoomSettings) {
@ -1636,10 +1660,13 @@ module.exports = React.createClass({
} else if (this.state.searching) {
hideCancel = true; // has own cancel
aux = <SearchBar ref="search_bar" searchInProgress={this.state.searchInProgress} onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch} />;
} else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} />;
hideCancel = true;
} else if (this.state.showingPinned) {
hideCancel = true; // has own cancel
aux = <PinnedEventsPanel room={this.state.room} onCancelClick={this.onPinnedClick} />;
} else if (!myMember || myMember.membership !== "join") {
} else if (myMembership !== "join") {
// We do have a room object for this room, but we're not currently in it.
// We may have a 3rd party invite to it.
var inviterName = undefined;
@ -1681,7 +1708,7 @@ module.exports = React.createClass({
let messageComposer, searchInfo;
const canSpeak = (
// joined and not showing search results
myMember && (myMember.membership == 'join') && !this.state.searchResults
myMembership == 'join' && !this.state.searchResults
);
if (canSpeak) {
messageComposer =
@ -1783,6 +1810,7 @@ module.exports = React.createClass({
onReadMarkerUpdated={this._updateTopUnreadMessagesBar}
showUrlPreview = {this.state.showUrlPreview}
className="mx_RoomView_messagePanel"
membersLoaded={this.state.membersLoaded}
/>);
let topUnreadMessagesBar = null;
@ -1817,15 +1845,15 @@ module.exports = React.createClass({
oobData={this.props.oobData}
editing={this.state.editingRoomSettings}
saving={this.state.uploadingRoomSettings}
inRoom={myMember && myMember.membership === 'join'}
inRoom={myMembership === 'join'}
collapsedRhs={this.props.collapsedRhs}
onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick}
onSaveClick={this.onSettingsSaveClick}
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
onForgetClick={(myMember && myMember.membership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMember && myMember.membership === "join") ? this.onLeaveClick : null}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
/>
{ auxPanel }
<div className={fadableSectionClasses}>

View file

@ -76,7 +76,7 @@ const TagPanel = React.createClass({
_onClientSync(syncState, prevState) {
// Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING or PREPARED.
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
const reconnected = syncState !== "ERROR" && prevState !== syncState;
if (reconnected) {
// Load joined groups

View file

@ -1146,10 +1146,11 @@ var TimelinePanel = React.createClass({
// of paginating our way through the entire history of the room.
const stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS);
// If the state is PREPARED, we're still waiting for the js-sdk to sync with
// If the state is PREPARED or CATCHUP, we're still waiting for the js-sdk to sync with
// the HS and fetch the latest events, so we are effectively forward paginating.
const forwardPaginating = (
this.state.forwardPaginating || this.state.clientSyncState == 'PREPARED'
this.state.forwardPaginating ||
['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState)
);
return (
<MessagePanel ref="messagePanel"

View file

@ -81,6 +81,7 @@ const SIMPLE_SETTINGS = [
{ id: "VideoView.flipVideoHorizontally" },
{ id: "TagPanel.disableTagPanel" },
{ id: "enableWidgetScreenshots" },
{ id: "RoomSubList.showEmpty" },
];
// These settings must be defined in SettingsStore
@ -802,7 +803,7 @@ module.exports = React.createClass({
}
return (
<div>
<h3>{ _t("Debug Logs Submission") }</h3>
<h3>{ _t("Submit Debug Logs") }</h3>
<div className="mx_UserSettings_section">
<p>{
_t( "If you've submitted a bug via GitHub, debug logs can help " +
@ -830,9 +831,9 @@ module.exports = React.createClass({
<br />
{ _t('Privacy is important to us, so we don\'t collect any personal'
+ ' or identifiable data for our analytics.') }
<div className="mx_UserSettings_advanced_spoiler" onClick={Analytics.showDetailsModal}>
<AccessibleButton className="mx_UserSettings_advanced_spoiler" onClick={Analytics.showDetailsModal}>
{ _t('Learn more about how we use analytics.') }
</div>
</AccessibleButton>
{ ANALYTICS_SETTINGS.map( this._renderDeviceSetting ) }
</div>
</div>;
@ -843,8 +844,16 @@ module.exports = React.createClass({
SettingsStore.getLabsFeatures().forEach((featureId) => {
// TODO: this ought to be a separate component so that we don't need
// to rebind the onChange each time we render
const onChange = (e) => {
SettingsStore.setFeatureEnabled(featureId, e.target.checked);
const onChange = async (e) => {
const checked = e.target.checked;
if (featureId === "feature_lazyloading") {
const confirmed = await this._onLazyLoadChanging(checked);
if (!confirmed) {
e.preventDefault();
return;
}
}
await SettingsStore.setFeatureEnabled(featureId, checked);
this.forceUpdate();
};
@ -854,7 +863,7 @@ module.exports = React.createClass({
type="checkbox"
id={featureId}
name={featureId}
defaultChecked={SettingsStore.isFeatureEnabled(featureId)}
checked={SettingsStore.isFeatureEnabled(featureId)}
onChange={onChange}
/>
<label htmlFor={featureId}>{ SettingsStore.getDisplayName(featureId) }</label>
@ -877,6 +886,30 @@ module.exports = React.createClass({
);
},
_onLazyLoadChanging: async function(enabling) {
// don't prevent turning LL off when not supported
if (enabling) {
const supported = await MatrixClientPeg.get().doesServerSupportLazyLoading();
if (!supported) {
await new Promise((resolve) => {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, {
title: _t("Lazy loading members not supported"),
description:
<div>
{ _t("Lazy loading is not supported by your " +
"current homeserver.") }
</div>,
button: _t("OK"),
onFinished: resolve,
});
});
return false;
}
}
return true;
},
_renderDeactivateAccount: function() {
return <div>
<h3>{ _t("Deactivate Account") }</h3>
@ -888,6 +921,25 @@ module.exports = React.createClass({
</div>;
},
_renderTermsAndConditionsLinks: function() {
if (SdkConfig.get().terms_and_conditions_links) {
const tncLinks = [];
for (const tncEntry of SdkConfig.get().terms_and_conditions_links) {
tncLinks.push(<div key={tncEntry.url}>
<a href={tncEntry.url} rel="noopener" target="_blank">{tncEntry.text}</a>
</div>);
}
return <div>
<h3>{ _t("Legal") }</h3>
<div className="mx_UserSettings_section">
{tncLinks}
</div>
</div>;
} else {
return null;
}
},
_renderClearCache: function() {
return <div>
<h3>{ _t("Clear Cache") }</h3>
@ -1013,9 +1065,9 @@ module.exports = React.createClass({
_renderWebRtcDeviceSettings: function() {
if (this.state.mediaDevices === false) {
return (
<p className="mx_UserSettings_link" onClick={this._requestMediaPermissions}>
<AccessibleButton element="p" className="mx_UserSettings_link" onClick={this._requestMediaPermissions}>
{ _t('Missing Media Permissions, click here to request.') }
</p>
</AccessibleButton>
);
} else if (!this.state.mediaDevices) return;
@ -1182,7 +1234,7 @@ module.exports = React.createClass({
/>
</div>
<div className="mx_UserSettings_threepidButton mx_filterFlipColor">
<img src="img/cancel-small.svg" width="14" height="14" alt={_t("Remove")}
<AccessibleButton element="img" src="img/cancel-small.svg" width="14" height="14" alt={_t("Remove")}
onClick={onRemoveClick} />
</div>
</div>
@ -1207,7 +1259,7 @@ module.exports = React.createClass({
onValueChanged={this._onAddEmailEditFinished} />
</div>
<div className="mx_UserSettings_threepidButton mx_filterFlipColor">
<img src="img/plus.svg" width="14" height="14" alt={_t("Add")} onClick={this._addEmail} />
<AccessibleButton element="img" src="img/plus.svg" width="14" height="14" alt={_t("Add")} onClick={this._addEmail} />
</div>
</div>
);
@ -1276,13 +1328,13 @@ module.exports = React.createClass({
</div>
<div className="mx_UserSettings_avatarPicker">
<div className="mx_UserSettings_avatarPicker_remove" onClick={this.onAvatarRemoveClick}>
<AccessibleButton className="mx_UserSettings_avatarPicker_remove" onClick={this.onAvatarRemoveClick}>
<img src="img/cancel.svg"
width="15" height="15"
className="mx_filterFlipColor"
alt={_t("Remove avatar")}
title={_t("Remove avatar")} />
</div>
</AccessibleButton>
<div onClick={this.onAvatarPickerClick} className="mx_UserSettings_avatarPicker_imgContainer">
<ChangeAvatar ref="changeAvatar" initialAvatarUrl={avatarUrl}
showUploadSection={false} className="mx_UserSettings_avatarPicker_img" />
@ -1343,11 +1395,11 @@ module.exports = React.createClass({
</div>
<div className="mx_UserSettings_advanced">
{ _t('Access Token:') + ' ' }
<span className="mx_UserSettings_advanced_spoiler"
<AccessibleButton element="span" className="mx_UserSettings_advanced_spoiler"
onClick={this._showSpoiler}
data-spoiler={MatrixClientPeg.get().getAccessToken()}>
&lt;{ _t("click to reveal") }&gt;
</span>
</AccessibleButton>
</div>
<div className="mx_UserSettings_advanced">
{ _t("Homeserver is") } { MatrixClientPeg.get().getHomeserverUrl() }
@ -1374,6 +1426,8 @@ module.exports = React.createClass({
{ this._renderDeactivateAccount() }
{ this._renderTermsAndConditionsLinks() }
</GeminiScrollbarWrapper>
</div>
);

View file

@ -20,11 +20,12 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { _t, _td } from '../../../languageHandler';
import sdk from '../../../index';
import Login from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore";
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@ -93,6 +94,13 @@ module.exports = React.createClass({
this._unmounted = true;
},
onPasswordLoginError: function(errorText) {
this.setState({
errorText,
loginIncorrect: Boolean(errorText),
});
},
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
this.setState({
busy: true,
@ -114,6 +122,30 @@ module.exports = React.createClass({
const usingEmail = username.indexOf("@") > 0;
if (error.httpStatus === 400 && usingEmail) {
errorText = _t('This Home Server does not support login using email address.');
} else if (error.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact, {
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
});
const errorDetail = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact, {
'': _td(
"Please <a>contact your service administrator</a> to continue using this service.",
),
});
errorText = (
<div>
<div>{errorTop}</div>
<div className="mx_Login_smallError">{errorDetail}</div>
</div>
);
} else if (error.httpStatus === 401 || error.httpStatus === 403) {
if (SdkConfig.get()['disable_custom_urls']) {
errorText = (
@ -357,6 +389,7 @@ module.exports = React.createClass({
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
onError={this.onPasswordLoginError}
initialUsername={this.state.username}
initialPhoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber}

View file

@ -26,9 +26,10 @@ import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import RegistrationForm from '../../views/login/RegistrationForm';
import RtsClient from '../../../RtsClient';
import { _t } from '../../../languageHandler';
import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore";
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
const MIN_PASSWORD_LENGTH = 6;
@ -92,6 +93,7 @@ module.exports = React.createClass({
doingUIAuth: Boolean(this.props.sessionId),
hsUrl: this.props.customHsUrl,
isUrl: this.props.customIsUrl,
flows: null,
};
},
@ -144,11 +146,27 @@ module.exports = React.createClass({
});
},
_replaceClient: function() {
_replaceClient: async function() {
this._matrixClient = Matrix.createClient({
baseUrl: this.state.hsUrl,
idBaseUrl: this.state.isUrl,
});
try {
await this._makeRegisterRequest({});
// This should never succeed since we specified an empty
// auth object.
console.log("Expecting 401 from register request but got success!");
} catch (e) {
if (e.httpStatus === 401) {
this.setState({
flows: e.data.flows,
});
} else {
this.setState({
errorText: _t("Unable to query for supported registration methods"),
});
}
}
},
onFormSubmit: function(formVals) {
@ -164,7 +182,29 @@ module.exports = React.createClass({
if (!success) {
let msg = response.message || response.toString();
// can we give a better error message?
if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
if (response.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
});
const errorDetail = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'': _td(
"Please <a>contact your service administrator</a> to continue using this service.",
),
});
msg = <div>
<p>{errorTop}</p>
<p>{errorDetail}</p>
</div>;
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdnAvailable = false;
for (const flow of response.available_flows) {
msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1;
@ -281,6 +321,12 @@ module.exports = React.createClass({
case "RegistrationForm.ERR_PHONE_NUMBER_INVALID":
errMsg = _t('This doesn\'t look like a valid phone number.');
break;
case "RegistrationForm.ERR_MISSING_EMAIL":
errMsg = _t('An email address is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_MISSING_PHONE_NUMBER":
errMsg = _t('A phone number is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = _t('User names may only contain letters, numbers, dots, hyphens and underscores.');
break;
@ -355,7 +401,7 @@ module.exports = React.createClass({
poll={true}
/>
);
} else if (this.state.busy || this.state.teamServerBusy) {
} else if (this.state.busy || this.state.teamServerBusy || !this.state.flows) {
registerBody = <Spinner />;
} else {
let serverConfigSection;
@ -385,6 +431,7 @@ module.exports = React.createClass({
onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit}
onTeamSelected={this.onTeamSelected}
flows={this.state.flows}
/>
{ serverConfigSection }
</div>