Merge remote-tracking branch 'origin/develop' into matthew/status

This commit is contained in:
Matthew Hodgson 2017-11-08 04:29:52 -08:00
commit e729bc431d
27 changed files with 519 additions and 75 deletions

View file

@ -49,20 +49,26 @@ export function showGroupInviteDialog(groupId) {
export function showGroupAddRoomDialog(groupId) { export function showGroupAddRoomDialog(groupId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let addRoomsPublicly = false;
const onCheckboxClicked = (e) => {
addRoomsPublicly = e.target.checked;
};
const description = <div> const description = <div>
<div>{ _t("Which rooms would you like to add to this community?") }</div> <div>{ _t("Which rooms would you like to add to this community?") }</div>
<div className="warning">
{ _t(
"Warning: any room you add to a community will be publicly "+
"visible to anyone who knows the community ID",
) }
</div>
</div>; </div>;
const checkboxContainer = <label className="mx_GroupAddressPicker_checkboxContainer">
<input type="checkbox" onClick={onCheckboxClicked} />
<div>
{ _t("Show these rooms to non-members on the community page and room list?") }
</div>
</label>;
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Add Rooms to Group', '', AddressPickerDialog, { Modal.createTrackedDialog('Add Rooms to Group', '', AddressPickerDialog, {
title: _t("Add rooms to the community"), title: _t("Add rooms to the community"),
description: description, description: description,
extraNode: checkboxContainer,
placeholder: _t("Room name or alias"), placeholder: _t("Room name or alias"),
button: _t("Add to community"), button: _t("Add to community"),
pickerType: 'room', pickerType: 'room',
@ -70,7 +76,7 @@ export function showGroupAddRoomDialog(groupId) {
onFinished: (success, addrs) => { onFinished: (success, addrs) => {
if (!success) return; if (!success) return;
_onGroupAddRoomFinished(groupId, addrs).then(resolve, reject); _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly).then(resolve, reject);
}, },
}); });
}); });
@ -106,13 +112,13 @@ function _onGroupInviteFinished(groupId, addrs) {
}); });
} }
function _onGroupAddRoomFinished(groupId, addrs) { function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
const matrixClient = MatrixClientPeg.get(); const matrixClient = MatrixClientPeg.get();
const groupStore = GroupStoreCache.getGroupStore(matrixClient, groupId); const groupStore = GroupStoreCache.getGroupStore(matrixClient, groupId);
const errorList = []; const errorList = [];
return Promise.all(addrs.map((addr) => { return Promise.all(addrs.map((addr) => {
return groupStore return groupStore
.addRoomToGroup(addr.address) .addRoomToGroup(addr.address, addRoomsPublicly)
.catch(() => { errorList.push(addr.address); }) .catch(() => { errorList.push(addr.address); })
.then(() => { .then(() => {
const roomId = addr.address; const roomId = addr.address;

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

@ -25,7 +25,6 @@ const onAction = function(payload) {
const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog'); const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog');
isDialogOpen = true; isDialogOpen = true;
Modal.createTrackedDialog('Unknown Device Error', '', UnknownDeviceDialog, { Modal.createTrackedDialog('Unknown Device Error', '', UnknownDeviceDialog, {
devices: payload.err.devices,
room: payload.room, room: payload.room,
onFinished: (r) => { onFinished: (r) => {
isDialogOpen = false; isDialogOpen = false;

View file

@ -23,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 = {
@ -44,6 +45,7 @@ const PROVIDERS = [
UserProvider, UserProvider,
RoomProvider, RoomProvider,
EmojiProvider, EmojiProvider,
NotifProvider,
CommandProvider, CommandProvider,
DuckDuckGoProvider, DuckDuckGoProvider,
]; ];

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

@ -22,7 +22,7 @@ import MatrixClientPeg from '../../MatrixClientPeg';
import sdk from '../../index'; import sdk from '../../index';
import dis from '../../dispatcher'; import dis from '../../dispatcher';
import { sanitizedHtmlNode } from '../../HtmlUtils'; import { sanitizedHtmlNode } from '../../HtmlUtils';
import { _t } from '../../languageHandler'; import { _t, _td, _tJsx } from '../../languageHandler';
import AccessibleButton from '../views/elements/AccessibleButton'; import AccessibleButton from '../views/elements/AccessibleButton';
import Modal from '../../Modal'; import Modal from '../../Modal';
import classnames from 'classnames'; import classnames from 'classnames';
@ -32,6 +32,17 @@ import GroupStore from '../../stores/GroupStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import GeminiScrollbar from 'react-gemini-scrollbar'; import GeminiScrollbar from 'react-gemini-scrollbar';
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
<p>
Use the long description to introduce new members to the community, or distribute
some important <a href="foo">links</a>
</p>
<p>
You can even use 'img' tags
</p>
`);
const RoomSummaryType = PropTypes.shape({ const RoomSummaryType = PropTypes.shape({
room_id: PropTypes.string.isRequired, room_id: PropTypes.string.isRequired,
profile: PropTypes.shape({ profile: PropTypes.shape({
@ -392,6 +403,8 @@ export default React.createClass({
propTypes: { propTypes: {
groupId: PropTypes.string.isRequired, groupId: PropTypes.string.isRequired,
// Whether this is the first time the group admin is viewing the group
groupIsNew: PropTypes.bool,
}, },
childContextTypes: { childContextTypes: {
@ -417,12 +430,13 @@ export default React.createClass({
uploadingAvatar: false, uploadingAvatar: false,
membershipBusy: false, membershipBusy: false,
publicityBusy: false, publicityBusy: false,
inviterProfile: null,
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
this._changeAvatarComponent = null; this._changeAvatarComponent = null;
this._initGroupStore(this.props.groupId); this._initGroupStore(this.props.groupId, true);
MatrixClientPeg.get().on("Group.myMembership", this._onGroupMyMembership); MatrixClientPeg.get().on("Group.myMembership", this._onGroupMyMembership);
}, },
@ -449,7 +463,11 @@ export default React.createClass({
this.setState({membershipBusy: false}); this.setState({membershipBusy: false});
}, },
_initGroupStore: function(groupId) { _initGroupStore: function(groupId, firstInit) {
const group = MatrixClientPeg.get().getGroup(groupId);
if (group && group.inviter && group.inviter.userId) {
this._fetchInviterProfile(group.inviter.userId);
}
this._groupStore = GroupStoreCache.getGroupStore(MatrixClientPeg.get(), groupId); this._groupStore = GroupStoreCache.getGroupStore(MatrixClientPeg.get(), groupId);
this._groupStore.registerListener(() => { this._groupStore.registerListener(() => {
const summary = this._groupStore.getSummary(); const summary = this._groupStore.getSummary();
@ -472,6 +490,9 @@ export default React.createClass({
), ),
error: null, error: null,
}); });
if (this.props.groupIsNew && firstInit) {
this._onEditClick();
}
}); });
this._groupStore.on('error', (err) => { this._groupStore.on('error', (err) => {
this.setState({ this.setState({
@ -481,6 +502,26 @@ export default React.createClass({
}); });
}, },
_fetchInviterProfile(userId) {
this.setState({
inviterProfileBusy: true,
});
MatrixClientPeg.get().getProfileInfo(userId).then((resp) => {
this.setState({
inviterProfile: {
avatarUrl: resp.avatar_url,
displayName: resp.displayname,
},
});
}).catch((e) => {
console.error('Error getting group inviter profile', e);
}).finally(() => {
this.setState({
inviterProfileBusy: false,
});
});
},
_onShowRhsClick: function(ev) { _onShowRhsClick: function(ev) {
dis.dispatch({ action: 'show_right_panel' }); dis.dispatch({ action: 'show_right_panel' });
}, },
@ -575,7 +616,7 @@ export default React.createClass({
_onAcceptInviteClick: function() { _onAcceptInviteClick: function() {
this.setState({membershipBusy: true}); this.setState({membershipBusy: true});
MatrixClientPeg.get().acceptGroupInvite(this.props.groupId).then(() => { this._groupStore.acceptGroupInvite().then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync // don't reset membershipBusy here: wait for the membership change to come down the sync
}).catch((e) => { }).catch((e) => {
this.setState({membershipBusy: false}); this.setState({membershipBusy: false});
@ -661,6 +702,14 @@ export default React.createClass({
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg'); const TintableSvg = sdk.getComponent('elements.TintableSvg');
const Spinner = sdk.getComponent('elements.Spinner'); const Spinner = sdk.getComponent('elements.Spinner');
const ToolTipButton = sdk.getComponent('elements.ToolTipButton');
const roomsHelpNode = this.state.editing ? <ToolTipButton helpText={
_t(
'These rooms are displayed to community members on the community page. '+
'Community members can join the rooms by clicking on them.',
)
} /> : <div />;
const addRoomRow = this.state.editing ? const addRoomRow = this.state.editing ?
(<AccessibleButton className="mx_GroupView_rooms_header_addRow" (<AccessibleButton className="mx_GroupView_rooms_header_addRow"
@ -673,14 +722,23 @@ export default React.createClass({
{ _t('Add rooms to this community') } { _t('Add rooms to this community') }
</div> </div>
</AccessibleButton>) : <div />; </AccessibleButton>) : <div />;
const roomDetailListClassName = classnames({
"mx_fadable": true,
"mx_fadable_faded": this.state.editing,
});
return <div className="mx_GroupView_rooms"> return <div className="mx_GroupView_rooms">
<div className="mx_GroupView_rooms_header"> <div className="mx_GroupView_rooms_header">
<h3>{ _t('Rooms') }</h3> <h3>
{ _t('Rooms') }
{ roomsHelpNode }
</h3>
{ addRoomRow } { addRoomRow }
</div> </div>
{ this.state.groupRoomsLoading ? { this.state.groupRoomsLoading ?
<Spinner /> : <Spinner /> :
<RoomDetailList rooms={this.state.groupRooms} /> <RoomDetailList
rooms={this.state.groupRooms}
className={roomDetailListClassName} />
} }
</div>; </div>;
}, },
@ -769,20 +827,37 @@ export default React.createClass({
_getMembershipSection: function() { _getMembershipSection: function() {
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const group = MatrixClientPeg.get().getGroup(this.props.groupId); const group = MatrixClientPeg.get().getGroup(this.props.groupId);
if (!group) return null; if (!group) return null;
if (group.myMembership === 'invite') { if (group.myMembership === 'invite') {
if (this.state.membershipBusy) { if (this.state.membershipBusy || this.state.inviterProfileBusy) {
return <div className="mx_GroupView_membershipSection"> return <div className="mx_GroupView_membershipSection">
<Spinner /> <Spinner />
</div>; </div>;
} }
const httpInviterAvatar = this.state.inviterProfile ?
MatrixClientPeg.get().mxcUrlToHttp(
this.state.inviterProfile.avatarUrl, 36, 36,
) : null;
let inviterName = group.inviter.userId;
if (this.state.inviterProfile) {
inviterName = this.state.inviterProfile.displayName || group.inviter.userId;
}
return <div className="mx_GroupView_membershipSection mx_GroupView_membershipSection_invited"> return <div className="mx_GroupView_membershipSection mx_GroupView_membershipSection_invited">
<div className="mx_GroupView_membershipSubSection"> <div className="mx_GroupView_membershipSubSection">
<div className="mx_GroupView_membershipSection_description"> <div className="mx_GroupView_membershipSection_description">
{ _t("%(inviter)s has invited you to join this community", {inviter: group.inviter.userId}) } <BaseAvatar url={httpInviterAvatar}
name={inviterName}
width={36}
height={36}
/>
{ _t("%(inviter)s has invited you to join this community", {
inviter: inviterName,
}) }
</div> </div>
<div className="mx_GroupView_membership_buttonContainer"> <div className="mx_GroupView_membership_buttonContainer">
<AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton" <AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton"
@ -851,6 +926,18 @@ export default React.createClass({
let description = null; let description = null;
if (summary.profile && summary.profile.long_description) { if (summary.profile && summary.profile.long_description) {
description = sanitizedHtmlNode(summary.profile.long_description); description = sanitizedHtmlNode(summary.profile.long_description);
} else if (this.state.isUserPrivileged) {
description = <div
className="mx_GroupView_groupDesc_placeholder"
onClick={this._onEditClick}
>
{ _tJsx(
'Your community hasn\'t got a Long Description, a HTML page to show to community members.<br />' +
'Click here to open settings and give it one!',
[/<br \/>/],
[(sub) => <br />])
}
</div>;
} }
const groupDescEditingClasses = classnames({ const groupDescEditingClasses = classnames({
"mx_GroupView_groupDesc": true, "mx_GroupView_groupDesc": true,
@ -862,6 +949,7 @@ export default React.createClass({
<h3> { _t("Long Description (HTML)") } </h3> <h3> { _t("Long Description (HTML)") } </h3>
<textarea <textarea
value={this.state.profileForm.long_description} value={this.state.profileForm.long_description}
placeholder={_t(LONG_DESC_PLACEHOLDER)}
onChange={this._onLongDescChange} onChange={this._onLongDescChange}
tabIndex="4" tabIndex="4"
key="editLongDesc" key="editLongDesc"

View file

@ -301,6 +301,7 @@ export default React.createClass({
case PageTypes.GroupView: case PageTypes.GroupView:
page_element = <GroupView page_element = <GroupView
groupId={this.props.currentGroupId} groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew}
collapsedRhs={this.props.collapseRhs} collapsedRhs={this.props.collapseRhs}
/>; />;
if (!this.props.collapseRhs) right_panel = <RightPanel groupId={this.props.currentGroupId} disabled={this.props.rightDisabled} />; if (!this.props.collapseRhs) right_panel = <RightPanel groupId={this.props.currentGroupId} disabled={this.props.rightDisabled} />;

View file

@ -493,7 +493,10 @@ module.exports = React.createClass({
case 'view_group': case 'view_group':
{ {
const groupId = payload.group_id; const groupId = payload.group_id;
this.setState({currentGroupId: groupId}); this.setState({
currentGroupId: groupId,
currentGroupIsNew: payload.group_is_new,
});
this._setPage(PageTypes.GroupView); this._setPage(PageTypes.GroupView);
this.notifyNewScreen('group/' + groupId); this.notifyNewScreen('group/' + groupId);
} }

View file

@ -303,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++) {

View file

@ -34,6 +34,8 @@ module.exports = React.createClass({
propTypes: { propTypes: {
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
description: PropTypes.node, description: PropTypes.node,
// Extra node inserted after picker input, dropdown and errors
extraNode: PropTypes.node,
value: PropTypes.string, value: PropTypes.string,
placeholder: PropTypes.string, placeholder: PropTypes.string,
roomId: PropTypes.string, roomId: PropTypes.string,
@ -268,34 +270,53 @@ module.exports = React.createClass({
const rooms = MatrixClientPeg.get().getRooms(); const rooms = MatrixClientPeg.get().getRooms();
const results = []; const results = [];
rooms.forEach((room) => { rooms.forEach((room) => {
let rank = Infinity;
const nameEvent = room.currentState.getStateEvents('m.room.name', ''); const nameEvent = room.currentState.getStateEvents('m.room.name', '');
const topicEvent = room.currentState.getStateEvents('m.room.topic', '');
const name = nameEvent ? nameEvent.getContent().name : ''; const name = nameEvent ? nameEvent.getContent().name : '';
const canonicalAlias = room.getCanonicalAlias(); const canonicalAlias = room.getCanonicalAlias();
const aliasEvents = room.currentState.getStateEvents('m.room.aliases'); const aliasEvents = room.currentState.getStateEvents('m.room.aliases');
const aliases = aliasEvents.map((ev) => ev.getContent().aliases).reduce((a, b) => { const aliases = aliasEvents.map((ev) => ev.getContent().aliases).reduce((a, b) => {
return a.concat(b); return a.concat(b);
}, []); }, []);
const topic = topicEvent ? topicEvent.getContent().topic : '';
const nameMatch = (name || '').toLowerCase().includes(lowerCaseQuery); const nameMatch = (name || '').toLowerCase().includes(lowerCaseQuery);
const aliasMatch = aliases.some((alias) => let aliasMatch = false;
(alias || '').toLowerCase().includes(lowerCaseQuery), let shortestMatchingAliasLength = Infinity;
); aliases.forEach((alias) => {
const topicMatch = (topic || '').toLowerCase().includes(lowerCaseQuery); if ((alias || '').toLowerCase().includes(lowerCaseQuery)) {
if (!(nameMatch || topicMatch || aliasMatch)) { aliasMatch = true;
if (shortestMatchingAliasLength > alias.length) {
shortestMatchingAliasLength = alias.length;
}
}
});
if (!(nameMatch || aliasMatch)) {
return; return;
} }
if (aliasMatch) {
// A shorter matching alias will give a better rank
rank = shortestMatchingAliasLength;
}
const avatarEvent = room.currentState.getStateEvents('m.room.avatar', ''); const avatarEvent = room.currentState.getStateEvents('m.room.avatar', '');
const avatarUrl = avatarEvent ? avatarEvent.getContent().url : undefined; const avatarUrl = avatarEvent ? avatarEvent.getContent().url : undefined;
results.push({ results.push({
rank,
room_id: room.roomId, room_id: room.roomId,
avatar_url: avatarUrl, avatar_url: avatarUrl,
name: name || canonicalAlias || aliases[0] || _t('Unnamed Room'), name: name || canonicalAlias || aliases[0] || _t('Unnamed Room'),
}); });
}); });
this._processResults(results, query);
// Sort by rank ascending (a high rank being less relevant)
const sortedResults = results.sort((a, b) => {
return a.rank - b.rank;
});
this._processResults(sortedResults, query);
this.setState({ this.setState({
busy: false, busy: false,
}); });
@ -574,6 +595,7 @@ module.exports = React.createClass({
<div className="mx_ChatInviteDialog_inputContainer">{ query }</div> <div className="mx_ChatInviteDialog_inputContainer">{ query }</div>
{ error } { error }
{ addressSelector } { addressSelector }
{ this.props.extraNode }
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" onClick={this.onButtonClick}> <button className="mx_Dialog_primary" onClick={this.onButtonClick}>

View file

@ -81,6 +81,7 @@ export default React.createClass({
dis.dispatch({ dis.dispatch({
action: 'view_group', action: 'view_group',
group_id: result.group_id, group_id: result.group_id,
group_is_new: true,
}); });
this.props.onFinished(true); this.props.onFinished(true);
}).catch((e) => { }).catch((e) => {

View file

@ -49,7 +49,8 @@ function UserUnknownDeviceList(props) {
const deviceListEntries = Object.keys(userDevices).map((deviceId) => const deviceListEntries = Object.keys(userDevices).map((deviceId) =>
<DeviceListEntry key={deviceId} userId={userId} <DeviceListEntry key={deviceId} userId={userId}
device={userDevices[deviceId]} />, device={userDevices[deviceId]}
/>,
); );
return ( return (
@ -92,26 +93,60 @@ export default React.createClass({
propTypes: { propTypes: {
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
// map from userid -> deviceid -> deviceinfo
devices: React.PropTypes.object.isRequired,
onFinished: React.PropTypes.func.isRequired, onFinished: React.PropTypes.func.isRequired,
}, },
componentDidMount: function() { componentWillMount: function() {
this._unmounted = false;
const roomMembers = this.props.room.getJoinedMembers().map((m) => {
return m.userId;
});
this.setState({
// map from userid -> deviceid -> deviceinfo
devices: null,
});
MatrixClientPeg.get().downloadKeys(roomMembers, false).then((devices) => {
if (this._unmounted) return;
const unknownDevices = {};
// This is all devices in this room, so find the unknown ones.
Object.keys(devices).forEach((userId) => {
Object.keys(devices[userId]).map((deviceId) => {
const device = devices[userId][deviceId];
if (device.isUnverified() && !device.isKnown()) {
if (unknownDevices[userId] === undefined) {
unknownDevices[userId] = {};
}
unknownDevices[userId][deviceId] = device;
}
// Given we've now shown the user the unknown device, it is no longer // Given we've now shown the user the unknown device, it is no longer
// unknown to them. Therefore mark it as 'known'. // unknown to them. Therefore mark it as 'known'.
Object.keys(this.props.devices).forEach((userId) => { if (!device.isKnown()) {
Object.keys(this.props.devices[userId]).map((deviceId) => {
MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true); MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true);
}
}); });
}); });
// XXX: temporary logging to try to diagnose this.setState({
// https://github.com/vector-im/riot-web/issues/3148 devices: unknownDevices,
console.log('Opening UnknownDeviceDialog'); });
});
},
componentWillUnmount: function() {
this._unmounted = true;
}, },
render: function() { render: function() {
if (this.state.devices === null) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const blacklistUnverified = client.getGlobalBlacklistUnverifiedDevices() || const blacklistUnverified = client.getGlobalBlacklistUnverifiedDevices() ||
this.props.room.getBlacklistUnverifiedDevices(); this.props.room.getBlacklistUnverifiedDevices();
@ -154,7 +189,7 @@ export default React.createClass({
{ warning } { warning }
{ _t("Unknown devices") }: { _t("Unknown devices") }:
<UnknownDeviceList devices={this.props.devices} /> <UnknownDeviceList devices={this.state.devices} />
</GeminiScrollbar> </GeminiScrollbar>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" autoFocus={true} <button className="mx_Dialog_primary" autoFocus={true}

View file

@ -0,0 +1,55 @@
/*
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 sdk from '../../../index';
module.exports = React.createClass({
displayName: 'ToolTipButton',
getInitialState: function() {
return {
hover: false,
};
},
onMouseOver: function() {
this.setState({
hover: true,
});
},
onMouseOut: function() {
this.setState({
hover: false,
});
},
render: function() {
const RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
const tip = this.state.hover ? <RoomTooltip
className="mx_ToolTipButton_container"
tooltipClassName="mx_ToolTipButton_helpText"
label={this.props.helpText}
/> : <div />;
return (
<div className="mx_ToolTipButton" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut} >
?
{ tip }
</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.isPrivileged ? EntityTile.POWER_STATUS_ADMIN : null}
/> />
); );
}, },

View file

@ -94,7 +94,7 @@ export default React.createClass({
let roomList = this.state.rooms; let roomList = this.state.rooms;
if (query) { if (query) {
roomList = roomList.filter((room) => { roomList = roomList.filter((room) => {
const matchesName = (room.name || "").toLowerCase().include(query); const matchesName = (room.name || "").toLowerCase().includes(query);
const matchesAlias = (room.canonicalAlias || "").toLowerCase().includes(query); const matchesAlias = (room.canonicalAlias || "").toLowerCase().includes(query);
return matchesName || matchesAlias; return matchesName || matchesAlias;
}); });

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

@ -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

@ -58,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(
@ -188,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,
); );
@ -210,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}
@ -784,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;
@ -989,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;

View file

@ -19,6 +19,7 @@ import MatrixClientPeg from "../../../MatrixClientPeg";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import PinnedEventTile from "./PinnedEventTile"; import PinnedEventTile from "./PinnedEventTile";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import PinningUtils from "../../../utils/PinningUtils";
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'PinnedEventsPanel', displayName: 'PinnedEventsPanel',
@ -61,20 +62,39 @@ module.exports = React.createClass({
Promise.all(promises).then((contexts) => { Promise.all(promises).then((contexts) => {
// Filter out the messages before we try to render them // Filter out the messages before we try to render them
const pinned = contexts.filter((context) => { const pinned = contexts.filter((context) => PinningUtils.isPinnable(context.event));
if (!context) return false; // no context == not applicable for the room
if (context.event.getType() !== "m.room.message") return false;
if (context.event.isRedacted()) return false;
return true;
});
this.setState({ loading: false, pinned }); this.setState({ loading: false, pinned });
}); });
} }
this._updateReadState();
},
_updateReadState: function() {
const pinnedEvents = this.props.room.currentState.getStateEvents("m.room.pinned_events", "");
if (!pinnedEvents) return; // nothing to read
let readStateEvents = [];
const readPinsEvent = this.props.room.getAccountData("im.vector.room.read_pins");
if (readPinsEvent && readPinsEvent.getContent()) {
readStateEvents = readPinsEvent.getContent().event_ids || [];
}
if (!readStateEvents.includes(pinnedEvents.getId())) {
readStateEvents.push(pinnedEvents.getId());
// Only keep the last 10 event IDs to avoid infinite growth
readStateEvents = readStateEvents.reverse().splice(0, 10).reverse();
MatrixClientPeg.get().setRoomAccountData(this.props.room.roomId, "im.vector.room.read_pins", {
event_ids: readStateEvents,
});
}
}, },
_getPinnedTiles: function() { _getPinnedTiles: function() {
if (this.state.pinned.length == 0) { if (this.state.pinned.length === 0) {
return (<div>{ _t("No pinned messages.") }</div>); return (<div>{ _t("No pinned messages.") }</div>);
} }

View file

@ -23,6 +23,7 @@ import sanitizeHtml from 'sanitize-html';
import { ContentRepo } from 'matrix-js-sdk'; import { ContentRepo } from 'matrix-js-sdk';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
function getDisplayAliasForRoom(room) { function getDisplayAliasForRoom(room) {
return room.canonicalAlias || (room.aliases ? room.aliases[0] : ""); return room.canonicalAlias || (room.aliases ? room.aliases[0] : "");
@ -117,6 +118,8 @@ export default React.createClass({
worldReadable: PropTypes.bool, worldReadable: PropTypes.bool,
guestCanJoin: PropTypes.bool, guestCanJoin: PropTypes.bool,
})), })),
className: PropTypes.string,
}, },
getRows: function() { getRows: function() {
@ -138,7 +141,7 @@ export default React.createClass({
</tbody> </tbody>
</table>; </table>;
} }
return <div className="mx_RoomDetailList"> return <div className={classNames("mx_RoomDetailList", this.props.className)}>
{ rooms } { rooms }
</div>; </div>;
}, },

View file

@ -65,6 +65,7 @@ module.exports = React.createClass({
componentDidMount: function() { componentDidMount: function() {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
cli.on("RoomState.events", this._onRoomStateEvents); cli.on("RoomState.events", this._onRoomStateEvents);
cli.on("Room.accountData", this._onRoomAccountData);
// When a room name occurs, RoomState.events is fired *before* // When a room name occurs, RoomState.events is fired *before*
// room.name is updated. So we have to listen to Room.name as well as // room.name is updated. So we have to listen to Room.name as well as
@ -87,6 +88,7 @@ module.exports = React.createClass({
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli) { if (cli) {
cli.removeListener("RoomState.events", this._onRoomStateEvents); cli.removeListener("RoomState.events", this._onRoomStateEvents);
cli.removeListener("Room.accountData", this._onRoomAccountData);
} }
}, },
@ -99,6 +101,13 @@ module.exports = React.createClass({
this._rateLimitedUpdate(); this._rateLimitedUpdate();
}, },
_onRoomAccountData: function(event, room) {
if (!this.props.room || room.roomId !== this.props.room.roomId) return;
if (event.getType() !== "im.vector.room.read_pins") return;
this._rateLimitedUpdate();
},
_rateLimitedUpdate: new RateLimitedFunc(function() { _rateLimitedUpdate: new RateLimitedFunc(function() {
/* eslint-disable babel/no-invalid-this */ /* eslint-disable babel/no-invalid-this */
this.forceUpdate(); this.forceUpdate();
@ -139,6 +148,32 @@ module.exports = React.createClass({
dis.dispatch({ action: 'show_right_panel' }); dis.dispatch({ action: 'show_right_panel' });
}, },
_hasUnreadPins: function() {
const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", '');
if (!currentPinEvent) return false;
if (currentPinEvent.getContent().pinned && currentPinEvent.getContent().pinned.length <= 0) {
return false; // no pins == nothing to read
}
const readPinsEvent = this.props.room.getAccountData("im.vector.room.read_pins");
if (readPinsEvent && readPinsEvent.getContent()) {
const readStateEvents = readPinsEvent.getContent().event_ids || [];
if (readStateEvents) {
return !readStateEvents.includes(currentPinEvent.getId());
}
}
// There's pins, and we haven't read any of them
return true;
},
_hasPins: function() {
const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", '');
if (!currentPinEvent) return false;
return !(currentPinEvent.getContent().pinned && currentPinEvent.getContent().pinned.length <= 0);
},
/** /**
* After editing the settings, get the new name for the room * After editing the settings, get the new name for the room
* *
@ -305,8 +340,17 @@ module.exports = React.createClass({
} }
if (this.props.onPinnedClick && UserSettingsStore.isFeatureEnabled('feature_pinning')) { if (this.props.onPinnedClick && UserSettingsStore.isFeatureEnabled('feature_pinning')) {
let pinsIndicator = null;
if (this._hasUnreadPins()) {
pinsIndicator = (<div className="mx_RoomHeader_pinsIndicator mx_RoomHeader_pinsIndicatorUnread" />);
} else if (this._hasPins()) {
pinsIndicator = (<div className="mx_RoomHeader_pinsIndicator" />);
}
pinnedEventsButton = pinnedEventsButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onPinnedClick} title={_t("Pinned Messages")}> <AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_pinnedButton"
onClick={this.props.onPinnedClick} title={_t("Pinned Messages")}>
{ pinsIndicator }
<TintableSvg src="img/icons-pin.svg" width="16" height="16" /> <TintableSvg src="img/icons-pin.svg" width="16" height="16" />
</AccessibleButton>; </AccessibleButton>;
} }

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,
isPrivileged: apiObject.is_privileged,
}; };
} }

View file

@ -49,7 +49,7 @@
"Name or matrix ID": "Name or matrix ID", "Name or matrix ID": "Name or matrix ID",
"Invite to Community": "Invite to Community", "Invite to Community": "Invite to Community",
"Which rooms would you like to add to this community?": "Which rooms would you like to add to this community?", "Which rooms would you like to add to this community?": "Which rooms would you like to add to this community?",
"Warning: any room you add to a community will be publicly visible to anyone who knows the community ID": "Warning: any room you add to a community will be publicly visible to anyone who knows the community ID", "Show these rooms to non-members on the community page and room list?": "Show these rooms to non-members on the community page and room list?",
"Add rooms to the community": "Add rooms to the community", "Add rooms to the community": "Add rooms to the community",
"Room name or alias": "Room name or alias", "Room name or alias": "Room name or alias",
"Add to community": "Add to community", "Add to community": "Add to community",
@ -673,6 +673,7 @@
"You must <a>register</a> to use this functionality": "You must <a>register</a> to use this functionality", "You must <a>register</a> to use this functionality": "You must <a>register</a> to use this functionality",
"You must join the room to see its files": "You must join the room to see its files", "You must join the room to see its files": "You must join the room to see its files",
"There are no visible files in this room": "There are no visible files in this room", "There are no visible files in this room": "There are no visible files in this room",
"<h1>HTML for your community's page</h1>\n<p>\n Use the long description to introduce new members to the community, or distribute\n some important <a href=\"foo\">links</a>\n</p>\n<p>\n You can even use 'img' tags\n</p>\n": "<h1>HTML for your community's page</h1>\n<p>\n Use the long description to introduce new members to the community, or distribute\n some important <a href=\"foo\">links</a>\n</p>\n<p>\n You can even use 'img' tags\n</p>\n",
"Add rooms to the community summary": "Add rooms to the community summary", "Add rooms to the community summary": "Add rooms to the community summary",
"Which rooms would you like to add to this summary?": "Which rooms would you like to add to this summary?", "Which rooms would you like to add to this summary?": "Which rooms would you like to add to this summary?",
"Add to summary": "Add to summary", "Add to summary": "Add to summary",
@ -695,6 +696,7 @@
"Leave": "Leave", "Leave": "Leave",
"Unable to leave room": "Unable to leave room", "Unable to leave room": "Unable to leave room",
"Community Settings": "Community Settings", "Community Settings": "Community Settings",
"These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.": "These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.",
"Add rooms to this community": "Add rooms to this community", "Add rooms to this community": "Add rooms to this community",
"Featured Rooms:": "Featured Rooms:", "Featured Rooms:": "Featured Rooms:",
"Featured Users:": "Featured Users:", "Featured Users:": "Featured Users:",
@ -703,6 +705,7 @@
"You are a member of this community": "You are a member of this community", "You are a member of this community": "You are a member of this community",
"Community Member Settings": "Community Member Settings", "Community Member Settings": "Community Member Settings",
"Publish this community on your profile": "Publish this community on your profile", "Publish this community on your profile": "Publish this community on your profile",
"Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!": "Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!",
"Long Description (HTML)": "Long Description (HTML)", "Long Description (HTML)": "Long Description (HTML)",
"Description": "Description", "Description": "Description",
"Community %(groupId)s not found": "Community %(groupId)s not found", "Community %(groupId)s not found": "Community %(groupId)s not found",
@ -893,6 +896,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

@ -69,9 +69,13 @@ class FlairStore extends EventEmitter {
} }
// Bulk lookup ongoing, return promise to resolve/reject // Bulk lookup ongoing, return promise to resolve/reject
if (this._usersPending[userId] || this._usersInFlight[userId]) { if (this._usersPending[userId]) {
return this._usersPending[userId].prom; return this._usersPending[userId].prom;
} }
// User has been moved from pending to in-flight
if (this._usersInFlight[userId]) {
return this._usersInFlight[userId].prom;
}
this._usersPending[userId] = {}; this._usersPending[userId] = {};
this._usersPending[userId].prom = new Promise((resolve, reject) => { this._usersPending[userId].prom = new Promise((resolve, reject) => {

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 = {};
@ -166,6 +169,12 @@ export default class GroupStore extends EventEmitter {
.then(this._fetchMembers.bind(this)); .then(this._fetchMembers.bind(this));
} }
acceptGroupInvite() {
return this._matrixClient.acceptGroupInvite(this.groupId)
// The user might be able to see more rooms now
.then(this._fetchRooms.bind(this));
}
addRoomToGroupSummary(roomId, categoryId) { addRoomToGroupSummary(roomId, categoryId) {
return this._matrixClient return this._matrixClient
.addRoomToGroupSummary(this.groupId, roomId, categoryId) .addRoomToGroupSummary(this.groupId, roomId, categoryId)

30
src/utils/PinningUtils.js Normal file
View file

@ -0,0 +1,30 @@
/*
Copyright 2017 Travis Ralston
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.
*/
export default class PinningUtils {
/**
* Determines if the given event may be pinned.
* @param {MatrixEvent} event The event to check.
* @return {boolean} True if the event may be pinned, false otherwise.
*/
static isPinnable(event) {
if (!event) return false;
if (event.getType() !== "m.room.message") return false;
if (event.isRedacted()) return false;
return true;
}
}