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) {
return new Promise((resolve, reject) => {
let addRoomsPublicly = false;
const onCheckboxClicked = (e) => {
addRoomsPublicly = e.target.checked;
};
const description = <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>;
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");
Modal.createTrackedDialog('Add Rooms to Group', '', AddressPickerDialog, {
title: _t("Add rooms to the community"),
description: description,
extraNode: checkboxContainer,
placeholder: _t("Room name or alias"),
button: _t("Add to community"),
pickerType: 'room',
@ -70,7 +76,7 @@ export function showGroupAddRoomDialog(groupId) {
onFinished: (success, addrs) => {
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 groupStore = GroupStoreCache.getGroupStore(matrixClient, groupId);
const errorList = [];
return Promise.all(addrs.map((addr) => {
return groupStore
.addRoomToGroup(addr.address)
.addRoomToGroup(addr.address, addRoomsPublicly)
.catch(() => { errorList.push(addr.address); })
.then(() => {
const roomId = addr.address;

View file

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

View file

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

View file

@ -23,6 +23,7 @@ import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider';
import NotifProvider from './NotifProvider';
import Promise from 'bluebird';
export type SelectionRange = {
@ -44,6 +45,7 @@ const PROVIDERS = [
UserProvider,
RoomProvider,
EmojiProvider,
NotifProvider,
CommandProvider,
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 dis from '../../dispatcher';
import { sanitizedHtmlNode } from '../../HtmlUtils';
import { _t } from '../../languageHandler';
import { _t, _td, _tJsx } from '../../languageHandler';
import AccessibleButton from '../views/elements/AccessibleButton';
import Modal from '../../Modal';
import classnames from 'classnames';
@ -32,6 +32,17 @@ import GroupStore from '../../stores/GroupStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
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({
room_id: PropTypes.string.isRequired,
profile: PropTypes.shape({
@ -392,6 +403,8 @@ export default React.createClass({
propTypes: {
groupId: PropTypes.string.isRequired,
// Whether this is the first time the group admin is viewing the group
groupIsNew: PropTypes.bool,
},
childContextTypes: {
@ -417,12 +430,13 @@ export default React.createClass({
uploadingAvatar: false,
membershipBusy: false,
publicityBusy: false,
inviterProfile: null,
};
},
componentWillMount: function() {
this._changeAvatarComponent = null;
this._initGroupStore(this.props.groupId);
this._initGroupStore(this.props.groupId, true);
MatrixClientPeg.get().on("Group.myMembership", this._onGroupMyMembership);
},
@ -449,7 +463,11 @@ export default React.createClass({
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.registerListener(() => {
const summary = this._groupStore.getSummary();
@ -472,6 +490,9 @@ export default React.createClass({
),
error: null,
});
if (this.props.groupIsNew && firstInit) {
this._onEditClick();
}
});
this._groupStore.on('error', (err) => {
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) {
dis.dispatch({ action: 'show_right_panel' });
},
@ -575,7 +616,7 @@ export default React.createClass({
_onAcceptInviteClick: function() {
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
}).catch((e) => {
this.setState({membershipBusy: false});
@ -661,6 +702,14 @@ export default React.createClass({
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
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 ?
(<AccessibleButton className="mx_GroupView_rooms_header_addRow"
@ -673,14 +722,23 @@ export default React.createClass({
{ _t('Add rooms to this community') }
</div>
</AccessibleButton>) : <div />;
const roomDetailListClassName = classnames({
"mx_fadable": true,
"mx_fadable_faded": this.state.editing,
});
return <div className="mx_GroupView_rooms">
<div className="mx_GroupView_rooms_header">
<h3>{ _t('Rooms') }</h3>
<h3>
{ _t('Rooms') }
{ roomsHelpNode }
</h3>
{ addRoomRow }
</div>
{ this.state.groupRoomsLoading ?
<Spinner /> :
<RoomDetailList rooms={this.state.groupRooms} />
<RoomDetailList
rooms={this.state.groupRooms}
className={roomDetailListClassName} />
}
</div>;
},
@ -769,20 +827,37 @@ export default React.createClass({
_getMembershipSection: function() {
const Spinner = sdk.getComponent("elements.Spinner");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const group = MatrixClientPeg.get().getGroup(this.props.groupId);
if (!group) return null;
if (group.myMembership === 'invite') {
if (this.state.membershipBusy) {
if (this.state.membershipBusy || this.state.inviterProfileBusy) {
return <div className="mx_GroupView_membershipSection">
<Spinner />
</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">
<div className="mx_GroupView_membershipSubSection">
<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 className="mx_GroupView_membership_buttonContainer">
<AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton"
@ -851,6 +926,18 @@ export default React.createClass({
let description = null;
if (summary.profile && 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({
"mx_GroupView_groupDesc": true,
@ -862,6 +949,7 @@ export default React.createClass({
<h3> { _t("Long Description (HTML)") } </h3>
<textarea
value={this.state.profileForm.long_description}
placeholder={_t(LONG_DESC_PLACEHOLDER)}
onChange={this._onLongDescChange}
tabIndex="4"
key="editLongDesc"

View file

@ -301,6 +301,7 @@ export default React.createClass({
case PageTypes.GroupView:
page_element = <GroupView
groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew}
collapsedRhs={this.props.collapseRhs}
/>;
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':
{
const groupId = payload.group_id;
this.setState({currentGroupId: groupId});
this.setState({
currentGroupId: groupId,
currentGroupIsNew: payload.group_is_new,
});
this._setPage(PageTypes.GroupView);
this.notifyNewScreen('group/' + groupId);
}

View file

@ -303,6 +303,15 @@ module.exports = React.createClass({
_shouldShowApps: function(room) {
if (!BROWSER_SUPPORTS_SANDBOX) return false;
// Check if user has previously chosen to hide the app drawer for this
// room. If so, do not show apps
let hideWidgetDrawer = localStorage.getItem(
room.roomId + "_hide_widget_drawer");
if (hideWidgetDrawer === "true") {
return false;
}
const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets');
// any valid widget = show apps
for (let i = 0; i < appsStateEvents.length; i++) {

View file

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

View file

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

View file

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

View file

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

View file

@ -47,7 +47,7 @@ function presenceClassForMember(presenceState, lastActiveAgo) {
}
}
module.exports = React.createClass({
const EntityTile = React.createClass({
displayName: 'EntityTile',
propTypes: {
@ -140,16 +140,19 @@ module.exports = React.createClass({
}
let power;
const powerLevel = this.props.powerLevel;
if (powerLevel >= 50 && powerLevel < 99) {
power = <img src="img/mod.svg" className="mx_EntityTile_power" width="16" height="17" alt={_t("Moderator")} />;
}
if (powerLevel >= 99) {
power = <img src="img/admin.svg" className="mx_EntityTile_power" width="16" height="17" alt={_t("Admin")} />;
const powerStatus = this.props.powerStatus;
if (powerStatus) {
const src = {
[EntityTile.POWER_STATUS_MODERATOR]: "img/mod.svg",
[EntityTile.POWER_STATUS_ADMIN]: "img/admin.svg",
}[powerStatus];
const alt = {
[EntityTile.POWER_STATUS_MODERATOR]: _t("Moderator"),
[EntityTile.POWER_STATUS_ADMIN]: _t("Admin"),
}[powerStatus];
power = <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 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();
// 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 (
<EntityTile {...this.props} presenceState={presenceState}
presenceLastActiveAgo={member.user ? member.user.lastActiveAgo : 0}
presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
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 = String.fromCharCode(ZWS_CODE); // zero width space
const ENTITY_TYPES = {
AT_ROOM_PILL: 'ATROOMPILL',
};
function stateToMarkdown(state) {
return __stateToMarkdown(state)
.replace(
@ -188,13 +193,16 @@ export default class MessageComposerInput extends React.Component {
this.client = MatrixClientPeg.get();
}
findLinkEntities(contentState: ContentState, contentBlock: ContentBlock, callback) {
findPillEntities(contentState: ContentState, contentBlock: ContentBlock, callback) {
contentBlock.findEntityRanges(
(character) => {
const entityKey = character.getEntity();
return (
entityKey !== null &&
contentState.getEntity(entityKey).getType() === 'LINK'
(
contentState.getEntity(entityKey).getType() === 'LINK' ||
contentState.getEntity(entityKey).getType() === ENTITY_TYPES.AT_ROOM_PILL
)
);
}, callback,
);
@ -210,11 +218,19 @@ export default class MessageComposerInput extends React.Component {
RichText.getScopedMDDecorators(this.props);
const shouldShowPillAvatar = !UserSettingsStore.getSyncedSetting("Pill.shouldHidePillAvatar", false);
decorators.push({
strategy: this.findLinkEntities.bind(this),
strategy: this.findPillEntities.bind(this),
component: (entityProps) => {
const Pill = sdk.getComponent('elements.Pill');
const type = entityProps.contentState.getEntity(entityProps.entityKey).getType();
const {url} = entityProps.contentState.getEntity(entityProps.entityKey).getData();
if (Pill.isPillUrl(url)) {
if (type === ENTITY_TYPES.AT_ROOM_PILL) {
return <Pill
type={Pill.TYPE_AT_ROOM_MENTION}
room={this.props.room}
offsetKey={entityProps.offsetKey}
shouldShowPillAvatar={shouldShowPillAvatar}
/>;
} else if (Pill.isPillUrl(url)) {
return <Pill
url={url}
room={this.props.room}
@ -784,7 +800,7 @@ export default class MessageComposerInput extends React.Component {
const pt = contentState.getBlocksAsArray().map((block) => {
let blockText = block.getText();
let offset = 0;
this.findLinkEntities(contentState, block, (start, end) => {
this.findPillEntities(contentState, block, (start, end) => {
const entity = contentState.getEntity(block.getEntityAt(start));
if (entity.getType() !== 'LINK') {
return;
@ -989,6 +1005,11 @@ export default class MessageComposerInput extends React.Component {
isCompletion: true,
});
entityKey = contentState.getLastCreatedEntityKey();
} else if (completion === '@room') {
contentState = contentState.createEntity(ENTITY_TYPES.AT_ROOM_PILL, 'IMMUTABLE', {
isCompletion: true,
});
entityKey = contentState.getLastCreatedEntityKey();
}
let selection;

View file

@ -19,6 +19,7 @@ import MatrixClientPeg from "../../../MatrixClientPeg";
import AccessibleButton from "../elements/AccessibleButton";
import PinnedEventTile from "./PinnedEventTile";
import { _t } from '../../../languageHandler';
import PinningUtils from "../../../utils/PinningUtils";
module.exports = React.createClass({
displayName: 'PinnedEventsPanel',
@ -61,20 +62,39 @@ module.exports = React.createClass({
Promise.all(promises).then((contexts) => {
// Filter out the messages before we try to render them
const pinned = contexts.filter((context) => {
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;
});
const pinned = contexts.filter((context) => PinningUtils.isPinnable(context.event));
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() {
if (this.state.pinned.length == 0) {
if (this.state.pinned.length === 0) {
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 MatrixClientPeg from '../../../MatrixClientPeg';
import PropTypes from 'prop-types';
import classNames from 'classnames';
function getDisplayAliasForRoom(room) {
return room.canonicalAlias || (room.aliases ? room.aliases[0] : "");
@ -117,6 +118,8 @@ export default React.createClass({
worldReadable: PropTypes.bool,
guestCanJoin: PropTypes.bool,
})),
className: PropTypes.string,
},
getRows: function() {
@ -138,7 +141,7 @@ export default React.createClass({
</tbody>
</table>;
}
return <div className="mx_RoomDetailList">
return <div className={classNames("mx_RoomDetailList", this.props.className)}>
{ rooms }
</div>;
},

View file

@ -65,6 +65,7 @@ module.exports = React.createClass({
componentDidMount: function() {
const cli = MatrixClientPeg.get();
cli.on("RoomState.events", this._onRoomStateEvents);
cli.on("Room.accountData", this._onRoomAccountData);
// 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
@ -87,6 +88,7 @@ module.exports = React.createClass({
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this._onRoomStateEvents);
cli.removeListener("Room.accountData", this._onRoomAccountData);
}
},
@ -99,6 +101,13 @@ module.exports = React.createClass({
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() {
/* eslint-disable babel/no-invalid-this */
this.forceUpdate();
@ -139,6 +148,32 @@ module.exports = React.createClass({
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
*
@ -305,8 +340,17 @@ module.exports = React.createClass({
}
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 =
<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" />
</AccessibleButton>;
}

View file

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

View file

@ -49,7 +49,7 @@
"Name or matrix ID": "Name or matrix ID",
"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?",
"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",
"Room name or alias": "Room name or alias",
"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 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",
"<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",
"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",
@ -695,6 +696,7 @@
"Leave": "Leave",
"Unable to leave room": "Unable to leave room",
"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",
"Featured Rooms:": "Featured Rooms:",
"Featured Users:": "Featured Users:",
@ -703,6 +705,7 @@
"You are a member of this community": "You are a member of this community",
"Community Member Settings": "Community Member Settings",
"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)",
"Description": "Description",
"Community %(groupId)s not found": "Community %(groupId)s not found",
@ -893,6 +896,8 @@
"Commands": "Commands",
"Results from DuckDuckGo": "Results from DuckDuckGo",
"Emoji": "Emoji",
"Notify the whole room": "Notify the whole room",
"Room Notification": "Room Notification",
"Users": "Users",
"unknown device": "unknown device",
"NOT verified": "NOT verified",

View file

@ -69,9 +69,13 @@ class FlairStore extends EventEmitter {
}
// Bulk lookup ongoing, return promise to resolve/reject
if (this._usersPending[userId] || this._usersInFlight[userId]) {
if (this._usersPending[userId]) {
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].prom = new Promise((resolve, reject) => {

View file

@ -33,6 +33,9 @@ export default class GroupStore extends EventEmitter {
constructor(matrixClient, groupId) {
super();
if (!groupId) {
throw new Error('GroupStore needs a valid groupId to be created');
}
this.groupId = groupId;
this._matrixClient = matrixClient;
this._summary = {};
@ -166,6 +169,12 @@ export default class GroupStore extends EventEmitter {
.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) {
return this._matrixClient
.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;
}
}