diff --git a/res/css/_components.scss b/res/css/_components.scss index 51087eba62..22c9b73dca 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -143,7 +143,9 @@ @import "./views/messages/_TextualEvent.scss"; @import "./views/messages/_UnknownBody.scss"; @import "./views/messages/_ViewSourceEvent.scss"; +@import "./views/right_panel/_EncryptionInfo.scss"; @import "./views/right_panel/_UserInfo.scss"; +@import "./views/right_panel/_VerificationPanel.scss"; @import "./views/room_settings/_AliasSettings.scss"; @import "./views/room_settings/_ColorSettings.scss"; @import "./views/rooms/_AppsDrawer.scss"; diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss index 53e82670e1..bbbf3fc1d3 100644 --- a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss +++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss @@ -15,6 +15,30 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_CreateSecretStorageDialog { + // Why you ask? Because CompleteSecurityBody is 600px so this is the width + // we end up when in there, but when in our own dialog we set our own width + // so need to fix it to something sensible as otherwise we'd end up either + // really wide or really narrow depending on the phase. I bet you wish you + // never asked. + width: 560px; + + .mx_SettingsFlag { + display: flex; + } + + .mx_SettingsFlag_label { + flex: 1 1 0; + min-width: 0; + font-weight: 600; + } + + .mx_ToggleSwitch { + flex: 0 0 auto; + margin-left: 30px; + } +} + .mx_CreateSecretStorageDialog .mx_Dialog_title { /* TODO: Consider setting this for all dialog titles. */ margin-bottom: 1em; diff --git a/res/css/views/right_panel/_EncryptionInfo.scss b/res/css/views/right_panel/_EncryptionInfo.scss new file mode 100644 index 0000000000..e13b1b6802 --- /dev/null +++ b/res/css/views/right_panel/_EncryptionInfo.scss @@ -0,0 +1,26 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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_UserInfo { + .mx_EncryptionInfo_spinner { + .mx_Spinner { + margin-top: 25px; + margin-bottom: 15px; + } + + text-align: center; + } +} diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index ad6254f57c..9ce524c5ac 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -49,12 +49,17 @@ limitations under the License. } .mx_UserInfo_container { - padding: 0 16px 16px 16px; + padding: 8px 16px; + } + + .mx_UserInfo_separator { border-bottom: 1px solid lightgray; } .mx_UserInfo_memberDetailsContainer { + padding-top: 0; padding-bottom: 0; + margin-bottom: 8px; } .mx_RoomTile_nameContainer { @@ -76,6 +81,7 @@ limitations under the License. .mx_UserInfo_avatar > div { max-width: 30vh; margin: 0 auto; + transition: 0.5s; } .mx_UserInfo_avatar > div > div { @@ -105,6 +111,7 @@ limitations under the License. // override the calculated sizes so that the letter isn't HUGE font-size: 56px !important; width: 100% !important; + transition: font-size 0.5s; } .mx_UserInfo_avatar .mx_BaseAvatar.mx_BaseAvatar_image { @@ -204,10 +211,9 @@ limitations under the License. padding-bottom: 16px; } - .mx_UserInfo_scrollContainer .mx_UserInfo_container { + .mx_UserInfo_scrollContainer:not(.mx_UserInfo_separator) { padding-top: 16px; padding-bottom: 0; - border-bottom: none; > :not(h3) { margin-left: 8px; @@ -256,11 +262,17 @@ limitations under the License. .mx_UserInfo_verify { display: block; - background-color: $accent-color; - color: $accent-fg-color; - border-radius: 4px; - padding: 7px 1.5em; - text-align: center; margin: 16px 0; } } + +.mx_UserInfo.mx_UserInfo_smallAvatar { + .mx_UserInfo_avatar > div { + max-width: 72px; + margin: 0 auto; + } + + .mx_UserInfo_avatar .mx_BaseAvatar_initial { + font-size: 40px !important; // override the other override because here the avatar is smaller + } +} diff --git a/res/css/views/right_panel/_VerificationPanel.scss b/res/css/views/right_panel/_VerificationPanel.scss new file mode 100644 index 0000000000..75b469cef9 --- /dev/null +++ b/res/css/views/right_panel/_VerificationPanel.scss @@ -0,0 +1,37 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +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_UserInfo { + .mx_VerificationPanel_verified_section .mx_E2EIcon { + margin: 0 auto; + } + + .mx_VerificationPanel_qrCode { + padding: 4px 4px 0 4px; + background: white; + border-radius: 4px; + width: max-content; + max-width: 100%; + margin: 0 auto; + + canvas { + // override height and width which are set on the element directly + height: auto !important; + width: 100% !important; + max-width: 240px; + } + } +} diff --git a/res/css/views/rooms/_TopUnreadMessagesBar.scss b/res/css/views/rooms/_TopUnreadMessagesBar.scss index 77f19dac1c..505af9691d 100644 --- a/res/css/views/rooms/_TopUnreadMessagesBar.scss +++ b/res/css/views/rooms/_TopUnreadMessagesBar.scss @@ -25,19 +25,16 @@ limitations under the License. } .mx_TopUnreadMessagesBar::after { - content: "·"; + content: ""; position: absolute; top: -8px; left: 11px; - width: 16px; - height: 16px; + width: 4px; + height: 4px; border-radius: 16px; - font-weight: 600; - font-size: 30px; - line-height: 14px; - text-align: center; - color: $secondary-accent-color; - background-color: $accent-color; + overflow: hidden; + background-color: $secondary-accent-color; + border: 6px solid $accent-color; } .mx_TopUnreadMessagesBar_scrollUp { diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js index 0867cae6f4..4068f72217 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -25,15 +25,14 @@ import { _t } from '../../../../languageHandler'; import Modal from '../../../../Modal'; const PHASE_LOADING = 0; -const PHASE_RESTORE_KEY_BACKUP = 1; -const PHASE_MIGRATE = 2; -const PHASE_PASSPHRASE = 3; -const PHASE_PASSPHRASE_CONFIRM = 4; -const PHASE_SHOWKEY = 5; -const PHASE_KEEPITSAFE = 6; -const PHASE_STORING = 7; -const PHASE_DONE = 8; -const PHASE_OPTOUT_CONFIRM = 9; +const PHASE_MIGRATE = 1; +const PHASE_PASSPHRASE = 2; +const PHASE_PASSPHRASE_CONFIRM = 3; +const PHASE_SHOWKEY = 4; +const PHASE_KEEPITSAFE = 5; +const PHASE_STORING = 6; +const PHASE_DONE = 7; +const PHASE_CONFIRM_SKIP = 8; const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms. @@ -58,7 +57,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { accountPassword: PropTypes.string, }; - defaultProps = { + static defaultProps = { hasCancel: true, }; @@ -88,6 +87,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { // set if we are 'upgrading' encryption (making an SSSS store from // an existing key backup secret). doingUpgrade: null, + // status of the key backup toggle switch + useKeyBackup: true, }; this._fetchBackupInfo(); @@ -110,9 +111,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo) ); - const phase = backupInfo ? - (backupSigStatus.usable ? PHASE_MIGRATE : PHASE_RESTORE_KEY_BACKUP) : - PHASE_PASSPHRASE; + const phase = backupInfo ? PHASE_MIGRATE : PHASE_PASSPHRASE; this.setState({ phase, @@ -144,16 +143,26 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _onKeyBackupStatusChange = () => { - this._fetchBackupInfo(); + if (this.state.phase === PHASE_MIGRATE) this._fetchBackupInfo(); } _collectRecoveryKeyNode = (n) => { this._recoveryKeyNode = n; } + _onUseKeyBackupChange = (enabled) => { + this.setState({ + useKeyBackup: enabled, + }); + } + _onMigrateFormSubmit = (e) => { e.preventDefault(); - this._bootstrapSecretStorage(); + if (this.state.backupSigStatus.usable) { + this._bootstrapSecretStorage(); + } else { + this._restoreBackup(); + } } _onCopyClick = () => { @@ -221,6 +230,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { authUploadDeviceSigningKeys: this._doBootstrapUIAuth, createSecretStorageKey: async () => this._keyInfo, keyBackupInfo: this.state.backupInfo, + setupNewKeyBackup: !this.state.backupInfo && this.state.useKeyBackup, }); this.setState({ phase: PHASE_DONE, @@ -228,6 +238,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } catch (e) { if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) { this.setState({ + accountPassword: '', accountPasswordCorrect: false, phase: PHASE_MIGRATE, }); @@ -246,16 +257,26 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.props.onFinished(true); } - _onRestoreKeyBackupClick = () => { + _restoreBackup = async () => { const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); - Modal.createTrackedDialog( + const { finished } = Modal.createTrackedDialog( 'Restore Backup', '', RestoreKeyBackupDialog, {showSummary: false}, null, /* priority = */ false, /* static = */ true, ); + + await finished; + await this._fetchBackupInfo(); + if ( + this.state.backupSigStatus.usable && + this.state.canUploadKeysWithPasswordOnly && + this.state.accountPassword + ) { + this._bootstrapSecretStorage(); + } } - _onOptOutClick = () => { - this.setState({phase: PHASE_OPTOUT_CONFIRM}); + _onSkipSetupClick = () => { + this.setState({phase: PHASE_CONFIRM_SKIP}); } _onSetUpClick = () => { @@ -367,23 +388,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }); } - _renderPhaseRestoreKeyBackup() { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return
-

{_t( - "Key Backup is enabled on your account but has not been set " + - "up from this session. To set up secret storage, " + - "restore your key backup.", - )}

- - -
; - } - _renderPhaseMigrate() { // TODO: This is a temporary screen so people who have the labs flag turned on and // click the button are aware they're making a change to their account. @@ -394,7 +398,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent { const Field = sdk.getComponent('views.elements.Field'); let authPrompt; - if (this.state.canUploadKeysWithPasswordOnly) { + let nextCaption = _t("Next"); + if (!this.state.backupSigStatus.usable) { + authPrompt =
+
{_t("Restore your key backup to upgrade your encryption")}
+
; + nextCaption = _t("Restore"); + } else if (this.state.canUploadKeysWithPasswordOnly) { authPrompt =
{_t("Enter your account password to confirm the upgrade:")}
{authPrompt}
- + > + + ; } @@ -432,6 +445,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const Field = sdk.getComponent('views.elements.Field'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch'); let strengthMeter; let helpText; @@ -480,13 +494,18 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
+ + @@ -554,7 +573,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { disabled={this.state.passPhrase !== this.state.passPhraseConfirm} > @@ -659,35 +678,32 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ; } - _renderPhaseOptOutConfirm() { + _renderPhaseSkipConfirm() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
{_t( - "Without setting up secret storage, you won't be able to restore your " + - "access to encrypted messages or your cross-signing identity for " + - "verifying other devices if you log out or use another device.", + "Without completing security on this device, it won’t have " + + "access to encrypted messages.", )} - - +
; } _titleForPhase(phase) { switch (phase) { - case PHASE_RESTORE_KEY_BACKUP: - return _t('Restore your Key Backup'); case PHASE_MIGRATE: return _t('Upgrade your encryption'); case PHASE_PASSPHRASE: return _t('Set up encryption'); case PHASE_PASSPHRASE_CONFIRM: return _t('Confirm passphrase'); - case PHASE_OPTOUT_CONFIRM: - return _t('Warning!'); + case PHASE_CONFIRM_SKIP: + return _t('Are you sure?'); case PHASE_SHOWKEY: return _t('Recovery key'); case PHASE_KEEPITSAFE: @@ -722,9 +738,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { case PHASE_LOADING: content = this._renderBusyPhase(); break; - case PHASE_RESTORE_KEY_BACKUP: - content = this._renderPhaseRestoreKeyBackup(); - break; case PHASE_MIGRATE: content = this._renderPhaseMigrate(); break; @@ -746,8 +759,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { case PHASE_DONE: content = this._renderPhaseDone(); break; - case PHASE_OPTOUT_CONFIRM: - content = this._renderPhaseOptOutConfirm(); + case PHASE_CONFIRM_SKIP: + content = this._renderPhaseSkipConfirm(); break; } } @@ -763,6 +776,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { title={this._titleForPhase(this.state.phase)} headerImage={headerImage} hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)} + fixedWidth={false} >
{content} diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index dca89d0c35..be10ead7ca 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -169,7 +169,6 @@ export default class RightPanel extends React.Component { const MemberList = sdk.getComponent('rooms.MemberList'); const MemberInfo = sdk.getComponent('rooms.MemberInfo'); const UserInfo = sdk.getComponent('right_panel.UserInfo'); - const EncryptionPanel = sdk.getComponent('right_panel.EncryptionPanel'); const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo'); const NotificationPanel = sdk.getComponent('structures.NotificationPanel'); const FilePanel = sdk.getComponent('structures.FilePanel'); @@ -181,64 +180,82 @@ export default class RightPanel extends React.Component { let panel =
; - if (this.props.roomId && this.state.phase === RIGHT_PANEL_PHASES.RoomMemberList) { - panel = ; - } else if (this.props.groupId && this.state.phase === RIGHT_PANEL_PHASES.GroupMemberList) { - panel = ; - } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupRoomList) { - panel = ; - } else if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberInfo) { - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { - const onClose = () => { - dis.dispatch({ - action: "view_user", - member: null, - }); - }; - panel = ; - } else { - panel = ; - } - } else if (this.state.phase === RIGHT_PANEL_PHASES.Room3pidMemberInfo) { - panel = ; - } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupMemberInfo) { - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { - const onClose = () => { - dis.dispatch({ - action: "view_user", - member: null, - }); - }; - panel = ; - } else { - panel = ( - ; + } + break; + case RIGHT_PANEL_PHASES.GroupMemberList: + if (this.props.groupId) { + panel = ; + } + break; + case RIGHT_PANEL_PHASES.GroupRoomList: + panel = ; + break; + case RIGHT_PANEL_PHASES.RoomMemberInfo: + case RIGHT_PANEL_PHASES.EncryptionPanel: + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + const onClose = () => { + dis.dispatch({ + action: "view_user", + member: this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel ? this.state.member : null, + }); + }; + panel = ; + } else { + panel = ; + } + break; + case RIGHT_PANEL_PHASES.Room3pidMemberInfo: + panel = ; + break; + case RIGHT_PANEL_PHASES.GroupMemberInfo: + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + const onClose = () => { + dis.dispatch({ + action: "view_user", + member: null, + }); + }; + panel = - ); - } - } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupRoomInfo) { - panel = ; - } else if (this.state.phase === RIGHT_PANEL_PHASES.NotificationPanel) { - panel = ; - } else if (this.state.phase === RIGHT_PANEL_PHASES.FilePanel) { - panel = ; - } else if (this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel) { - panel = ; + key={this.state.member.userId} + onClose={onClose} />; + } else { + panel = ( + + ); + } + break; + case RIGHT_PANEL_PHASES.GroupRoomInfo: + panel = ; + break; + case RIGHT_PANEL_PHASES.NotificationPanel: + panel = ; + break; + case RIGHT_PANEL_PHASES.FilePanel: + panel = ; + break; } const classes = classNames("mx_RightPanel", "mx_fadable", { diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 65fb00c305..e708fad6a4 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -2,7 +2,7 @@ Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -146,6 +146,9 @@ const TimelinePanel = createReactClass({ liveEvents: [], timelineLoading: true, // track whether our room timeline is loading + // the index of the first event that is to be shown + firstVisibleEventIndex: 0, + // canBackPaginate == false may mean: // // * we haven't (successfully) loaded the timeline yet, or: @@ -333,11 +336,12 @@ const TimelinePanel = createReactClass({ // We can now paginate in the unpaginated direction const canPaginateKey = (backwards) ? 'canBackPaginate' : 'canForwardPaginate'; - const { events, liveEvents } = this._getEvents(); + const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); this.setState({ [canPaginateKey]: true, events, liveEvents, + firstVisibleEventIndex, }); } }, @@ -369,6 +373,11 @@ const TimelinePanel = createReactClass({ return Promise.resolve(false); } + if (backwards && this.state.firstVisibleEventIndex !== 0) { + debuglog("TimelinePanel: won't", dir, "paginate past first visible event"); + return Promise.resolve(false); + } + debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards); this.setState({[paginatingKey]: true}); @@ -377,12 +386,13 @@ const TimelinePanel = createReactClass({ debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r); - const { events, liveEvents } = this._getEvents(); + const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); const newState = { [paginatingKey]: false, [canPaginateKey]: r, events, liveEvents, + firstVisibleEventIndex, }; // moving the window in this direction may mean that we can now @@ -402,7 +412,11 @@ const TimelinePanel = createReactClass({ // itself into the right place return new Promise((resolve) => { this.setState(newState, () => { - resolve(r); + // we can continue paginating in the given direction if: + // - _timelineWindow.paginate says we can + // - we're paginating forwards, or we won't be trying to + // paginate backwards past the first visible event + resolve(r && (!backwards || firstVisibleEventIndex === 0)); }); }); }); @@ -476,12 +490,13 @@ const TimelinePanel = createReactClass({ this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => { if (this.unmounted) { return; } - const { events, liveEvents } = this._getEvents(); + const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); const lastLiveEvent = liveEvents[liveEvents.length - 1]; const updatedState = { events, liveEvents, + firstVisibleEventIndex, }; let callRMUpdated; @@ -1115,6 +1130,7 @@ const TimelinePanel = createReactClass({ // get the list of events from the timeline window and the pending event list _getEvents: function() { const events = this._timelineWindow.getEvents(); + const firstVisibleEventIndex = this._checkForPreJoinUISI(events); // Hold onto the live events separately. The read receipt and read marker // should use this list, so that they don't advance into pending events. @@ -1128,9 +1144,72 @@ const TimelinePanel = createReactClass({ return { events, liveEvents, + firstVisibleEventIndex, }; }, + /** + * Check for undecryptable messages that were sent while the user was not in + * the room. + * + * @param {Array} events The timeline events to check + * + * @return {Number} The index within `events` of the event after the most recent + * undecryptable event that was sent while the user was not in the room. If no + * such events were found, then it returns 0. + */ + _checkForPreJoinUISI: function(events) { + const room = this.props.timelineSet.room; + + if (events.length === 0 || !room || + !MatrixClientPeg.get().isRoomEncrypted(room.roomId)) { + return 0; + } + + const userId = MatrixClientPeg.get().credentials.userId; + + // get the user's membership at the last event by getting the timeline + // that the event belongs to, and traversing the timeline looking for + // that event, while keeping track of the user's membership + const lastEvent = events[events.length - 1]; + const timeline = room.getTimelineForEvent(lastEvent.getId()); + const userMembershipEvent = + timeline.getState(EventTimeline.FORWARDS).getMember(userId); + let userMembership = userMembershipEvent + ? userMembershipEvent.membership : "leave"; + const timelineEvents = timeline.getEvents(); + for (let i = timelineEvents.length - 1; i >= 0; i--) { + const event = timelineEvents[i]; + if (event.getId() === lastEvent.getId()) { + // found the last event, so we can stop looking through the timeline + break; + } else if (event.getStateKey() === userId + && event.getType() === "m.room.member") { + const prevContent = event.getPrevContent(); + userMembership = prevContent.membership || "leave"; + } + } + + // now go through the events that we have and find the first undecryptable + // one that was sent when the user wasn't in the room + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + if (event.getStateKey() === userId + && event.getType() === "m.room.member") { + const prevContent = event.getPrevContent(); + userMembership = prevContent.membership || "leave"; + } else if (userMembership === "leave" && + (event.isDecryptionFailure() || event.isBeingDecrypted())) { + // reached an undecryptable message when the user wasn't in + // the room -- don't try to load any more + // Note: for now, we assume that events that are being decrypted are + // not decryptable + return i + 1; + } + } + return 0; + }, + _indexForEventId: function(evId) { for (let i = 0; i < this.state.events.length; ++i) { if (evId == this.state.events[i].getId()) { @@ -1323,6 +1402,9 @@ const TimelinePanel = createReactClass({ this.state.forwardPaginating || ['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState) ); + const events = this.state.firstVisibleEventIndex + ? this.state.events.slice(this.state.firstVisibleEventIndex) + : this.state.events; return (