Merge branch 'develop' of https://github.com/matrix-org/matrix-react-sdk into rxl881/widgetrendering

This commit is contained in:
Richard Lewis 2017-11-07 11:04:05 +00:00
commit 70c4100350
27 changed files with 328 additions and 143 deletions

View file

@ -208,7 +208,7 @@ const sanitizeHtmlParams = {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and // because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s. // 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: {}}; return { tagName, attribs: {}};
} }
attribs.src = MatrixClientPeg.get().mxcUrlToHttp( attribs.src = MatrixClientPeg.get().mxcUrlToHttp(

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 RoomProvider from './RoomProvider';
import UserProvider from './UserProvider'; import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider'; import EmojiProvider from './EmojiProvider';
import NotifProvider from './NotifProvider';
import Promise from 'bluebird'; import Promise from 'bluebird';
export type SelectionRange = { export type SelectionRange = {
@ -43,43 +45,59 @@ const PROVIDERS = [
UserProvider, UserProvider,
RoomProvider, RoomProvider,
EmojiProvider, EmojiProvider,
NotifProvider,
CommandProvider, CommandProvider,
DuckDuckGoProvider, DuckDuckGoProvider,
].map((completer) => completer.getInstance()); ];
// Providers will get rejected if they take longer than this. // Providers will get rejected if they take longer than this.
const PROVIDER_COMPLETION_TIMEOUT = 3000; const PROVIDER_COMPLETION_TIMEOUT = 3000;
export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> { export default class Autocompleter {
/* Note: That this waits for all providers to return is *intentional* constructor(room) {
otherwise, we run into a condition where new completions are displayed this.room = room;
while the user is interacting with the list, which makes it difficult this.providers = PROVIDERS.map((p) => {
to predict whether an action will actually do what is intended return new p(room);
*/ });
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();
}),
);
return completionsList.filter( destroy() {
(inspection) => inspection.isFulfilled(), this.providers.forEach((p) => {
).map((completionsState, i) => { p.destroy();
return { });
completions: completionsState.value(), }
provider: PROVIDERS[i],
/* the currently matched "command" the completer tried to complete async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
* we pass this through so that Autocomplete can figure out when to /* Note: This intentionally waits for all providers to return,
* re-show itself once hidden. otherwise, we run into a condition where new completions are displayed
*/ while the user is interacting with the list, which makes it difficult
command: PROVIDERS[i].getCurrentCommand(query, selection, force), 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),
};
});
}
} }

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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; const COMMAND_RE = /(^\/\w*)/g;
let instance = null;
export default class CommandProvider extends AutocompleteProvider { export default class CommandProvider extends AutocompleteProvider {
constructor() { constructor() {
super(COMMAND_RE); super(COMMAND_RE);
@ -142,12 +141,6 @@ export default class CommandProvider extends AutocompleteProvider {
return '*️⃣ ' + _t('Commands'); return '*️⃣ ' + _t('Commands');
} }
static getInstance(): CommandProvider {
if (instance === null) instance = new CommandProvider();
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block"> return <div className="mx_Autocomplete_Completion_container_block">
{ completions } { completions }

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector'; const REFERRER = 'vector';
let instance = null;
export default class DuckDuckGoProvider extends AutocompleteProvider { export default class DuckDuckGoProvider extends AutocompleteProvider {
constructor() { constructor() {
super(DDG_REGEX); super(DDG_REGEX);
@ -96,13 +95,6 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
return '🔍 ' + _t('Results from DuckDuckGo'); return '🔍 ' + _t('Results from DuckDuckGo');
} }
static getInstance(): DuckDuckGoProvider {
if (instance == null) {
instance = new DuckDuckGoProvider();
}
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block"> return <div className="mx_Autocomplete_Completion_container_block">
{ completions } { completions }

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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) { function score(query, space) {
const index = space.indexOf(query); const index = space.indexOf(query);
if (index === -1) { if (index === -1) {
@ -151,11 +150,6 @@ export default class EmojiProvider extends AutocompleteProvider {
return '😃 ' + _t('Emoji'); return '😃 ' + _t('Emoji');
} }
static getInstance() {
if (instance == null) {instance = new EmojiProvider();}
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill"> return <div className="mx_Autocomplete_Completion_container_pill">
{ completions } { completions }

View file

@ -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: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={this.room} />} title="@room" description={_t("Notify the whole room")} />
),
range,
}];
}
return [];
}
getName() {
return '❗️ ' + _t('Room Notification');
}
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{ completions }
</div>;
}
}

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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; const ROOM_REGEX = /(?=#)(\S*)/g;
let instance = null;
function score(query, space) { function score(query, space) {
const index = space.indexOf(query); const index = space.indexOf(query);
if (index === -1) { if (index === -1) {
@ -96,14 +95,6 @@ export default class RoomProvider extends AutocompleteProvider {
return '💬 ' + _t('Rooms'); return '💬 ' + _t('Rooms');
} }
static getInstance() {
if (instance == null) {
instance = new RoomProvider();
}
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"> return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{ completions } { completions }

View file

@ -2,6 +2,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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; const USER_REGEX = /@\S*/g;
let instance = null;
export default class UserProvider extends AutocompleteProvider { export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = null; users: Array<RoomMember> = null;
room: Room = null; room: Room = null;
constructor() { constructor(room) {
super(USER_REGEX, { super(USER_REGEX, {
keys: ['name'], keys: ['name'],
}); });
this.room = room;
this.matcher = new FuzzyMatcher([], { this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'], keys: ['name', 'userId'],
shouldMatchPrefix: true, 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) { async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
@ -86,11 +122,6 @@ export default class UserProvider extends AutocompleteProvider {
return '👥 ' + _t('Users'); return '👥 ' + _t('Users');
} }
setUserListFromRoom(room: Room) {
this.room = room;
this.users = null;
}
_makeUsers() { _makeUsers() {
const events = this.room.getLiveTimeline().getEvents(); const events = this.room.getLiveTimeline().getEvents();
const lastSpoken = {}; const lastSpoken = {};
@ -123,13 +154,6 @@ export default class UserProvider extends AutocompleteProvider {
this.matcher.setObjects(this.users); this.matcher.setObjects(this.users);
} }
static getInstance(): UserProvider {
if (instance == null) {
instance = new UserProvider();
}
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"> return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{ completions } { completions }

View file

@ -15,13 +15,14 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import GeminiScrollbar from 'react-gemini-scrollbar';
import {MatrixClient} from 'matrix-js-sdk'; import {MatrixClient} from 'matrix-js-sdk';
import sdk from '../../index'; import sdk from '../../index';
import { _t, _tJsx } from '../../languageHandler'; import { _t, _tJsx } from '../../languageHandler';
import withMatrixClient from '../../wrappers/withMatrixClient'; import withMatrixClient from '../../wrappers/withMatrixClient';
import AccessibleButton from '../views/elements/AccessibleButton'; import AccessibleButton from '../views/elements/AccessibleButton';
import dis from '../../dispatcher'; import dis from '../../dispatcher';
import PropTypes from 'prop-types';
import Modal from '../../Modal'; import Modal from '../../Modal';
import FlairStore from '../../stores/FlairStore'; import FlairStore from '../../stores/FlairStore';
@ -115,18 +116,17 @@ export default withMatrixClient(React.createClass({
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
let content; let content;
let contentHeader;
if (this.state.groups) { if (this.state.groups) {
const groupNodes = []; const groupNodes = [];
this.state.groups.forEach((g) => { this.state.groups.forEach((g) => {
groupNodes.push(<GroupTile groupId={g} />); groupNodes.push(<GroupTile groupId={g} />);
}); });
contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
content = groupNodes.length > 0 ? content = groupNodes.length > 0 ?
<div> <GeminiScrollbar className="mx_MyGroups_joinedGroups">
<h3>{ _t('Your Communities') }</h3> { groupNodes }
<div className="mx_MyGroups_joinedGroups"> </GeminiScrollbar> :
{ groupNodes }
</div>
</div> :
<div className="mx_MyGroups_placeholder"> <div className="mx_MyGroups_placeholder">
{ _t( { _t(
"You're not currently a member of any communities.", "You're not currently a member of any communities.",
@ -176,6 +176,7 @@ export default withMatrixClient(React.createClass({
</div> </div>
</div> </div>
<div className="mx_MyGroups_content"> <div className="mx_MyGroups_content">
{ contentHeader }
{ content } { content }
</div> </div>
</div>; </div>;

View file

@ -44,8 +44,6 @@ const Rooms = require('../../Rooms');
import KeyCode from '../../KeyCode'; import KeyCode from '../../KeyCode';
import UserProvider from '../../autocomplete/UserProvider';
import RoomViewStore from '../../stores/RoomViewStore'; import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
@ -305,6 +303,15 @@ module.exports = React.createClass({
_shouldShowApps: function(room) { _shouldShowApps: function(room) {
if (!BROWSER_SUPPORTS_SANDBOX) return false; 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'); const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets');
// any valid widget = show apps // any valid widget = show apps
for (let i = 0; i < appsStateEvents.length; i++) { 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) { onRoomName: function(room) {
@ -568,7 +569,6 @@ module.exports = React.createClass({
this._warnAboutEncryption(room); this._warnAboutEncryption(room);
this._calculatePeekRules(room); this._calculatePeekRules(room);
this._updatePreviewUrlVisibility(room); this._updatePreviewUrlVisibility(room);
UserProvider.getInstance().setUserListFromRoom(room);
}, },
_warnAboutEncryption: function(room) { _warnAboutEncryption: function(room) {
@ -722,9 +722,6 @@ module.exports = React.createClass({
// refresh the conf call notification state // refresh the conf call notification state
this._updateConfCallNotification(); 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 // 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 // means we have finished joining a room we were previously peeking
// into. // into.

View file

@ -137,16 +137,19 @@ export default React.createClass({
<div className="mx_CreateGroupDialog_label"> <div className="mx_CreateGroupDialog_label">
<label htmlFor="groupid">{ _t('Community ID') }</label> <label htmlFor="groupid">{ _t('Community ID') }</label>
</div> </div>
<div> <div className="mx_CreateGroupDialog_input_group">
<span>+</span> <span className="mx_CreateGroupDialog_prefix">+</span>
<input id="groupid" className="mx_CreateGroupDialog_input" <input id="groupid"
className="mx_CreateGroupDialog_input mx_CreateGroupDialog_input_hasPrefixAndSuffix"
size="32" size="32"
placeholder={_t('example')} placeholder={_t('example')}
onChange={this._onGroupIdChange} onChange={this._onGroupIdChange}
onBlur={this._onGroupIdBlur} onBlur={this._onGroupIdBlur}
value={this.state.groupId} value={this.state.groupId}
/> />
<span>:{ MatrixClientPeg.get().getDomain() }</span> <span className="mx_CreateGroupDialog_suffix">
:{ MatrixClientPeg.get().getDomain() }
</span>
</div> </div>
</div> </div>
<div className="error"> <div className="error">

View file

@ -26,11 +26,9 @@ class MenuOption extends React.Component {
this._onClick = this._onClick.bind(this); this._onClick = this._onClick.bind(this);
} }
getDefaultProps() { static defaultProps = {
return { disabled: false,
disabled: false, };
};
}
_onMouseEnter() { _onMouseEnter() {
this.props.onMouseEnter(this.props.dropdownKey); this.props.onMouseEnter(this.props.dropdownKey);

View file

@ -44,21 +44,21 @@ export default React.createClass({
const label = <EmojiText const label = <EmojiText
element="div" element="div"
title={groupName} title={this.props.group.groupId}
className="mx_GroupInviteTile_name" className="mx_RoomTile_name"
dir="auto" dir="auto"
> >
{ groupName } { groupName }
</EmojiText>; </EmojiText>;
const badge = <div className="mx_GroupInviteTile_badge">!</div>; const badge = <div className="mx_RoomSubList_badge mx_RoomSubList_badgeHighlight">!</div>;
return ( return (
<AccessibleButton className="mx_GroupInviteTile" onClick={this.onClick}> <AccessibleButton className="mx_RoomTile mx_RoomTile_highlight" onClick={this.onClick}>
<div className="mx_GroupInviteTile_avatarContainer"> <div className="mx_RoomTile_avatar">
{ av } { av }
</div> </div>
<div className="mx_GroupInviteTile_nameContainer"> <div className="mx_RoomTile_nameContainer">
{ label } { label }
{ badge } { badge }
</div> </div>

View file

@ -61,9 +61,9 @@ export default withMatrixClient(React.createClass({
); );
return ( return (
<EntityTile presenceState="online" <EntityTile name={name} avatarJsx={av} onClick={this.onClick}
avatarJsx={av} onClick={this.onClick} suppressOnHover={true} presenceState="online"
name={name} powerLevel={0} suppressOnHover={true} powerStatus={this.props.member.isAdmin ? EntityTile.POWER_STATUS_ADMIN : null}
/> />
); );
}, },

View file

@ -194,6 +194,9 @@ module.exports = React.createClass({
node.parentNode.replaceChild(pillContainer, node); node.parentNode.replaceChild(pillContainer, node);
// Pills within pills aren't going to go well, so move on // Pills within pills aren't going to go well, so move on
pillified = true; pillified = true;
// update the current node with one that's now taken its place
node = pillContainer;
} }
} else if (node.nodeType == Node.TEXT_NODE) { } else if (node.nodeType == Node.TEXT_NODE) {
const Pill = sdk.getComponent('elements.Pill'); const Pill = sdk.getComponent('elements.Pill');

View file

@ -81,16 +81,25 @@ module.exports = React.createClass({
}, },
onAction: function(action) { onAction: function(action) {
const hideWidgetKey = this.props.room.roomId + "_hide_widget_drawer";
switch (action.action) { switch (action.action) {
case 'appsDrawer': case 'appsDrawer':
// When opening the app draw when there aren't any apps, auto-launch the // When opening the app drawer when there aren't any apps,
// integrations manager to skip the awkward click on "Add widget" // auto-launch the integrations manager to skip the awkward
// click on "Add widget"
if (action.show) { if (action.show) {
const apps = this._getApps(); const apps = this._getApps();
if (apps.length === 0) { if (apps.length === 0) {
this._launchManageIntegrations(); this._launchManageIntegrations();
} }
localStorage.removeItem(hideWidgetKey);
} else {
// Store hidden state of widget
// Don't show if previously hidden
localStorage.setItem(hideWidgetKey, true);
} }
break; break;
} }
}, },

View file

@ -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 React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import flatMap from 'lodash/flatMap'; import flatMap from 'lodash/flatMap';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
@ -7,8 +25,9 @@ import sdk from '../../../index';
import type {Completion} from '../../../autocomplete/Autocompleter'; import type {Completion} from '../../../autocomplete/Autocompleter';
import Promise from 'bluebird'; import Promise from 'bluebird';
import UserSettingsStore from '../../../UserSettingsStore'; import UserSettingsStore from '../../../UserSettingsStore';
import { Room } from 'matrix-js-sdk';
import {getCompletions} from '../../../autocomplete/Autocompleter'; import Autocompleter from '../../../autocomplete/Autocompleter';
const COMPOSER_SELECTED = 0; const COMPOSER_SELECTED = 0;
@ -17,6 +36,7 @@ export default class Autocomplete extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.autocompleter = new Autocompleter(props.room);
this.completionPromise = null; this.completionPromise = null;
this.hide = this.hide.bind(this); this.hide = this.hide.bind(this);
this.onCompletionClicked = this.onCompletionClicked.bind(this); this.onCompletionClicked = this.onCompletionClicked.bind(this);
@ -41,6 +61,11 @@ export default class Autocomplete extends React.Component {
} }
componentWillReceiveProps(newProps, state) { 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 // Query hasn't changed so don't try to complete it
if (newProps.query === this.props.query) { if (newProps.query === this.props.query) {
return; return;
@ -49,6 +74,10 @@ export default class Autocomplete extends React.Component {
this.complete(newProps.query, newProps.selection); this.complete(newProps.query, newProps.selection);
} }
componentWillUnmount() {
this.autocompleter.destroy();
}
complete(query, selection) { complete(query, selection) {
this.queryRequested = query; this.queryRequested = query;
if (this.debounceCompletionsRequest) { if (this.debounceCompletionsRequest) {
@ -83,7 +112,7 @@ export default class Autocomplete extends React.Component {
} }
processQuery(query, selection) { processQuery(query, selection) {
return getCompletions( return this.autocompleter.getCompletions(
query, selection, this.state.forceComplete, query, selection, this.state.forceComplete,
).then((completions) => { ).then((completions) => {
// Only ever process the completions for the most recent query being processed // 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 = { Autocomplete.propTypes = {
// the query string for which to show autocomplete suggestions // 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 // 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),
}; };

View file

@ -47,7 +47,7 @@ function presenceClassForMember(presenceState, lastActiveAgo) {
} }
} }
module.exports = React.createClass({ const EntityTile = React.createClass({
displayName: 'EntityTile', displayName: 'EntityTile',
propTypes: { propTypes: {
@ -140,16 +140,19 @@ module.exports = React.createClass({
} }
let power; let power;
const powerLevel = this.props.powerLevel; const powerStatus = this.props.powerStatus;
if (powerLevel >= 50 && powerLevel < 99) { if (powerStatus) {
power = <img src="img/mod.svg" className="mx_EntityTile_power" width="16" height="17" alt={_t("Moderator")} />; const src = {
} [EntityTile.POWER_STATUS_MODERATOR]: "img/mod.svg",
if (powerLevel >= 99) { [EntityTile.POWER_STATUS_ADMIN]: "img/admin.svg",
power = <img src="img/admin.svg" className="mx_EntityTile_power" width="16" height="17" alt={_t("Admin")} />; }[powerStatus];
const alt = {
[EntityTile.POWER_STATUS_MODERATOR]: _t("Moderator"),
[EntityTile.POWER_STATUS_ADMIN]: _t("Admin"),
}[powerStatus];
power = <img src={src} className="mx_EntityTile_power" width="16" height="17" alt={alt} />;
} }
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const av = this.props.avatarJsx || <BaseAvatar name={this.props.name} width={36} height={36} />; const av = this.props.avatarJsx || <BaseAvatar name={this.props.name} width={36} height={36} />;
@ -168,3 +171,9 @@ module.exports = React.createClass({
); );
}, },
}); });
EntityTile.POWER_STATUS_MODERATOR = "moderator";
EntityTile.POWER_STATUS_ADMIN = "admin";
export default EntityTile;

View file

@ -86,13 +86,19 @@ module.exports = React.createClass({
} }
this.member_last_modified_time = member.getLastModifiedTime(); 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 ( return (
<EntityTile {...this.props} presenceState={presenceState} <EntityTile {...this.props} presenceState={presenceState}
presenceLastActiveAgo={member.user ? member.user.lastActiveAgo : 0} presenceLastActiveAgo={member.user ? member.user.lastActiveAgo : 0}
presenceLastTs={member.user ? member.user.lastPresenceTs : 0} presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
presenceCurrentlyActive={member.user ? member.user.currentlyActive : false} presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick} avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick}
name={name} powerLevel={this.props.member.powerLevel} /> name={name} powerStatus={powerStatus} />
); );
}, },
}); });

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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_CODE = 8203;
const ZWS = String.fromCharCode(ZWS_CODE); // zero width space const ZWS = String.fromCharCode(ZWS_CODE); // zero width space
const ENTITY_TYPES = {
AT_ROOM_PILL: 'ATROOMPILL',
};
function stateToMarkdown(state) { function stateToMarkdown(state) {
return __stateToMarkdown(state) return __stateToMarkdown(state)
.replace( .replace(
@ -187,13 +193,16 @@ export default class MessageComposerInput extends React.Component {
this.client = MatrixClientPeg.get(); this.client = MatrixClientPeg.get();
} }
findLinkEntities(contentState: ContentState, contentBlock: ContentBlock, callback) { findPillEntities(contentState: ContentState, contentBlock: ContentBlock, callback) {
contentBlock.findEntityRanges( contentBlock.findEntityRanges(
(character) => { (character) => {
const entityKey = character.getEntity(); const entityKey = character.getEntity();
return ( return (
entityKey !== null && entityKey !== null &&
contentState.getEntity(entityKey).getType() === 'LINK' (
contentState.getEntity(entityKey).getType() === 'LINK' ||
contentState.getEntity(entityKey).getType() === ENTITY_TYPES.AT_ROOM_PILL
)
); );
}, callback, }, callback,
); );
@ -209,11 +218,19 @@ export default class MessageComposerInput extends React.Component {
RichText.getScopedMDDecorators(this.props); RichText.getScopedMDDecorators(this.props);
const shouldShowPillAvatar = !UserSettingsStore.getSyncedSetting("Pill.shouldHidePillAvatar", false); const shouldShowPillAvatar = !UserSettingsStore.getSyncedSetting("Pill.shouldHidePillAvatar", false);
decorators.push({ decorators.push({
strategy: this.findLinkEntities.bind(this), strategy: this.findPillEntities.bind(this),
component: (entityProps) => { component: (entityProps) => {
const Pill = sdk.getComponent('elements.Pill'); const Pill = sdk.getComponent('elements.Pill');
const type = entityProps.contentState.getEntity(entityProps.entityKey).getType();
const {url} = entityProps.contentState.getEntity(entityProps.entityKey).getData(); const {url} = entityProps.contentState.getEntity(entityProps.entityKey).getData();
if (Pill.isPillUrl(url)) { if (type === ENTITY_TYPES.AT_ROOM_PILL) {
return <Pill
type={Pill.TYPE_AT_ROOM_MENTION}
room={this.props.room}
offsetKey={entityProps.offsetKey}
shouldShowPillAvatar={shouldShowPillAvatar}
/>;
} else if (Pill.isPillUrl(url)) {
return <Pill return <Pill
url={url} url={url}
room={this.props.room} room={this.props.room}
@ -783,7 +800,7 @@ export default class MessageComposerInput extends React.Component {
const pt = contentState.getBlocksAsArray().map((block) => { const pt = contentState.getBlocksAsArray().map((block) => {
let blockText = block.getText(); let blockText = block.getText();
let offset = 0; let offset = 0;
this.findLinkEntities(contentState, block, (start, end) => { this.findPillEntities(contentState, block, (start, end) => {
const entity = contentState.getEntity(block.getEntityAt(start)); const entity = contentState.getEntity(block.getEntityAt(start));
if (entity.getType() !== 'LINK') { if (entity.getType() !== 'LINK') {
return; return;
@ -988,6 +1005,11 @@ export default class MessageComposerInput extends React.Component {
isCompletion: true, isCompletion: true,
}); });
entityKey = contentState.getLastCreatedEntityKey(); entityKey = contentState.getLastCreatedEntityKey();
} else if (completion === '@room') {
contentState = contentState.createEntity(ENTITY_TYPES.AT_ROOM_PILL, 'IMMUTABLE', {
isCompletion: true,
});
entityKey = contentState.getLastCreatedEntityKey();
} }
let selection; let selection;
@ -1130,10 +1152,12 @@ export default class MessageComposerInput extends React.Component {
<div className="mx_MessageComposer_autocomplete_wrapper"> <div className="mx_MessageComposer_autocomplete_wrapper">
<Autocomplete <Autocomplete
ref={(e) => this.autocomplete = e} ref={(e) => this.autocomplete = e}
room={this.props.room}
onConfirm={this.setDisplayedCompletion} onConfirm={this.setDisplayedCompletion}
onSelectionChange={this.setDisplayedCompletion} onSelectionChange={this.setDisplayedCompletion}
query={this.getAutocompleteQuery(content)} query={this.getAutocompleteQuery(content)}
selection={selection} /> selection={selection}
/>
</div> </div>
<div className={className}> <div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor" <img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor"

View file

@ -49,6 +49,7 @@ const RoomDetailRow = React.createClass({
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
room_alias: this.props.room.canonicalAlias || (this.props.room.aliases || [])[0],
}); });
}, },

View file

@ -555,13 +555,23 @@ module.exports = React.createClass({
render: function() { render: function() {
const RoomSubList = sdk.getComponent('structures.RoomSubList'); const RoomSubList = sdk.getComponent('structures.RoomSubList');
const inviteSectionExtraTiles = this._makeGroupInviteTiles();
const self = this; const self = this;
return ( return (
<GeminiScrollbar className="mx_RoomList_scrollbar" <GeminiScrollbar className="mx_RoomList_scrollbar"
autoshow={true} onScroll={self._whenScrolling} ref="gemscroll"> autoshow={true} onScroll={self._whenScrolling} ref="gemscroll">
<div className="mx_RoomList"> <div className="mx_RoomList">
<RoomSubList list={[]}
extraTiles={this._makeGroupInviteTiles()}
label={_t('Community Invites')}
editable={false}
order="recent"
isInvite={true}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
/>
<RoomSubList list={self.state.lists['im.vector.fake.invite']} <RoomSubList list={self.state.lists['im.vector.fake.invite']}
label={_t('Invites')} label={_t('Invites')}
editable={false} editable={false}
@ -573,7 +583,6 @@ module.exports = React.createClass({
searchFilter={self.props.searchFilter} searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick} onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms} onShowMoreRooms={self.onShowMoreRooms}
extraTiles={inviteSectionExtraTiles}
/> />
<RoomSubList list={self.state.lists['m.favourite']} <RoomSubList list={self.state.lists['m.favourite']}

View file

@ -36,6 +36,7 @@ export function groupMemberFromApiObject(apiObject) {
userId: apiObject.user_id, userId: apiObject.user_id,
displayname: apiObject.displayname, displayname: apiObject.displayname,
avatarUrl: apiObject.avatar_url, avatarUrl: apiObject.avatar_url,
isAdmin: apiObject.is_admin,
}; };
} }

View file

@ -326,6 +326,7 @@
"Drop here to tag %(section)s": "Drop here to tag %(section)s", "Drop here to tag %(section)s": "Drop here to tag %(section)s",
"Press <StartChatButton> to start a chat with someone": "Press <StartChatButton> to start a chat with someone", "Press <StartChatButton> to start a chat with someone": "Press <StartChatButton> to start a chat with someone",
"You're not in any rooms yet! Press <CreateRoomButton> to make a room or <RoomDirectoryButton> to browse the directory": "You're not in any rooms yet! Press <CreateRoomButton> to make a room or <RoomDirectoryButton> to browse the directory", "You're not in any rooms yet! Press <CreateRoomButton> to make a room or <RoomDirectoryButton> to browse the directory": "You're not in any rooms yet! Press <CreateRoomButton> to make a room or <RoomDirectoryButton> to browse the directory",
"Community Invites": "Community Invites",
"Invites": "Invites", "Invites": "Invites",
"Favourites": "Favourites", "Favourites": "Favourites",
"People": "People", "People": "People",
@ -891,6 +892,8 @@
"Commands": "Commands", "Commands": "Commands",
"Results from DuckDuckGo": "Results from DuckDuckGo", "Results from DuckDuckGo": "Results from DuckDuckGo",
"Emoji": "Emoji", "Emoji": "Emoji",
"Notify the whole room": "Notify the whole room",
"Room Notification": "Room Notification",
"Users": "Users", "Users": "Users",
"unknown device": "unknown device", "unknown device": "unknown device",
"NOT verified": "NOT verified", "NOT verified": "NOT verified",

View file

@ -48,6 +48,9 @@ class FlairStore extends EventEmitter {
// reject: () => {} // reject: () => {}
// } // }
}; };
this._usersInFlight = {
// This has the same schema as _usersPending
};
this._debounceTimeoutID = null; this._debounceTimeoutID = null;
} }
@ -125,12 +128,16 @@ class FlairStore extends EventEmitter {
} catch (err) { } catch (err) {
// Propagate the same error to all usersInFlight // Propagate the same error to all usersInFlight
Object.keys(this._usersInFlight).forEach((userId) => { 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); this._usersInFlight[userId].reject(err);
}); });
return; return;
} }
const updatedUserGroups = resp.users; const updatedUserGroups = resp.users;
Object.keys(this._usersInFlight).forEach((userId) => { 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] || []); this._usersInFlight[userId].resolve(updatedUserGroups[userId] || []);
}); });
} }

View file

@ -33,6 +33,9 @@ export default class GroupStore extends EventEmitter {
constructor(matrixClient, groupId) { constructor(matrixClient, groupId) {
super(); super();
if (!groupId) {
throw new Error('GroupStore needs a valid groupId to be created');
}
this.groupId = groupId; this.groupId = groupId;
this._matrixClient = matrixClient; this._matrixClient = matrixClient;
this._summary = {}; this._summary = {};