diff --git a/package.json b/package.json index bb8db64d28..b32e5f0501 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "file-saver": "^1.3.3", "filesize": "3.5.6", "flux": "2.1.1", + "focus-trap-react": "^3.0.5", "fuse.js": "^2.2.0", "glob": "^5.0.14", "highlight.js": "^8.9.1", diff --git a/src/Analytics.js b/src/Analytics.js index 5c39b48a35..5f4a0d0c77 100644 --- a/src/Analytics.js +++ b/src/Analytics.js @@ -92,6 +92,10 @@ class Analytics { */ disable() { this.trackEvent('Analytics', 'opt-out'); + // disableHeartBeatTimer is undocumented but exists in the piwik code + // the _paq.push method will result in an error being printed in the console + // if an unknown method signature is passed + this._paq.push(['disableHeartBeatTimer']); this.disabled = true; } diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index ef9010cbf2..c45a335ab6 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -22,28 +22,30 @@ import MatrixClientPeg from './MatrixClientPeg'; import GroupStoreCache from './stores/GroupStoreCache'; export function showGroupInviteDialog(groupId) { - const description =
-
{ _t("Who would you like to add to this community?") }
-
- { _t( - "Warning: any person you add to a community will be publicly "+ - "visible to anyone who knows the community ID", - ) } -
-
; + return new Promise((resolve, reject) => { + const description =
+
{ _t("Who would you like to add to this community?") }
+
+ { _t( + "Warning: any person you add to a community will be publicly "+ + "visible to anyone who knows the community ID", + ) } +
+
; - const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); - Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, { - title: _t("Invite new community members"), - description: description, - placeholder: _t("Name or matrix ID"), - button: _t("Invite to Community"), - validAddressTypes: ['mx-user-id'], - onFinished: (success, addrs) => { - if (!success) return; + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, { + title: _t("Invite new community members"), + description: description, + placeholder: _t("Name or matrix ID"), + button: _t("Invite to Community"), + validAddressTypes: ['mx-user-id'], + onFinished: (success, addrs) => { + if (!success) return; - _onGroupInviteFinished(groupId, addrs); - }, + _onGroupInviteFinished(groupId, addrs).then(resolve, reject); + }, + }); }); } @@ -87,7 +89,7 @@ function _onGroupInviteFinished(groupId, addrs) { const addrTexts = addrs.map((addr) => addr.address); - multiInviter.invite(addrTexts).then((completionStates) => { + return multiInviter.invite(addrTexts).then((completionStates) => { // Show user any errors const errorList = []; for (const addr of Object.keys(completionStates)) { diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 026c6b1499..c621d8314e 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -414,19 +414,11 @@ class TextHighlighter extends BaseHighlighter { * opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing */ export function bodyToHtml(content, highlights, opts={}) { - const isHtml = (content.format === "org.matrix.custom.html") && content.formatted_body; - let body; - if (isHtml) { - body = content.formatted_body; - // Part of Replies fallback support - if (opts.stripReplyFallback) body = ReplyThread.stripHTMLReply(body); - } else { - // Part of Replies fallback support - special because strip must be before escape - body = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : escape(content.body); - } + let isHtml = content.format === "org.matrix.custom.html" && content.formatted_body; let bodyHasEmoji = false; + let strippedBody; let safeBody; // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which @@ -443,9 +435,32 @@ export function bodyToHtml(content, highlights, opts={}) { return highlighter.applyHighlights(safeText, safeHighlights).join(''); }; } - safeBody = sanitizeHtml(body, sanitizeHtmlParams); - bodyHasEmoji = containsEmoji(body); - if (bodyHasEmoji) safeBody = unicodeToImage(safeBody); + + let formattedBody = content.formatted_body; + if (opts.stripReplyFallback) formattedBody = ReplyThread.stripHTMLReply(content.formatted_body); + strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : content.body; + + bodyHasEmoji = containsEmoji(isHtml ? formattedBody : content.body); + + + // Only generate safeBody if the message was sent as org.matrix.custom.html + if (isHtml) { + safeBody = sanitizeHtml(formattedBody, sanitizeHtmlParams); + } else { + // ... or if there are emoji, which we insert as HTML alongside the + // escaped plaintext body. + if (bodyHasEmoji) { + isHtml = true; + safeBody = sanitizeHtml(escape(strippedBody), sanitizeHtmlParams); + } + } + + // An HTML message with emoji + // or a plaintext message with emoji that was escaped and sanitized into + // HTML. + if (bodyHasEmoji) { + safeBody = unicodeToImage(safeBody); + } } finally { delete sanitizeHtmlParams.textFilter; } @@ -463,7 +478,12 @@ export function bodyToHtml(content, highlights, opts={}) { 'mx_EventTile_bigEmoji': emojiBody, 'markdown-body': isHtml, }); - return ; + + if (isHtml) { + return ; + } + + return { strippedBody }; } export function emojifyText(text) { diff --git a/src/Lifecycle.js b/src/Lifecycle.js index efd5c20d5c..ec1fca2bc6 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -362,7 +362,7 @@ async function _doSetLoggedIn(credentials, clearStorage) { dis.dispatch({action: 'on_logged_in', teamToken: teamToken}); }); - startMatrixClient(); + await startMatrixClient(); return MatrixClientPeg.get(); } @@ -423,7 +423,7 @@ export function logout() { * Starts the matrix client and all other react-sdk services that * listen for events while a session is logged in. */ -function startMatrixClient() { +async function startMatrixClient() { console.log(`Lifecycle: Starting MatrixClient`); // dispatch this before starting the matrix client: it's used @@ -437,7 +437,7 @@ function startMatrixClient() { Presence.start(); DMRoomMap.makeShared().start(); - MatrixClientPeg.start(); + await MatrixClientPeg.start(); // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 14dfa91fa4..99841c986e 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -175,4 +175,4 @@ class MatrixClientPeg { if (!global.mxMatrixClientPeg) { global.mxMatrixClientPeg = new MatrixClientPeg(); } -module.exports = global.mxMatrixClientPeg; +export default global.mxMatrixClientPeg; diff --git a/src/Modal.js b/src/Modal.js index c9f08772e7..2565d5c73b 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -22,6 +22,7 @@ const ReactDOM = require('react-dom'); import PropTypes from 'prop-types'; import Analytics from './Analytics'; import sdk from './index'; +import dis from './dispatcher'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; @@ -188,10 +189,22 @@ class ModalManager { _reRender() { if (this._modals.length == 0) { + // If there is no modal to render, make all of Riot available + // to screen reader users again + dis.dispatch({ + action: 'aria_unhide_main_app', + }); ReactDOM.unmountComponentAtNode(this.getOrCreateContainer()); return; } + // Hide the content outside the modal to screen reader users + // so they won't be able to navigate into it and act on it using + // screen reader specific features + dis.dispatch({ + action: 'aria_hide_main_app', + }); + const modal = this._modals[0]; const dialog = (
diff --git a/src/Presence.js b/src/Presence.js index 2652c64c96..fd9bcf516d 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket 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. @@ -31,7 +32,7 @@ class Presence { this.running = true; if (undefined === this.state) { this._resetTimer(); - this.dispatcherRef = dis.register(this._onUserActivity.bind(this)); + this.dispatcherRef = dis.register(this._onAction.bind(this)); } } @@ -125,9 +126,10 @@ class Presence { this.setState("unavailable"); } - _onUserActivity(payload) { - if (payload.action === "sync_state" || payload.action === "self_presence_updated") return; - this._resetTimer(); + _onAction(payload) { + if (payload.action === "user_activity") { + this._resetTimer(); + } } /** diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 3c164c6551..fc8ee9edf6 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -563,7 +563,7 @@ const onMessage = function(event) { const url = SdkConfig.get().integrations_ui_url; if ( event.origin.length === 0 || - !url.startsWith(event.origin) || + !url.startsWith(event.origin + '/') || !event.data.action || event.data.api // Ignore messages with specific API set ) { diff --git a/src/Tinter.js b/src/Tinter.js index c7402c15be..7667e6d912 100644 --- a/src/Tinter.js +++ b/src/Tinter.js @@ -252,7 +252,6 @@ class Tinter { setTheme(theme) { - console.trace("setTheme " + theme); this.theme = theme; // update keyRgb from the current theme CSS itself, if it defines it diff --git a/src/Unread.js b/src/Unread.js index 383b5c2e5a..55e60f2a9a 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -28,6 +28,8 @@ module.exports = { return false; } else if (ev.getType() == 'm.room.member') { return false; + } else if (ev.getType() == 'm.room.third_party_invite') { + return false; } else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') { return false; } else if (ev.getType == 'm.room.message' && ev.getContent().msgtype == 'm.notify') { diff --git a/src/actions/MatrixActionCreators.js b/src/actions/MatrixActionCreators.js index dbfe910533..6e1d52a88f 100644 --- a/src/actions/MatrixActionCreators.js +++ b/src/actions/MatrixActionCreators.js @@ -62,14 +62,127 @@ function createAccountDataAction(matrixClient, accountDataEvent) { }; } +/** + * @typedef RoomAction + * @type {Object} + * @property {string} action 'MatrixActions.Room'. + * @property {Room} room the Room that was stored. + */ + +/** + * Create a MatrixActions.Room action that represents a MatrixClient `Room` + * matrix event, emitted when a Room is stored in the client. + * + * @param {MatrixClient} matrixClient the matrix client. + * @param {Room} room the Room that was stored. + * @returns {RoomAction} an action of type `MatrixActions.Room`. + */ +function createRoomAction(matrixClient, room) { + return { action: 'MatrixActions.Room', room }; +} + +/** + * @typedef RoomTagsAction + * @type {Object} + * @property {string} action 'MatrixActions.Room.tags'. + * @property {Room} room the Room whose tags changed. + */ + +/** + * Create a MatrixActions.Room.tags action that represents a MatrixClient + * `Room.tags` matrix event, emitted when the m.tag room account data + * event is updated. + * + * @param {MatrixClient} matrixClient the matrix client. + * @param {MatrixEvent} roomTagsEvent the m.tag event. + * @param {Room} room the Room whose tags were changed. + * @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`. + */ function createRoomTagsAction(matrixClient, roomTagsEvent, room) { return { action: 'MatrixActions.Room.tags', room }; } +/** + * @typedef RoomTimelineAction + * @type {Object} + * @property {string} action 'MatrixActions.Room.timeline'. + * @property {boolean} isLiveEvent whether the event was attached to a + * live timeline. + * @property {boolean} isLiveUnfilteredRoomTimelineEvent whether the + * event was attached to a timeline in the set of unfiltered timelines. + * @property {Room} room the Room whose tags changed. + */ + +/** + * Create a MatrixActions.Room.timeline action that represents a + * MatrixClient `Room.timeline` matrix event, emitted when an event + * is added to or removed from a timeline of a room. + * + * @param {MatrixClient} matrixClient the matrix client. + * @param {MatrixEvent} timelineEvent the event that was added/removed. + * @param {Room} room the Room that was stored. + * @param {boolean} toStartOfTimeline whether the event is being added + * to the start (and not the end) of the timeline. + * @param {boolean} removed whether the event was removed from the + * timeline. + * @param {Object} data + * @param {boolean} data.liveEvent whether the event is a live event, + * belonging to a live timeline. + * @param {EventTimeline} data.timeline the timeline being altered. + * @returns {RoomTimelineAction} an action of type `MatrixActions.Room.timeline`. + */ +function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTimeline, removed, data) { + return { + action: 'MatrixActions.Room.timeline', + event: timelineEvent, + isLiveEvent: data.liveEvent, + isLiveUnfilteredRoomTimelineEvent: + room && data.timeline.getTimelineSet() === room.getUnfilteredTimelineSet(), + }; +} + +/** + * @typedef RoomMembershipAction + * @type {Object} + * @property {string} action 'MatrixActions.RoomMember.membership'. + * @property {RoomMember} member the member whose membership was updated. + */ + +/** + * Create a MatrixActions.RoomMember.membership action that represents + * a MatrixClient `RoomMember.membership` matrix event, emitted when a + * member's membership is updated. + * + * @param {MatrixClient} matrixClient the matrix client. + * @param {MatrixEvent} membershipEvent the m.room.member event. + * @param {RoomMember} member the member whose membership was updated. + * @param {string} oldMembership the member's previous membership. + * @returns {RoomMembershipAction} an action of type `MatrixActions.RoomMember.membership`. + */ function createRoomMembershipAction(matrixClient, membershipEvent, member, oldMembership) { return { action: 'MatrixActions.RoomMember.membership', member }; } +/** + * @typedef EventDecryptedAction + * @type {Object} + * @property {string} action 'MatrixActions.Event.decrypted'. + * @property {MatrixEvent} event the matrix event that was decrypted. + */ + +/** + * Create a MatrixActions.Event.decrypted action that represents + * a MatrixClient `Event.decrypted` matrix event, emitted when a + * matrix event is decrypted. + * + * @param {MatrixClient} matrixClient the matrix client. + * @param {MatrixEvent} event the matrix event that was decrypted. + * @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`. + */ +function createEventDecryptedAction(matrixClient, event) { + return { action: 'MatrixActions.Event.decrypted', event }; +} + /** * This object is responsible for dispatching actions when certain events are emitted by * the given MatrixClient. @@ -86,8 +199,11 @@ export default { start(matrixClient) { this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); + this._addMatrixClientListener(matrixClient, 'Room', createRoomAction); this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); + this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction); this._addMatrixClientListener(matrixClient, 'RoomMember.membership', createRoomMembershipAction); + this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction); }, /** diff --git a/src/actions/RoomListActions.js b/src/actions/RoomListActions.js index a92bd1ebaf..e5911c4e32 100644 --- a/src/actions/RoomListActions.js +++ b/src/actions/RoomListActions.js @@ -18,7 +18,7 @@ import { asyncAction } from './actionCreators'; import RoomListStore from '../stores/RoomListStore'; import Modal from '../Modal'; -import Rooms from '../Rooms'; +import * as Rooms from '../Rooms'; import { _t } from '../languageHandler'; import sdk from '../index'; diff --git a/src/actions/TagOrderActions.js b/src/actions/TagOrderActions.js index 38bada2228..a257ff16d8 100644 --- a/src/actions/TagOrderActions.js +++ b/src/actions/TagOrderActions.js @@ -35,7 +35,7 @@ const TagOrderActions = {}; TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) { // Only commit tags if the state is ready, i.e. not null let tags = TagOrderStore.getOrderedTags(); - let removedTags = TagOrderStore.getRemovedTagsAccountData(); + let removedTags = TagOrderStore.getRemovedTagsAccountData() || []; if (!tags) { return; } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index d47f1a161a..e33fa7861f 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -105,6 +105,11 @@ const COMMANDS = [ args: '', description: _td('Stops ignoring a user, showing their messages going forward'), }, + { + command: '/devtools', + args: '', + description: _td('Opens the Developer Tools dialog'), + }, // Omitting `/markdown` as it only seems to apply to OldComposer ]; diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index bceec3f144..e636f95751 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -44,6 +44,7 @@ export default class UserProvider extends AutocompleteProvider { this.matcher = new FuzzyMatcher([], { keys: ['name', 'userId'], shouldMatchPrefix: true, + shouldMatchWordsOnly: false }); this._onRoomTimelineBound = this._onRoomTimeline.bind(this); @@ -72,6 +73,7 @@ export default class UserProvider extends AutocompleteProvider { // updates from pagination will happen when the paginate completes. if (toStartOfTimeline || !data || !data.liveEvent) return; + // TODO: lazyload if we have no ev.sender room member? this.onUserSpoke(ev.sender); } @@ -147,6 +149,7 @@ export default class UserProvider extends AutocompleteProvider { onUserSpoke(user: RoomMember) { if (this.users === null) return; + if (!user) return; if (user.userId === MatrixClientPeg.get().credentials.userId) return; // Move the user that spoke to the front of the array diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index de96935838..30e897e5dc 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -29,6 +29,7 @@ import classnames from 'classnames'; import GroupStoreCache from '../../stores/GroupStoreCache'; import GroupStore from '../../stores/GroupStore'; +import FlairStore from '../../stores/FlairStore'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GeminiScrollbar from 'react-gemini-scrollbar'; import {makeGroupPermalink, makeUserPermalink} from "../../matrix-to"; @@ -429,6 +430,7 @@ export default React.createClass({ editing: false, saving: false, uploadingAvatar: false, + avatarChanged: false, membershipBusy: false, publicityBusy: false, inviterProfile: null, @@ -590,6 +592,10 @@ export default React.createClass({ this.setState({ uploadingAvatar: false, profileForm: newProfileForm, + + // Indicate that FlairStore needs to be poked to show this change + // in TagTile (TagPanel), Flair and GroupTile (MyGroups). + avatarChanged: true, }); }).catch((e) => { this.setState({uploadingAvatar: false}); @@ -615,6 +621,11 @@ export default React.createClass({ }); dis.dispatch({action: 'panel_disable'}); this._initGroupStore(this.props.groupId); + + if (this.state.avatarChanged) { + // XXX: Evil - poking a store should be done from an async action + FlairStore.refreshGroupProfile(this._matrixClient, this.props.groupId); + } }).catch((e) => { this.setState({ saving: false, @@ -625,6 +636,10 @@ export default React.createClass({ title: _t('Error'), description: _t('Failed to update community'), }); + }).finally(() => { + this.setState({ + avatarChanged: false, + }); }).done(); }, @@ -692,8 +707,15 @@ export default React.createClass({ }); const header = this.state.editing ?

{ _t('Community Settings') }

:
; + const changeDelayWarning = this.state.editing && this.state.isUserPrivileged ? +
+ { _t( 'Changes made to your community might not be seen by other users ' + + 'for up to 30 minutes.', + ) } +
:
; return
{ header } + { changeDelayWarning } { this._getLongDescriptionNode() } { this._getRoomsNode() }
; diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index f6bbfd247b..d9ac9de693 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -374,7 +374,7 @@ const LoggedInView = React.createClass({ } return ( -
+
{ topBar }
diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index b37da0144f..1312abda09 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -171,6 +171,10 @@ export default React.createClass({ register_hs_url: null, register_is_url: null, register_id_sid: null, + + // 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, }; return s; }, @@ -608,6 +612,16 @@ export default React.createClass({ case 'send_event': this.onSendEvent(payload.room_id, payload.event); break; + case 'aria_hide_main_app': + this.setState({ + hideToSRUsers: true, + }); + break; + case 'aria_unhide_main_app': + this.setState({ + hideToSRUsers: false, + }); + break; } }, @@ -1171,18 +1185,6 @@ export default React.createClass({ cli.on("crypto.warning", (type) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); switch (type) { - case 'CRYPTO_WARNING_ACCOUNT_MIGRATED': - Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, { - title: _t('Cryptography data migrated'), - description: _t( - "A one-off migration of cryptography data has been performed. "+ - "End-to-end encryption will not work if you go back to an older "+ - "version of Riot. If you need to use end-to-end cryptography on "+ - "an older version, log out of Riot first. To retain message history, "+ - "export and re-import your keys.", - ), - }); - break; case 'CRYPTO_WARNING_OLD_VERSION_DETECTED': Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, { title: _t('Old cryptography data detected'), diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 116607fb08..da7bebd16a 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -74,6 +74,21 @@ export default withMatrixClient(React.createClass({ contentHeader = groupNodes.length > 0 ?

{ _t('Your Communities') }

:
; content = groupNodes.length > 0 ? +
+

+ { _t( + "Did you know: you can use communities to filter your Riot.im experience!", + ) } +

+

+ { _t( + "To set up a filter, drag a community avatar over to the filter panel on " + + "the far left hand side of the screen. You can click on an avatar in the " + + "filter panel at any time to see only the rooms and people associated " + + "with that community.", + ) } +

+
{ groupNodes }
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 75fa316030..8f0134dc55 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -628,8 +628,8 @@ module.exports = React.createClass({ const room = this.state.room; if (!room) return; - const color_scheme = SettingsStore.getValue("roomColor", room.room_id); console.log("Tinter.tint from updateTint"); + const color_scheme = SettingsStore.getValue("roomColor", room.roomId); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); }, @@ -678,23 +678,7 @@ module.exports = React.createClass({ // a member state changed in this room // refresh the conf call notification state this._updateConfCallNotification(); - - // if we are now a member of the room, where we were not before, that - // means we have finished joining a room we were previously peeking - // into. - const me = MatrixClientPeg.get().credentials.userId; - if (this.state.joining && this.state.room.hasMembershipState(me, "join")) { - // Having just joined a room, check to see if it looks like a DM room, and if so, - // mark it as one. This is to work around the fact that some clients don't support - // is_direct. We should remove this once they do. - const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId); - if (Rooms.looksLikeDirectMessageRoom(this.state.room, me)) { - // XXX: There's not a whole lot we can really do if this fails: at best - // perhaps we could try a couple more times, but since it's a temporary - // compatability workaround, let's not bother. - Rooms.setDMRoom(this.state.room.roomId, me.events.member.getSender()).done(); - } - } + this._updateDMState(); }, 500), _checkIfAlone: function(room) { @@ -735,6 +719,44 @@ module.exports = React.createClass({ }); }, + _updateDMState() { + const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId); + if (!me || me.membership !== "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; + } + }, + onSearchResultsResize: function() { dis.dispatch({ action: 'timeline_resize' }, true); }, @@ -827,18 +849,6 @@ module.exports = React.createClass({ action: 'join_room', opts: { inviteSignUrl: signUrl }, }); - - // if this is an invite and has the 'direct' hint set, mark it as a DM room now. - if (this.state.room) { - const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId); - if (me && me.membership == 'invite') { - if (me.events.member.getContent().is_direct) { - // The 'direct' hint is there, so declare that this is a DM room for - // whoever invited us. - return Rooms.setDMRoom(this.state.room.roomId, me.events.member.getSender()); - } - } - } return Promise.resolve(); }); }, diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 6e3bcf521b..7a187e4298 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -17,12 +17,14 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk'; +import GeminiScrollbar from 'react-gemini-scrollbar'; import TagOrderStore from '../../stores/TagOrderStore'; import GroupActions from '../../actions/GroupActions'; import sdk from '../../index'; import dis from '../../dispatcher'; +import { _t } from '../../languageHandler'; import { Droppable } from 'react-beautiful-dnd'; @@ -43,7 +45,7 @@ const TagPanel = React.createClass({ componentWillMount: function() { this.unmounted = false; this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership); - this.context.matrixClient.on("sync", this.onClientSync); + this.context.matrixClient.on("sync", this._onClientSync); this._tagOrderStoreToken = TagOrderStore.addListener(() => { if (this.unmounted) { @@ -61,7 +63,7 @@ const TagPanel = React.createClass({ componentWillUnmount() { this.unmounted = true; this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership); - this.context.matrixClient.removeListener("sync", this.onClientSync); + this.context.matrixClient.removeListener("sync", this._onClientSync); if (this._filterStoreToken) { this._filterStoreToken.remove(); } @@ -72,7 +74,7 @@ const TagPanel = React.createClass({ dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); }, - onClientSync(syncState, prevState) { + _onClientSync(syncState, prevState) { // Consider the client reconnected if there is no error with syncing. // This means the state could be RECONNECTING, SYNCING or PREPARED. const reconnected = syncState !== "ERROR" && prevState !== syncState; @@ -82,9 +84,7 @@ const TagPanel = React.createClass({ } }, - onClick(e) { - // Ignore clicks on children - if (e.target !== e.currentTarget) return; + onMouseDown(e) { dis.dispatch({action: 'deselect_tags'}); }, @@ -93,9 +93,15 @@ const TagPanel = React.createClass({ dis.dispatch({action: 'view_create_group'}); }, + onClearFilterClick(ev) { + dis.dispatch({action: 'deselect_tags'}); + }, + render() { const GroupsButton = sdk.getComponent('elements.GroupsButton'); const DNDTagTile = sdk.getComponent('elements.DNDTagTile'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const TintableSvg = sdk.getComponent('elements.TintableSvg'); const tags = this.state.orderedTags.map((tag, index) => { return ; }); + + const clearButton = this.state.selectedTags.length > 0 ? + : +
; + return
- + { clearButton } + +
+ - { (provided, snapshot) => ( -
- { tags } - { provided.placeholder } -
- ) } - + + { (provided, snapshot) => ( +
+ { tags } + { provided.placeholder } +
+ ) } +
+
+
diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 12f745146e..1a03b5d994 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -624,6 +624,7 @@ var TimelinePanel = React.createClass({ this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0); dis.dispatch({ action: 'on_room_read', + roomId: this.props.timelineSet.room.roomId, }); } } diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index b1eedd1a90..f6629b4b09 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -79,6 +79,7 @@ const SIMPLE_SETTINGS = [ { id: "Pill.shouldHidePillAvatar" }, { id: "TextualBody.disableBigEmoji" }, { id: "VideoView.flipVideoHorizontally" }, + { id: "TagPanel.disableTagPanel" }, ]; // These settings must be defined in SettingsStore @@ -794,11 +795,18 @@ module.exports = React.createClass({ } return (
-

{ _t("Bug Report") }

+

{ _t("Debug Logs Submission") }

-

{ _t("Found a bug?") }

+

{ + _t( "If you've submitted a bug via GitHub, debug logs can help " + + "us track down the problem. Debug logs contain application " + + "usage data including your username, the IDs or aliases of " + + "the rooms or groups you have visited and the usernames of " + + "other users. They do not contian messages.", + ) + }

diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index 5735a99125..6fb86c9cd8 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket 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. @@ -82,7 +83,7 @@ module.exports = React.createClass({ } }, - onClientSync(syncState, prevState) { + onClientSync: function(syncState, prevState) { if (this.unmounted) return; // Consider the client reconnected if there is no error with syncing. diff --git a/src/components/views/avatars/RoomAvatar.js b/src/components/views/avatars/RoomAvatar.js index cae02ac408..e547cf0fa7 100644 --- a/src/components/views/avatars/RoomAvatar.js +++ b/src/components/views/avatars/RoomAvatar.js @@ -48,12 +48,33 @@ module.exports = React.createClass({ }; }, + componentWillMount: function() { + MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); + }, + + componentWillUnmount: function() { + const cli = MatrixClientPeg.get(); + if (cli) { + cli.removeListener("RoomState.events", this.onRoomStateEvents); + } + }, + componentWillReceiveProps: function(newProps) { this.setState({ urls: this.getImageUrls(newProps), }); }, + onRoomStateEvents: function(ev) { + if (ev.getRoomId() !== this.props.room.roomId || + ev.getType() !== 'm.room.avatar' + ) return; + + this.setState({ + urls: this.getImageUrls(this.props), + }); + }, + getImageUrls: function(props) { return [ ContentRepo.getHttpUriForMxc( diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index 66e5fcb0c0..21a2477c37 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import FocusTrap from 'focus-trap-react'; import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk'; @@ -37,9 +38,6 @@ export default React.createClass({ // onFinished callback to call when Escape is pressed onFinished: PropTypes.func.isRequired, - // callback to call when Enter is pressed - onEnterPressed: PropTypes.func, - // called when a key is pressed onKeyDown: PropTypes.func, @@ -52,6 +50,10 @@ export default React.createClass({ // children should be the content of the dialog children: PropTypes.node, + + // Id of content element + // If provided, this is used to add a aria-describedby attribute + contentId: React.PropTypes.string, }, childContextTypes: { @@ -76,12 +78,6 @@ export default React.createClass({ e.stopPropagation(); e.preventDefault(); this.props.onFinished(); - } else if (e.keyCode === KeyCode.ENTER) { - if (this.props.onEnterPressed) { - e.stopPropagation(); - e.preventDefault(); - this.props.onEnterPressed(e); - } } }, @@ -93,17 +89,28 @@ export default React.createClass({ const TintableSvg = sdk.getComponent("elements.TintableSvg"); return ( -
+ -
+
{ this.props.title }
{ this.props.children } -
+
); }, }); diff --git a/src/components/views/dialogs/ChatCreateOrReuseDialog.js b/src/components/views/dialogs/ChatCreateOrReuseDialog.js index dc4f3f77db..e2387064cf 100644 --- a/src/components/views/dialogs/ChatCreateOrReuseDialog.js +++ b/src/components/views/dialogs/ChatCreateOrReuseDialog.js @@ -59,6 +59,7 @@ export default class ChatCreateOrReuseDialog extends React.Component { ); tiles.push(
{ _t("Start new chat") }
; - content =
+ content =
{ _t('You already have existing direct chats with this user:') }
{ this.state.tiles } @@ -146,7 +147,7 @@ export default class ChatCreateOrReuseDialog extends React.Component { if (this.state.busyProfile) { profile = ; } else if (this.state.profileError) { - profile =
+ profile =
Unable to load profile information for { this.props.userId }
; } else { @@ -162,14 +163,14 @@ export default class ChatCreateOrReuseDialog extends React.Component {
; } content =
-
+

{ _t('Click on the button below to start chatting!') }

{ profile }
+ onPrimaryButtonClick={this.props.onNewDMClick} focus="true" />
; } @@ -178,6 +179,7 @@ export default class ChatCreateOrReuseDialog extends React.Component { { content } diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.js index f347261470..b65d98d78d 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.js @@ -114,10 +114,10 @@ export default React.createClass({ return ( -
+
{ avatar }
diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js index 86a2b2498c..04f99a0e15 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.js @@ -112,7 +112,7 @@ export default React.createClass({ // XXX: We should catch errcodes and give sensible i18ned messages for them, // rather than displaying what the server gives us, but synapse doesn't give // any yet. - createErrorNode =
+ createErrorNode =
{ _t('Something went wrong whilst creating your community') }
{ this.state.createError.message }
; @@ -120,7 +120,6 @@ export default React.createClass({ return (
diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js index d9287d23da..51693a19c9 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -45,30 +45,31 @@ export default React.createClass({ const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( -
-
- -
-
- -
-
- -
- { _t('Advanced options') } -
- - + +
+
+
-
-
+
+ +
+
+ +
+ { _t('Advanced options') } +
+ + +
+
+
+ diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index 2af2d6214f..a055f07629 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -52,22 +52,18 @@ export default React.createClass({ }; }, - componentDidMount: function() { - if (this.props.focus) { - this.refs.button.focus(); - } - }, - render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( -
+ title={this.props.title || _t('Error')} + contentId='mx_Dialog_content' + > +
{ this.props.description || _t('An error has occurred.') }
-
diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index a47702305c..b682156072 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -73,11 +73,12 @@ export default React.createClass({ let content; if (this.state.authError) { content = ( -
-
{ this.state.authError.message || this.state.authError.toString() }
+
+
{ this.state.authError.message || this.state.authError.toString() }

{ _t("Dismiss") } @@ -85,7 +86,7 @@ export default React.createClass({ ); } else { content = ( -
+
{ content } diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js index 00bcc942a1..b9b64a69d2 100644 --- a/src/components/views/dialogs/KeyShareDialog.js +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -126,11 +126,11 @@ export default React.createClass({ text = _t(text, {displayName: displayName}); return ( -
+

{ text }

-