diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index b306eab23c..0c262fe89a 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -208,7 +208,7 @@ const sanitizeHtmlParams = { // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // because transformTags is used _before_ we filter by allowedSchemesByTag and // we don't want to allow images with `https?` `src`s. - if (!attribs.src.startsWith('mxc://')) { + if (!attribs.src || !attribs.src.startsWith('mxc://')) { return { tagName, attribs: {}}; } attribs.src = MatrixClientPeg.get().mxcUrlToHttp( diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 4c7d039da4..c93ae4fb2a 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +Copyright 2017 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. @@ -28,6 +29,10 @@ export default class AutocompleteProvider { } } + destroy() { + // stub + } + /** * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. */ diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index 5b10110f04..3d30363d9f 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -1,5 +1,6 @@ /* Copyright 2016 Aviral Dasgupta +Copyright 2017 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. @@ -22,6 +23,7 @@ import DuckDuckGoProvider from './DuckDuckGoProvider'; import RoomProvider from './RoomProvider'; import UserProvider from './UserProvider'; import EmojiProvider from './EmojiProvider'; +import NotifProvider from './NotifProvider'; import Promise from 'bluebird'; export type SelectionRange = { @@ -43,43 +45,59 @@ const PROVIDERS = [ UserProvider, RoomProvider, EmojiProvider, + NotifProvider, CommandProvider, DuckDuckGoProvider, -].map((completer) => completer.getInstance()); +]; // Providers will get rejected if they take longer than this. const PROVIDER_COMPLETION_TIMEOUT = 3000; -export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { - /* Note: That this waits for all providers to return is *intentional* - otherwise, we run into a condition where new completions are displayed - while the user is interacting with the list, which makes it difficult - to predict whether an action will actually do what is intended - */ - const completionsList = await Promise.all( - // Array of inspections of promises that might timeout. Instead of allowing a - // single timeout to reject the Promise.all, reflect each one and once they've all - // settled, filter for the fulfilled ones - PROVIDERS.map((provider) => { - return provider - .getCompletions(query, selection, force) - .timeout(PROVIDER_COMPLETION_TIMEOUT) - .reflect(); - }), - ); +export default class Autocompleter { + constructor(room) { + this.room = room; + this.providers = PROVIDERS.map((p) => { + return new p(room); + }); + } - return completionsList.filter( - (inspection) => inspection.isFulfilled(), - ).map((completionsState, i) => { - return { - completions: completionsState.value(), - provider: PROVIDERS[i], + destroy() { + this.providers.forEach((p) => { + p.destroy(); + }); + } - /* the currently matched "command" the completer tried to complete - * we pass this through so that Autocomplete can figure out when to - * re-show itself once hidden. - */ - command: PROVIDERS[i].getCurrentCommand(query, selection, force), - }; - }); + async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { + /* Note: This intentionally waits for all providers to return, + otherwise, we run into a condition where new completions are displayed + while the user is interacting with the list, which makes it difficult + to predict whether an action will actually do what is intended + */ + const completionsList = await Promise.all( + // Array of inspections of promises that might timeout. Instead of allowing a + // single timeout to reject the Promise.all, reflect each one and once they've all + // settled, filter for the fulfilled ones + this.providers.map((provider) => { + return provider + .getCompletions(query, selection, force) + .timeout(PROVIDER_COMPLETION_TIMEOUT) + .reflect(); + }), + ); + + return completionsList.filter( + (inspection) => inspection.isFulfilled(), + ).map((completionsState, i) => { + return { + completions: completionsState.value(), + provider: this.providers[i], + + /* the currently matched "command" the completer tried to complete + * we pass this through so that Autocomplete can figure out when to + * re-show itself once hidden. + */ + command: this.providers[i].getCurrentCommand(query, selection, force), + }; + }); + } } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index e85457e6aa..d47f1a161a 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +Copyright 2017 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. @@ -109,8 +110,6 @@ const COMMANDS = [ const COMMAND_RE = /(^\/\w*)/g; -let instance = null; - export default class CommandProvider extends AutocompleteProvider { constructor() { super(COMMAND_RE); @@ -142,12 +141,6 @@ export default class CommandProvider extends AutocompleteProvider { return '*️⃣ ' + _t('Commands'); } - static getInstance(): CommandProvider { - if (instance === null) instance = new CommandProvider(); - - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index b2e85c4668..68d4915f56 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +Copyright 2017 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. @@ -25,8 +26,6 @@ import {TextualCompletion} from './Components'; const DDG_REGEX = /\/ddg\s+(.+)$/g; const REFERRER = 'vector'; -let instance = null; - export default class DuckDuckGoProvider extends AutocompleteProvider { constructor() { super(DDG_REGEX); @@ -96,13 +95,6 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { return '🔍 ' + _t('Results from DuckDuckGo'); } - static getInstance(): DuckDuckGoProvider { - if (instance == null) { - instance = new DuckDuckGoProvider(); - } - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index a5b80e3b0e..9f1f40dbe7 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +Copyright 2017 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. @@ -70,8 +71,6 @@ const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sor }; }); -let instance = null; - function score(query, space) { const index = space.indexOf(query); if (index === -1) { @@ -151,11 +150,6 @@ export default class EmojiProvider extends AutocompleteProvider { return '😃 ' + _t('Emoji'); } - static getInstance() { - if (instance == null) {instance = new EmojiProvider();} - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/NotifProvider.js b/src/autocomplete/NotifProvider.js new file mode 100644 index 0000000000..b7ac645525 --- /dev/null +++ b/src/autocomplete/NotifProvider.js @@ -0,0 +1,62 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import AutocompleteProvider from './AutocompleteProvider'; +import { _t } from '../languageHandler'; +import MatrixClientPeg from '../MatrixClientPeg'; +import {PillCompletion} from './Components'; +import sdk from '../index'; + +const AT_ROOM_REGEX = /@\S*/g; + +export default class NotifProvider extends AutocompleteProvider { + constructor(room) { + super(AT_ROOM_REGEX); + this.room = room; + } + + async getCompletions(query: string, selection: {start: number, end: number}, force = false) { + const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); + + const client = MatrixClientPeg.get(); + + if (!this.room.currentState.mayTriggerNotifOfType('room', client.credentials.userId)) return []; + + const {command, range} = this.getCurrentCommand(query, selection, force); + if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) { + return [{ + completion: '@room', + suffix: ' ', + component: ( + } title="@room" description={_t("Notify the whole room")} /> + ), + range, + }]; + } + return []; + } + + getName() { + return '❗️ ' + _t('Room Notification'); + } + + renderCompletions(completions: [React.Component]): ?React.Component { + return
+ { completions } +
; + } +} diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index cc04f54dda..1e1928a1ee 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +Copyright 2017 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. @@ -27,8 +28,6 @@ import _sortBy from 'lodash/sortBy'; const ROOM_REGEX = /(?=#)(\S*)/g; -let instance = null; - function score(query, space) { const index = space.indexOf(query); if (index === -1) { @@ -96,14 +95,6 @@ export default class RoomProvider extends AutocompleteProvider { return '💬 ' + _t('Rooms'); } - static getInstance() { - if (instance == null) { - instance = new RoomProvider(); - } - - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 296399c06c..8b43964b1a 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -2,6 +2,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +Copyright 2017 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -30,20 +31,55 @@ import type {Room, RoomMember} from 'matrix-js-sdk'; const USER_REGEX = /@\S*/g; -let instance = null; - export default class UserProvider extends AutocompleteProvider { users: Array = null; room: Room = null; - constructor() { + constructor(room) { super(USER_REGEX, { keys: ['name'], }); + this.room = room; this.matcher = new FuzzyMatcher([], { keys: ['name', 'userId'], shouldMatchPrefix: true, }); + + this._onRoomTimelineBound = this._onRoomTimeline.bind(this); + this._onRoomStateMemberBound = this._onRoomStateMember.bind(this); + + MatrixClientPeg.get().on("Room.timeline", this._onRoomTimelineBound); + MatrixClientPeg.get().on("RoomState.members", this._onRoomStateMemberBound); + } + + destroy() { + MatrixClientPeg.get().removeListener("Room.timeline", this._onRoomTimelineBound); + MatrixClientPeg.get().removeListener("RoomState.members", this._onRoomStateMemberBound); + } + + _onRoomTimeline(ev, room, toStartOfTimeline, removed, data) { + if (!room) return; + if (removed) return; + if (room.roomId !== this.room.roomId) return; + + // ignore events from filtered timelines + if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; + + // 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; + + this.onUserSpoke(ev.sender); + } + + _onRoomStateMember(ev, state, member) { + // ignore members in other rooms + if (member.roomId !== this.room.roomId) { + return; + } + + // blow away the users cache + this.users = null; } async getCompletions(query: string, selection: {start: number, end: number}, force = false) { @@ -86,11 +122,6 @@ export default class UserProvider extends AutocompleteProvider { return '👥 ' + _t('Users'); } - setUserListFromRoom(room: Room) { - this.room = room; - this.users = null; - } - _makeUsers() { const events = this.room.getLiveTimeline().getEvents(); const lastSpoken = {}; @@ -123,13 +154,6 @@ export default class UserProvider extends AutocompleteProvider { this.matcher.setObjects(this.users); } - static getInstance(): UserProvider { - if (instance == null) { - instance = new UserProvider(); - } - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index b6a450fbb4..cc4783fdac 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -15,13 +15,14 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; +import GeminiScrollbar from 'react-gemini-scrollbar'; import {MatrixClient} from 'matrix-js-sdk'; import sdk from '../../index'; import { _t, _tJsx } from '../../languageHandler'; import withMatrixClient from '../../wrappers/withMatrixClient'; import AccessibleButton from '../views/elements/AccessibleButton'; import dis from '../../dispatcher'; -import PropTypes from 'prop-types'; import Modal from '../../Modal'; import FlairStore from '../../stores/FlairStore'; @@ -115,18 +116,17 @@ export default withMatrixClient(React.createClass({ const TintableSvg = sdk.getComponent("elements.TintableSvg"); let content; + let contentHeader; if (this.state.groups) { const groupNodes = []; this.state.groups.forEach((g) => { groupNodes.push(); }); + contentHeader = groupNodes.length > 0 ?

{ _t('Your Communities') }

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

{ _t('Your Communities') }

-
- { groupNodes } -
-
: + + { groupNodes } + :
{ _t( "You're not currently a member of any communities.", @@ -176,6 +176,7 @@ export default withMatrixClient(React.createClass({
+ { contentHeader } { content }
; diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 9b6dbb4c27..409b95947f 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -44,8 +44,6 @@ const Rooms = require('../../Rooms'); import KeyCode from '../../KeyCode'; -import UserProvider from '../../autocomplete/UserProvider'; - import RoomViewStore from '../../stores/RoomViewStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; @@ -305,6 +303,15 @@ module.exports = React.createClass({ _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 + let hideWidgetDrawer = localStorage.getItem( + room.roomId + "_hide_widget_drawer"); + + if (hideWidgetDrawer === "true") { + return false; + } + const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets'); // any valid widget = show apps for (let i = 0; i < appsStateEvents.length; i++) { @@ -541,12 +548,6 @@ module.exports = React.createClass({ }); } } - - // update the tab complete list as it depends on who most recently spoke, - // and that has probably just changed - if (ev.sender) { - UserProvider.getInstance().onUserSpoke(ev.sender); - } }, onRoomName: function(room) { @@ -568,7 +569,6 @@ module.exports = React.createClass({ this._warnAboutEncryption(room); this._calculatePeekRules(room); this._updatePreviewUrlVisibility(room); - UserProvider.getInstance().setUserListFromRoom(room); }, _warnAboutEncryption: function(room) { @@ -722,9 +722,6 @@ module.exports = React.createClass({ // refresh the conf call notification state this._updateConfCallNotification(); - // refresh the tab complete list - UserProvider.getInstance().setUserListFromRoom(this.state.room); - // 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. diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js index 12f419ddd6..e1dfe388d6 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.js @@ -137,16 +137,19 @@ export default React.createClass({
-
- + - + + + - :{ MatrixClientPeg.get().getDomain() } + + :{ MatrixClientPeg.get().getDomain() } +
diff --git a/src/components/views/elements/Dropdown.js b/src/components/views/elements/Dropdown.js index 3787523a56..b1291710b7 100644 --- a/src/components/views/elements/Dropdown.js +++ b/src/components/views/elements/Dropdown.js @@ -26,11 +26,9 @@ class MenuOption extends React.Component { this._onClick = this._onClick.bind(this); } - getDefaultProps() { - return { - disabled: false, - }; - } + static defaultProps = { + disabled: false, + }; _onMouseEnter() { this.props.onMouseEnter(this.props.dropdownKey); diff --git a/src/components/views/groups/GroupInviteTile.js b/src/components/views/groups/GroupInviteTile.js index d7a04247ec..fcc9acb00b 100644 --- a/src/components/views/groups/GroupInviteTile.js +++ b/src/components/views/groups/GroupInviteTile.js @@ -44,21 +44,21 @@ export default React.createClass({ const label = { groupName } ; - const badge =
!
; + const badge =
!
; return ( - -
+ +
{ av }
-
+
{ label } { badge }
diff --git a/src/components/views/groups/GroupMemberTile.js b/src/components/views/groups/GroupMemberTile.js index f40c7ed1c5..84c2adcb41 100644 --- a/src/components/views/groups/GroupMemberTile.js +++ b/src/components/views/groups/GroupMemberTile.js @@ -61,9 +61,9 @@ export default withMatrixClient(React.createClass({ ); return ( - ); }, diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index faa4d6cf77..911f2c98d1 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -194,6 +194,9 @@ module.exports = React.createClass({ node.parentNode.replaceChild(pillContainer, node); // Pills within pills aren't going to go well, so move on pillified = true; + + // update the current node with one that's now taken its place + node = pillContainer; } } else if (node.nodeType == Node.TEXT_NODE) { const Pill = sdk.getComponent('elements.Pill'); diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 1c9296228d..9a3ba5f329 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -81,16 +81,25 @@ module.exports = React.createClass({ }, onAction: function(action) { + const hideWidgetKey = this.props.room.roomId + "_hide_widget_drawer"; switch (action.action) { case 'appsDrawer': - // When opening the app draw when there aren't any apps, auto-launch the - // integrations manager to skip the awkward click on "Add widget" + // When opening the app drawer when there aren't any apps, + // auto-launch the integrations manager to skip the awkward + // click on "Add widget" if (action.show) { const apps = this._getApps(); if (apps.length === 0) { this._launchManageIntegrations(); } + + localStorage.removeItem(hideWidgetKey); + } else { + // Store hidden state of widget + // Don't show if previously hidden + localStorage.setItem(hideWidgetKey, true); } + break; } }, diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index ecc908a02c..958d16073c 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -1,5 +1,23 @@ +/* +Copyright 2016 Aviral Dasgupta +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import React from 'react'; import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; import classNames from 'classnames'; import flatMap from 'lodash/flatMap'; import isEqual from 'lodash/isEqual'; @@ -7,8 +25,9 @@ import sdk from '../../../index'; import type {Completion} from '../../../autocomplete/Autocompleter'; import Promise from 'bluebird'; import UserSettingsStore from '../../../UserSettingsStore'; +import { Room } from 'matrix-js-sdk'; -import {getCompletions} from '../../../autocomplete/Autocompleter'; +import Autocompleter from '../../../autocomplete/Autocompleter'; const COMPOSER_SELECTED = 0; @@ -17,6 +36,7 @@ export default class Autocomplete extends React.Component { constructor(props) { super(props); + this.autocompleter = new Autocompleter(props.room); this.completionPromise = null; this.hide = this.hide.bind(this); this.onCompletionClicked = this.onCompletionClicked.bind(this); @@ -41,6 +61,11 @@ export default class Autocomplete extends React.Component { } componentWillReceiveProps(newProps, state) { + if (this.props.room.roomId !== newProps.room.roomId) { + this.autocompleter.destroy(); + this.autocompleter = new Autocompleter(newProps.room); + } + // Query hasn't changed so don't try to complete it if (newProps.query === this.props.query) { return; @@ -49,6 +74,10 @@ export default class Autocomplete extends React.Component { this.complete(newProps.query, newProps.selection); } + componentWillUnmount() { + this.autocompleter.destroy(); + } + complete(query, selection) { this.queryRequested = query; if (this.debounceCompletionsRequest) { @@ -83,7 +112,7 @@ export default class Autocomplete extends React.Component { } processQuery(query, selection) { - return getCompletions( + return this.autocompleter.getCompletions( query, selection, this.state.forceComplete, ).then((completions) => { // Only ever process the completions for the most recent query being processed @@ -267,8 +296,11 @@ export default class Autocomplete extends React.Component { Autocomplete.propTypes = { // the query string for which to show autocomplete suggestions - query: React.PropTypes.string.isRequired, + query: PropTypes.string.isRequired, // method invoked with range and text content when completion is confirmed - onConfirm: React.PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + + // The room in which we're autocompleting + room: PropTypes.instanceOf(Room), }; diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.js index 1df19340cd..ffcb289437 100644 --- a/src/components/views/rooms/EntityTile.js +++ b/src/components/views/rooms/EntityTile.js @@ -47,7 +47,7 @@ function presenceClassForMember(presenceState, lastActiveAgo) { } } -module.exports = React.createClass({ +const EntityTile = React.createClass({ displayName: 'EntityTile', propTypes: { @@ -140,16 +140,19 @@ module.exports = React.createClass({ } let power; - const powerLevel = this.props.powerLevel; - if (powerLevel >= 50 && powerLevel < 99) { - power = {_t("Moderator")}; - } - if (powerLevel >= 99) { - power = {_t("Admin")}; + const powerStatus = this.props.powerStatus; + if (powerStatus) { + const src = { + [EntityTile.POWER_STATUS_MODERATOR]: "img/mod.svg", + [EntityTile.POWER_STATUS_ADMIN]: "img/admin.svg", + }[powerStatus]; + const alt = { + [EntityTile.POWER_STATUS_MODERATOR]: _t("Moderator"), + [EntityTile.POWER_STATUS_ADMIN]: _t("Admin"), + }[powerStatus]; + power = {alt}; } - - const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const av = this.props.avatarJsx || ; @@ -168,3 +171,9 @@ module.exports = React.createClass({ ); }, }); + +EntityTile.POWER_STATUS_MODERATOR = "moderator"; +EntityTile.POWER_STATUS_ADMIN = "admin"; + + +export default EntityTile; diff --git a/src/components/views/rooms/MemberTile.js b/src/components/views/rooms/MemberTile.js index e21f7c91f4..4848c4b258 100644 --- a/src/components/views/rooms/MemberTile.js +++ b/src/components/views/rooms/MemberTile.js @@ -86,13 +86,19 @@ module.exports = React.createClass({ } this.member_last_modified_time = member.getLastModifiedTime(); + // We deliberately leave power levels that are not 100 or 50 undefined + const powerStatus = { + 100: EntityTile.POWER_STATUS_ADMIN, + 50: EntityTile.POWER_STATUS_MODERATOR, + }[this.props.member.powerLevel]; + return ( + name={name} powerStatus={powerStatus} /> ); }, }); diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 4850428621..aa019de091 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 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. @@ -57,6 +58,11 @@ const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; const ZWS_CODE = 8203; const ZWS = String.fromCharCode(ZWS_CODE); // zero width space + +const ENTITY_TYPES = { + AT_ROOM_PILL: 'ATROOMPILL', +}; + function stateToMarkdown(state) { return __stateToMarkdown(state) .replace( @@ -187,13 +193,16 @@ export default class MessageComposerInput extends React.Component { this.client = MatrixClientPeg.get(); } - findLinkEntities(contentState: ContentState, contentBlock: ContentBlock, callback) { + findPillEntities(contentState: ContentState, contentBlock: ContentBlock, callback) { contentBlock.findEntityRanges( (character) => { const entityKey = character.getEntity(); return ( entityKey !== null && - contentState.getEntity(entityKey).getType() === 'LINK' + ( + contentState.getEntity(entityKey).getType() === 'LINK' || + contentState.getEntity(entityKey).getType() === ENTITY_TYPES.AT_ROOM_PILL + ) ); }, callback, ); @@ -209,11 +218,19 @@ export default class MessageComposerInput extends React.Component { RichText.getScopedMDDecorators(this.props); const shouldShowPillAvatar = !UserSettingsStore.getSyncedSetting("Pill.shouldHidePillAvatar", false); decorators.push({ - strategy: this.findLinkEntities.bind(this), + strategy: this.findPillEntities.bind(this), component: (entityProps) => { const Pill = sdk.getComponent('elements.Pill'); + const type = entityProps.contentState.getEntity(entityProps.entityKey).getType(); const {url} = entityProps.contentState.getEntity(entityProps.entityKey).getData(); - if (Pill.isPillUrl(url)) { + if (type === ENTITY_TYPES.AT_ROOM_PILL) { + return ; + } else if (Pill.isPillUrl(url)) { return { let blockText = block.getText(); let offset = 0; - this.findLinkEntities(contentState, block, (start, end) => { + this.findPillEntities(contentState, block, (start, end) => { const entity = contentState.getEntity(block.getEntityAt(start)); if (entity.getType() !== 'LINK') { return; @@ -988,6 +1005,11 @@ export default class MessageComposerInput extends React.Component { isCompletion: true, }); entityKey = contentState.getLastCreatedEntityKey(); + } else if (completion === '@room') { + contentState = contentState.createEntity(ENTITY_TYPES.AT_ROOM_PILL, 'IMMUTABLE', { + isCompletion: true, + }); + entityKey = contentState.getLastCreatedEntityKey(); } let selection; @@ -1130,10 +1152,12 @@ export default class MessageComposerInput extends React.Component {
this.autocomplete = e} + room={this.props.room} onConfirm={this.setDisplayedCompletion} onSelectionChange={this.setDisplayedCompletion} query={this.getAutocompleteQuery(content)} - selection={selection} /> + selection={selection} + />
+ + to start a chat with someone": "Press to start a chat with someone", "You're not in any rooms yet! Press to make a room or to browse the directory": "You're not in any rooms yet! Press to make a room or to browse the directory", + "Community Invites": "Community Invites", "Invites": "Invites", "Favourites": "Favourites", "People": "People", @@ -891,6 +892,8 @@ "Commands": "Commands", "Results from DuckDuckGo": "Results from DuckDuckGo", "Emoji": "Emoji", + "Notify the whole room": "Notify the whole room", + "Room Notification": "Room Notification", "Users": "Users", "unknown device": "unknown device", "NOT verified": "NOT verified", diff --git a/src/stores/FlairStore.js b/src/stores/FlairStore.js index 9424503390..1d20148c0d 100644 --- a/src/stores/FlairStore.js +++ b/src/stores/FlairStore.js @@ -48,6 +48,9 @@ class FlairStore extends EventEmitter { // reject: () => {} // } }; + this._usersInFlight = { + // This has the same schema as _usersPending + }; this._debounceTimeoutID = null; } @@ -125,12 +128,16 @@ class FlairStore extends EventEmitter { } catch (err) { // Propagate the same error to all usersInFlight Object.keys(this._usersInFlight).forEach((userId) => { + // The promise should always exist for userId, but do a null-check anyway + if (!this._usersInFlight[userId]) return; this._usersInFlight[userId].reject(err); }); return; } const updatedUserGroups = resp.users; Object.keys(this._usersInFlight).forEach((userId) => { + // The promise should always exist for userId, but do a null-check anyway + if (!this._usersInFlight[userId]) return; this._usersInFlight[userId].resolve(updatedUserGroups[userId] || []); }); } diff --git a/src/stores/GroupStore.js b/src/stores/GroupStore.js index 2578d373a7..11dd664053 100644 --- a/src/stores/GroupStore.js +++ b/src/stores/GroupStore.js @@ -33,6 +33,9 @@ export default class GroupStore extends EventEmitter { constructor(matrixClient, groupId) { super(); + if (!groupId) { + throw new Error('GroupStore needs a valid groupId to be created'); + } this.groupId = groupId; this._matrixClient = matrixClient; this._summary = {};