/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018, 2019 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // TODO: This component is enormous! There's several things which could stand-alone: // - Search results component // - Drag and drop // - File uploading - uploadFile() import shouldHideEvent from "../../shouldHideEvent"; const React = require("react"); const ReactDOM = require("react-dom"); import PropTypes from 'prop-types'; import Promise from 'bluebird'; import filesize from 'filesize'; const classNames = require("classnames"); import { _t } from '../../languageHandler'; import {RoomPermalinkCreator} from "../../matrix-to"; const MatrixClientPeg = require("../../MatrixClientPeg"); const ContentMessages = require("../../ContentMessages"); const Modal = require("../../Modal"); const sdk = require('../../index'); const CallHandler = require('../../CallHandler'); const dis = require("../../dispatcher"); const Tinter = require("../../Tinter"); const rate_limited_func = require('../../ratelimitedfunc'); const ObjectUtils = require('../../ObjectUtils'); const Rooms = require('../../Rooms'); import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; 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() {}; const BROWSER_SUPPORTS_SANDBOX = 'sandbox' in document.createElement('iframe'); if (DEBUG) { // using bind means that we get to keep useful line numbers in the console debuglog = console.log.bind(console); } module.exports = React.createClass({ displayName: 'RoomView', propTypes: { ConferenceHandler: PropTypes.any, // Called with the credentials of a registered user (if they were a ROU that // transitioned to PWLU) onRegistered: PropTypes.func, // An object representing a third party invite to join this room // Fields: // * inviteSignUrl (string) The URL used to join this room from an email invite // (given as part of the link in the invite email) // * invitedEmail (string) The email address that was invited to this room thirdPartyInvite: PropTypes.object, // Any data about the room that would normally come from the homeserver // but has been passed out-of-band, eg. the room name and avatar URL // from an email invite (a workaround for the fact that we can't // get this information from the HS using an email invite). // Fields: // * name (string) The room's name // * avatarUrl (string) The mxc:// avatar URL for the room // * inviterName (string) The display name of the person who // * invited us tovthe room oobData: PropTypes.object, // is the RightPanel collapsed? collapsedRhs: PropTypes.bool, // Servers the RoomView can use to try and assist joins viaServers: PropTypes.arrayOf(PropTypes.string), }, getInitialState: function() { const llMembers = MatrixClientPeg.get().hasLazyLoadMembersEnabled(); return { room: null, roomId: null, roomLoading: true, peekLoading: false, shouldPeek: true, // 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 initialEventPixelOffset: null, // Whether to highlight the event scrolled to isInitialEventHighlighted: null, forwardingEvent: null, numUnreadMessages: 0, draggingFile: false, searching: false, searchResults: null, callState: null, guestsCanJoin: false, canPeek: false, showApps: false, isAlone: false, isPeeking: false, showingPinned: false, // error object, as from the matrix client/server API // If we failed to load information about the room, // store the error here. roomLoadError: null, // Have we sent a request to join the room that we're waiting to complete? joining: false, // this is true if we are fully scrolled-down, and are looking at // the end of the live timeline. It has the effect of hiding the // 'scroll to bottom' knob, among a couple of other things. atEndOfLiveTimeline: true, atEndOfLiveTimelineInit: false, // used by componentDidUpdate to avoid unnecessary checks showTopUnreadMessagesBar: false, auxPanelMaxHeight: undefined, statusBarVisible: false, // We load this later by asking the js-sdk to suggest a version for us. // This object is the result of Room#getRecommendedVersion() upgradeRecommendation: null, }; }, componentWillMount: function() { this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("Room", this.onRoom); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.name", this.onRoomName); MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership); MatrixClientPeg.get().on("accountData", this.onAccountData); MatrixClientPeg.get().on("crypto.keyBackupStatus", this.onKeyBackupStatus); MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged); this._fetchMediaConfig(); // Start listening for RoomViewStore updates this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._onRoomViewStoreUpdate(true); WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate); }, _fetchMediaConfig: function(invalidateCache: boolean = false) { /// NOTE: Using global here so we don't make repeated requests for the /// config every time we swap room. if(global.mediaConfig !== undefined && !invalidateCache) { this.setState({mediaConfig: global.mediaConfig}); return; } console.log("[Media Config] Fetching"); MatrixClientPeg.get().getMediaConfig().then((config) => { console.log("[Media Config] Fetched config:", config); return config; }).catch(() => { // Media repo can't or won't report limits, so provide an empty object (no limits). console.log("[Media Config] Could not fetch config, so not limiting uploads."); return {}; }).then((config) => { global.mediaConfig = config; this.setState({mediaConfig: config}); }); }, _onRoomViewStoreUpdate: function(initial) { if (this.unmounted) { return; } if (!initial && this.state.roomId !== RoomViewStore.getRoomId()) { // RoomView explicitly does not support changing what room // is being viewed: instead it should just be re-mounted when // switching rooms. Therefore, if the room ID changes, we // ignore this. We either need to do this or add code to handle // saving the scroll position (otherwise we end up saving the // scroll position against the wrong room). // Given that doing the setState here would cause a bunch of // unnecessary work, we just ignore the change since we know // that if the current room ID has changed from what we thought // it was, it means we're about to be unmounted. return; } const newState = { roomId: RoomViewStore.getRoomId(), roomAlias: RoomViewStore.getRoomAlias(), roomLoading: RoomViewStore.isRoomLoading(), roomLoadError: RoomViewStore.getRoomLoadError(), joining: RoomViewStore.isJoining(), initialEventId: RoomViewStore.getInitialEventId(), isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(), forwardingEvent: RoomViewStore.getForwardingEvent(), shouldPeek: RoomViewStore.shouldPeek(), showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", RoomViewStore.getRoomId()), }; // Temporary logging to diagnose https://github.com/vector-im/riot-web/issues/4307 console.log( 'RVS update:', newState.roomId, newState.roomAlias, 'loading?', newState.roomLoading, 'joining?', newState.joining, 'initial?', initial, 'shouldPeek?', newState.shouldPeek, ); // NB: This does assume that the roomID will not change for the lifetime of // the RoomView instance if (initial) { newState.room = MatrixClientPeg.get().getRoom(newState.roomId); if (newState.room) { newState.showApps = this._shouldShowApps(newState.room); this._onRoomLoaded(newState.room); } } if (this.state.roomId === null && newState.roomId !== null) { // Get the scroll state for the new room // If an event ID wasn't specified, default to the one saved for this room // in the scroll state store. Assume initialEventPixelOffset should be set. if (!newState.initialEventId) { const roomScrollState = RoomScrollStateStore.getScrollState(newState.roomId); if (roomScrollState) { newState.initialEventId = roomScrollState.focussedEvent; newState.initialEventPixelOffset = roomScrollState.pixelOffset; } } } // Clear the search results when clicking a search result (which changes the // currently scrolled to event, this.state.initialEventId). if (this.state.initialEventId !== newState.initialEventId) { newState.searchResults = null; } this.setState(newState); // At this point, newState.roomId could be null (e.g. the alias might not // have been resolved yet) so anything called here must handle this case. // We pass the new state into this function for it to read: it needs to // observe the new state but we don't want to put it in the setState // callback because this would prevent the setStates from being batched, // ie. cause it to render RoomView twice rather than the once that is necessary. if (initial) { this._setupRoom(newState.room, newState.roomId, newState.joining, newState.shouldPeek); } }, _getRoomId() { // According to `_onRoomViewStoreUpdate`, `state.roomId` can be null // if we have a room alias we haven't resolved yet. To work around this, // first we'll try the room object if it's there, and then fallback to // the bare room ID. (We may want to update `state.roomId` after // resolving aliases, so we could always trust it.) return this.state.room ? this.state.room.roomId : this.state.roomId; }, _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) // - This is a room we can publicly join or were invited to. (we can /join) // - This is a room we cannot join at all. (no action can help us) // We can't try to /join because this may implicitly accept invites (!) // We can /peek though. If it fails then we present the join UI. If it // succeeds then great, show the preview (but we still may be able to /join!). // Note that peeking works by room ID and room ID only, as opposed to joining // which must be by alias or invite wherever possible (peeking currently does // not work over federation). // NB. We peek if we have never seen the room before (i.e. js-sdk does not know // about it). We don't peek in the historical case where we were joined but are // now not joined because the js-sdk peeking API will clobber our historical room, // making it impossible to indicate a newly joined room. if (!joining && roomId) { if (this.props.autoJoin) { this.onJoinButtonClicked(); } else if (!room && shouldPeek) { console.log("Attempting to peek into room %s", roomId); this.setState({ peekLoading: true, isPeeking: true, // this will change to false if peeking fails }); MatrixClientPeg.get().peekInRoom(roomId).then((room) => { if (this.unmounted) { return; } this.setState({ room: room, peekLoading: false, }); this._onRoomLoaded(room); }, (err) => { if (this.unmounted) { return; } // Stop peeking if anything went wrong this.setState({ isPeeking: false, }); // This won't necessarily be a MatrixError, but we duck-type // here and say if it's got an 'errcode' key with the right value, // it means we can't peek. if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") { // This is fine: the room just isn't peekable (we assume). this.setState({ peekLoading: false, }); } else { 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}); } } }, _shouldShowApps: function(room) { if (!BROWSER_SUPPORTS_SANDBOX) return false; // Check if user has previously chosen to hide the app drawer for this // room. If so, do not show apps const hideWidgetDrawer = localStorage.getItem( room.roomId + "_hide_widget_drawer"); if (hideWidgetDrawer === "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() { const call = this._getCallForRoom(); const callState = call ? call.call_state : "ended"; this.setState({ callState: callState, }); this._updateConfCallNotification(); window.addEventListener('beforeunload', this.onPageUnload); window.addEventListener('resize', this.onResize); this.onResize(); document.addEventListener("keydown", this.onKeyDown); // 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.getJoinedMemberCount() == 1 && this.state.room.getLiveTimeline() && this.state.room.getLiveTimeline().getEvents() && this.state.room.getLiveTimeline().getEvents().length <= 6) { const inviteBox = document.getElementById("mx_SearchableEntityList_query"); setTimeout(function() { if (inviteBox) { inviteBox.focus(); } }, 50); } }, shouldComponentUpdate: function(nextProps, nextState) { return (!ObjectUtils.shallowEqual(this.props, nextProps) || !ObjectUtils.shallowEqual(this.state, nextState)); }, componentDidUpdate: function() { if (this.refs.roomView) { const roomView = ReactDOM.findDOMNode(this.refs.roomView); if (!roomView.ondrop) { roomView.addEventListener('drop', this.onDrop); roomView.addEventListener('dragover', this.onDragOver); roomView.addEventListener('dragleave', this.onDragLeaveOrEnd); roomView.addEventListener('dragend', this.onDragLeaveOrEnd); } } if (this.refs.messagePanel && !this.state.atEndOfLiveTimelineInit) { this.setState({ atEndOfLiveTimelineInit: true, atEndOfLiveTimeline: this.refs.messagePanel.isAtEndOfLiveTimeline(), }); } }, componentWillUnmount: function() { // set a boolean to say we've been unmounted, which any pending // promises can use to throw away their results. // // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; // update the scroll map before we get unmounted if (this.state.roomId) { RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState()); } // stop tracking room changes to format permalinks if (this.state.permalinkCreator) { this.state.permalinkCreator.stop(); } if (this.refs.roomView) { // disconnect the D&D event listeners from the room view. This // is really just for hygiene - we're going to be // deleted anyway, so it doesn't matter if the event listeners // don't get cleaned up. const roomView = ReactDOM.findDOMNode(this.refs.roomView); roomView.removeEventListener('drop', this.onDrop); roomView.removeEventListener('dragover', this.onDragOver); roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd); roomView.removeEventListener('dragend', this.onDragLeaveOrEnd); } dis.unregister(this.dispatcherRef); if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener("Room", this.onRoom); 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("accountData", this.onAccountData); MatrixClientPeg.get().removeListener("crypto.keyBackupStatus", this.onKeyBackupStatus); MatrixClientPeg.get().removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); } window.removeEventListener('beforeunload', this.onPageUnload); window.removeEventListener('resize', this.onResize); document.removeEventListener("keydown", this.onKeyDown); // Remove RoomStore listener if (this._roomStoreToken) { this._roomStoreToken.remove(); } WidgetEchoStore.removeListener('update', this._onWidgetEchoStoreUpdate); // cancel any pending calls to the rate_limited_funcs this._updateRoomMembers.cancelPendingCall(); // no need to do this as Dir & Settings are now overlays. It just burnt CPU. // console.log("Tinter.tint from RoomView.unmount"); // Tinter.tint(); // reset colourscheme }, onPageUnload(event) { if (ContentMessages.getCurrentUploads().length > 0) { return event.returnValue = _t("You seem to be uploading files, are you sure you want to quit?"); } else if (this._getCallForRoom() && this.state.callState !== 'ended') { return event.returnValue = _t("You seem to be in a call, are you sure you want to quit?"); } }, onKeyDown: function(ev) { let handled = false; const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); switch (ev.keyCode) { case KeyCode.KEY_D: if (ctrlCmdOnly) { this.onMuteAudioClick(); handled = true; } break; case KeyCode.KEY_E: if (ctrlCmdOnly) { this.onMuteVideoClick(); handled = true; } break; } if (handled) { ev.stopPropagation(); ev.preventDefault(); } }, onAction: function(payload) { switch (payload.action) { case 'message_send_failed': case 'message_sent': this._checkIfAlone(this.state.room); break; case 'post_sticker_message': this.injectSticker( payload.data.content.url, payload.data.content.info, payload.data.description || payload.data.name); break; case 'picture_snapshot': this.uploadFile(payload.file); break; case 'upload_failed': // 413: File was too big or upset the server in some way. if (payload.error && payload.error.http_status === 413) { this._fetchMediaConfig(true); } case 'notifier_enabled': case 'upload_started': case 'upload_finished': this.forceUpdate(); break; case 'call_state': // don't filter out payloads for room IDs other than props.room because // we may be interested in the conf 1:1 room if (!payload.room_id) { return; } var call = this._getCallForRoom(); var callState; if (call) { callState = call.call_state; } else { callState = "ended"; } // possibly remove the conf call notification if we're now in // the conf this._updateConfCallNotification(); this.setState({ callState: callState, }); break; case 'appsDrawer': this.setState({ showApps: payload.show, }); break; } }, onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { if (this.unmounted) return; // ignore events for other rooms if (!room) return; if (!this.state.room || room.roomId != this.state.room.roomId) return; // ignore events from filtered timelines if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; if (ev.getType() === "org.matrix.room.preview_urls") { this._updatePreviewUrlVisibility(room); } if (ev.getType() === "m.room.encryption") { this._updateE2EStatus(room); } // ignore anything but real-time updates at the end of the room: // updates from pagination will happen when the paginate completes. if (toStartOfTimeline || !data || !data.liveEvent) return; // no point handling anything while we're waiting for the join to finish: // we'll only be showing a spinner. if (this.state.joining) return; if (ev.getSender() !== MatrixClientPeg.get().credentials.userId) { // update unread count when scrolled up if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { // no change } else if (!shouldHideEvent(ev)) { this.setState((state, props) => { return {numUnreadMessages: state.numUnreadMessages + 1}; }); } } }, onRoomName: function(room) { if (this.state.room && room.roomId == this.state.room.roomId) { this.forceUpdate(); } }, onRoomRecoveryReminderDontAskAgain: function() { // Called when the option to not ask again is set: // force an update to hide the recovery reminder this.forceUpdate(); }, onKeyBackupStatus() { // Key backup status changes affect whether the in-room recovery // reminder is displayed. this.forceUpdate(); }, canResetTimeline: function() { if (!this.refs.messagePanel) { return true; } return this.refs.messagePanel.canResetTimeline(); }, // called when state.room is first initialised (either at initial load, // after a successful peek, or after we join the room). _onRoomLoaded: function(room) { this._calculatePeekRules(room); this._updatePreviewUrlVisibility(room); this._loadMembersIfJoined(room); this._calculateRecommendedVersion(room); this._updateE2EStatus(room); if (!this.state.permalinkCreator) { const permalinkCreator = new RoomPermalinkCreator(room); permalinkCreator.start(); this.setState({permalinkCreator}); } }, _calculateRecommendedVersion: async function(room) { this.setState({ upgradeRecommendation: await room.getRecommendedVersion(), }); }, _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); } } } }, _calculatePeekRules: function(room) { const guestAccessEvent = room.currentState.getStateEvents("m.room.guest_access", ""); if (guestAccessEvent && guestAccessEvent.getContent().guest_access === "can_join") { this.setState({ guestsCanJoin: true, }); } const historyVisibility = room.currentState.getStateEvents("m.room.history_visibility", ""); if (historyVisibility && historyVisibility.getContent().history_visibility === "world_readable") { this.setState({ canPeek: true, }); } }, _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(key, roomId), }); }, onRoom: function(room) { if (!room || room.roomId !== this.state.roomId) { return; } this.setState({ room: room, }, () => { this._onRoomLoaded(room); }); }, onDeviceVerificationChanged: function(userId, device) { const room = this.state.room; if (!room.currentState.getMember(userId)) { return; } this._updateE2EStatus(room); }, _updateE2EStatus: function(room) { if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) { return; } room.hasUnverifiedDevices().then((hasUnverifiedDevices) => { this.setState({e2eStatus: hasUnverifiedDevices ? "warning" : "verified"}); }); }, updateTint: function() { const room = this.state.room; if (!room) return; console.log("Tinter.tint from updateTint"); const colorScheme = SettingsStore.getValue("roomColor", room.roomId); Tinter.tint(colorScheme.primary_color, colorScheme.secondary_color); }, onAccountData: function(event) { 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) { const type = event.getType(); if (type === "org.matrix.room.color_scheme") { const colorScheme = event.getContent(); // XXX: we should validate the event console.log("Tinter.tint from onRoomAccountData"); Tinter.tint(colorScheme.primary_color, colorScheme.secondary_color); } 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); } } }, onRoomStateMember: function(ev, state, member) { // ignore if we don't have a room yet if (!this.state.room) { return; } // ignore members in other rooms if (member.roomId !== this.state.room.roomId) { return; } this._updateRoomMembers(member); }, onMyMembership: function(room, membership, oldMembership) { if (room.roomId === this.state.roomId) { this.forceUpdate(); this._loadMembersIfJoined(room); } }, // rate limited because a power level change will emit an event for every // member in the room. _updateRoomMembers: new rate_limited_func(function(dueToMember) { // a member state changed in this room // refresh the conf call notification state this._updateConfCallNotification(); this._updateDMState(); let memberCountInfluence = 0; if (dueToMember && dueToMember.membership === "invite" && this.state.room.getInvitedMemberCount() === 0) { // A member got invited, but the room hasn't detected that change yet. Influence the member // count by 1 to counteract this. memberCountInfluence = 1; } this._checkIfAlone(this.state.room, memberCountInfluence); this._updateE2EStatus(this.state.room); }, 500), _checkIfAlone: function(room, countInfluence) { let warnedAboutLonelyRoom = false; if (localStorage) { warnedAboutLonelyRoom = localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId); } if (warnedAboutLonelyRoom) { if (this.state.isAlone) this.setState({isAlone: false}); return; } let joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount(); if (countInfluence) joinedOrInvitedMemberCount += countInfluence; this.setState({isAlone: joinedOrInvitedMemberCount === 1}); }, _updateConfCallNotification: function() { const room = this.state.room; if (!room || !this.props.ConferenceHandler) { return; } const confMember = room.getMember( this.props.ConferenceHandler.getConferenceUserIdForRoom(room.roomId), ); if (!confMember) { return; } const confCall = this.props.ConferenceHandler.getConferenceCallForRoom(confMember.roomId); // A conf call notification should be displayed if there is an ongoing // conf call but this cilent isn't a part of it. this.setState({ displayConfCallNotification: ( (!confCall || confCall.call_state === "ended") && confMember.membership === "join" ), }); }, _updateDMState() { const room = this.state.room; if (room.getMyMembership() != "join") { return; } const dmInviter = room.getDMInviter(); if (dmInviter) { Rooms.setDMRoom(room.roomId, dmInviter); } }, onSearchResultsResize: function() { dis.dispatch({ action: 'timeline_resize' }, true); }, onSearchResultsFillRequest: function(backwards) { if (!backwards) { return Promise.resolve(false); } if (this.state.searchResults.next_batch) { debuglog("requesting more search results"); const searchPromise = MatrixClientPeg.get().backPaginateRoomEventsSearch( this.state.searchResults); return this._handleSearchResult(searchPromise); } else { debuglog("no more search results"); return Promise.resolve(false); } }, onInviteButtonClick: function() { // call AddressPickerDialog dis.dispatch({ action: 'view_invite', roomId: this.state.room.roomId, }); this.setState({isAlone: false}); // there's a good chance they'll invite someone }, onStopAloneWarningClick: function() { if (localStorage) { localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, true); } this.setState({isAlone: false}); }, onJoinButtonClicked: function(ev) { const cli = MatrixClientPeg.get(); // If the user is a ROU, allow them to transition to a PWLU if (cli && cli.isGuest()) { // Join this room once the user has registered and logged in // (If we failed to peek, we may not have a valid room object.) dis.dispatch({ action: 'do_after_sync_prepared', deferred_action: { action: 'view_room', room_id: this._getRoomId(), }, }); // Don't peek whilst registering otherwise getPendingEventList complains // Do this by indicating our intention to join // XXX: ILAG is disabled for now, // see https://github.com/vector-im/riot-web/issues/8222 dis.dispatch({action: 'require_registration'}); // dis.dispatch({ // action: 'will_join', // }); // const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); // const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, { // homeserverUrl: cli.getHomeserverUrl(), // onFinished: (submitted, credentials) => { // if (submitted) { // this.props.onRegistered(credentials); // } else { // dis.dispatch({ // action: 'cancel_after_sync_prepared', // }); // dis.dispatch({ // action: 'cancel_join', // }); // } // }, // onDifferentServerClicked: (ev) => { // dis.dispatch({action: 'start_registration'}); // close(); // }, // onLoginClick: (ev) => { // dis.dispatch({action: 'start_login'}); // close(); // }, // }).close; // return; } else { Promise.resolve().then(() => { const signUrl = this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : undefined; dis.dispatch({ action: 'join_room', opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, }); return Promise.resolve(); }); } }, onMessageListScroll: function(ev) { if (this.refs.messagePanel.isAtEndOfLiveTimeline()) { this.setState({ numUnreadMessages: 0, atEndOfLiveTimeline: true, }); } else { this.setState({ atEndOfLiveTimeline: false, }); } this._updateTopUnreadMessagesBar(); }, onDragOver: function(ev) { ev.stopPropagation(); ev.preventDefault(); ev.dataTransfer.dropEffect = 'none'; const items = [...ev.dataTransfer.items]; if (items.length >= 1) { const isDraggingFiles = items.every(function(item) { return item.kind == 'file'; }); if (isDraggingFiles) { this.setState({ draggingFile: true }); ev.dataTransfer.dropEffect = 'copy'; } } }, onDrop: function(ev) { ev.stopPropagation(); ev.preventDefault(); this.setState({ draggingFile: false }); const files = [...ev.dataTransfer.files]; files.forEach(this.uploadFile); }, onDragLeaveOrEnd: function(ev) { ev.stopPropagation(); ev.preventDefault(); this.setState({ draggingFile: false }); }, isFileUploadAllowed(file) { if (this.state.mediaConfig !== undefined && this.state.mediaConfig["m.upload.size"] !== undefined && file.size > this.state.mediaConfig["m.upload.size"]) { return _t("File is too big. Maximum file size is %(fileSize)s", {fileSize: filesize(this.state.mediaConfig["m.upload.size"])}); } return true; }, uploadFile: async function(file) { dis.dispatch({action: 'focus_composer'}); if (MatrixClientPeg.get().isGuest()) { dis.dispatch({action: 'require_registration'}); return; } try { await ContentMessages.sendContentToRoom(file, this.state.room.roomId, MatrixClientPeg.get()); } catch (error) { if (error.name === "UnknownDeviceError") { // Let the status bar handle this return; } const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to upload file " + file + " " + error); Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, { title: _t('Failed to upload file'), description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or the file too big")), }); // bail early to avoid calling the dispatch below return; } // Send message_sent callback, for things like _checkIfAlone because after all a file is still a message. dis.dispatch({ action: 'message_sent', }); }, injectSticker: function(url, info, text) { if (MatrixClientPeg.get().isGuest()) { dis.dispatch({action: 'require_registration'}); return; } ContentMessages.sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get()) .done(undefined, (error) => { if (error.name === "UnknownDeviceError") { // Let the staus bar handle this return; } }); }, onSearch: function(term, scope) { this.setState({ searchTerm: term, searchScope: scope, searchResults: {}, searchHighlights: [], }); // if we already have a search panel, we need to tell it to forget // about its scroll state. if (this.refs.searchResultsPanel) { this.refs.searchResultsPanel.resetScrollState(); } // make sure that we don't end up showing results from // an aborted search by keeping a unique id. // // todo: should cancel any previous search requests. this.searchId = new Date().getTime(); let filter; if (scope === "Room") { filter = { // XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :( rooms: [ this.state.room.roomId, ], }; } debuglog("sending search request"); const searchPromise = MatrixClientPeg.get().searchRoomEvents({ filter: filter, term: term, }); this._handleSearchResult(searchPromise).done(); }, _handleSearchResult: function(searchPromise) { const self = this; // keep a record of the current search id, so that if the search terms // change before we get a response, we can ignore the results. const localSearchId = this.searchId; this.setState({ searchInProgress: true, }); return searchPromise.then(function(results) { debuglog("search complete"); if (self.unmounted || !self.state.searching || self.searchId != localSearchId) { console.error("Discarding stale search results"); return; } // postgres on synapse returns us precise details of the strings // which actually got matched for highlighting. // // In either case, we want to highlight the literal search term // whether it was used by the search engine or not. let highlights = results.highlights; if (highlights.indexOf(self.state.searchTerm) < 0) { highlights = highlights.concat(self.state.searchTerm); } // For overlapping highlights, // favour longer (more specific) terms first highlights = highlights.sort(function(a, b) { return b.length - a.length; }); self.setState({ searchHighlights: highlights, searchResults: results, }); }, function(error) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Search failed: " + error); Modal.createTrackedDialog('Search failed', '', ErrorDialog, { title: _t("Search failed"), description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or search timed out :(")), }); }).finally(function() { self.setState({ searchInProgress: false, }); }); }, getSearchResultTiles: function() { const EventTile = sdk.getComponent('rooms.EventTile'); const SearchResultTile = sdk.getComponent('rooms.SearchResultTile'); const Spinner = sdk.getComponent("elements.Spinner"); const cli = MatrixClientPeg.get(); // XXX: todo: merge overlapping results somehow? // XXX: why doesn't searching on name work? const ret = []; if (this.state.searchInProgress) { ret.push(