mirror of
https://github.com/element-hq/element-web.git
synced 2024-12-14 19:31:31 +03:00
Merge remote-tracking branch 'origin/develop' into dbkr/group_userlist
This commit is contained in:
commit
7469a9ae2a
9 changed files with 513 additions and 147 deletions
|
@ -145,7 +145,7 @@ const sanitizeHtmlParams = {
|
|||
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
|
||||
span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
|
||||
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
|
||||
img: ['src'],
|
||||
img: ['src', 'width', 'height', 'alt', 'title'],
|
||||
ol: ['start'],
|
||||
code: ['class'], // We don't actually allow all classes, we filter them in transformTags
|
||||
},
|
||||
|
|
|
@ -22,6 +22,8 @@ import dis from '../../dispatcher';
|
|||
import { sanitizedHtmlNode } from '../../HtmlUtils';
|
||||
import { _t } from '../../languageHandler';
|
||||
import AccessibleButton from '../views/elements/AccessibleButton';
|
||||
import Modal from '../../Modal';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const RoomSummaryType = PropTypes.shape({
|
||||
room_id: PropTypes.string.isRequired,
|
||||
|
@ -179,10 +181,13 @@ export default React.createClass({
|
|||
summary: null,
|
||||
error: null,
|
||||
editing: false,
|
||||
saving: false,
|
||||
uploadingAvatar: false,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this._changeAvatarComponent = null;
|
||||
this._loadGroupFromServer(this.props.groupId);
|
||||
},
|
||||
|
||||
|
@ -211,8 +216,83 @@ export default React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_onSettingsClick: function() {
|
||||
this.setState({editing: true});
|
||||
_onEditClick: function() {
|
||||
this.setState({
|
||||
editing: true,
|
||||
profileForm: Object.assign({}, this.state.summary.profile),
|
||||
});
|
||||
},
|
||||
|
||||
_onCancelClick: function() {
|
||||
this.setState({
|
||||
editing: false,
|
||||
profileForm: null,
|
||||
});
|
||||
},
|
||||
|
||||
_onNameChange: function(e) {
|
||||
const newProfileForm = Object.assign(this.state.profileForm, { name: e.target.value });
|
||||
this.setState({
|
||||
profileForm: newProfileForm,
|
||||
});
|
||||
},
|
||||
|
||||
_onShortDescChange: function(e) {
|
||||
const newProfileForm = Object.assign(this.state.profileForm, { short_description: e.target.value });
|
||||
this.setState({
|
||||
profileForm: newProfileForm,
|
||||
});
|
||||
},
|
||||
|
||||
_onLongDescChange: function(e) {
|
||||
const newProfileForm = Object.assign(this.state.profileForm, { long_description: e.target.value });
|
||||
this.setState({
|
||||
profileForm: newProfileForm,
|
||||
});
|
||||
},
|
||||
|
||||
_onAvatarSelected: function(ev) {
|
||||
const file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
this.setState({uploadingAvatar: true});
|
||||
MatrixClientPeg.get().uploadContent(file).then((url) => {
|
||||
const newProfileForm = Object.assign(this.state.profileForm, { avatar_url: url });
|
||||
this.setState({
|
||||
uploadingAvatar: false,
|
||||
profileForm: newProfileForm,
|
||||
});
|
||||
}).catch((e) => {
|
||||
this.setState({uploadingAvatar: false});
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to upload avatar image", e);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: _t('Failed to upload image'),
|
||||
});
|
||||
}).done();
|
||||
},
|
||||
|
||||
_onSaveClick: function() {
|
||||
this.setState({saving: true});
|
||||
MatrixClientPeg.get().setGroupProfile(this.props.groupId, this.state.profileForm).then((result) => {
|
||||
this.setState({
|
||||
saving: false,
|
||||
editing: false,
|
||||
summary: null,
|
||||
});
|
||||
this._loadGroupFromServer(this.props.groupId);
|
||||
}).catch((e) => {
|
||||
this.setState({
|
||||
saving: false,
|
||||
});
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to save group profile", e);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: _t('Failed to update group'),
|
||||
});
|
||||
}).done();
|
||||
},
|
||||
|
||||
_getFeaturedRoomsNode() {
|
||||
|
@ -296,60 +376,129 @@ export default React.createClass({
|
|||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
|
||||
if (this.state.summary === null && this.state.error === null) {
|
||||
if (this.state.summary === null && this.state.error === null || this.state.saving) {
|
||||
return <Loader />;
|
||||
} else if (this.state.editing) {
|
||||
return <div />;
|
||||
} else if (this.state.summary) {
|
||||
const summary = this.state.summary;
|
||||
let description = null;
|
||||
if (summary.profile && summary.profile.long_description) {
|
||||
description = sanitizedHtmlNode(summary.profile.long_description);
|
||||
}
|
||||
|
||||
const roomBody = <div>
|
||||
<div className="mx_GroupView_groupDesc">{description}</div>
|
||||
{this._getFeaturedRoomsNode()}
|
||||
{this._getFeaturedUsersNode()}
|
||||
</div>;
|
||||
|
||||
let avatarNode;
|
||||
let nameNode;
|
||||
if (summary.profile && summary.profile.name) {
|
||||
nameNode = <div className="mx_RoomHeader_name">
|
||||
<span>{summary.profile.name}</span>
|
||||
<span className="mx_GroupView_header_groupid">
|
||||
({this.props.groupId})
|
||||
</span>
|
||||
let shortDescNode;
|
||||
let rightButtons;
|
||||
let roomBody;
|
||||
const headerClasses = {
|
||||
mx_GroupView_header: true,
|
||||
};
|
||||
if (this.state.editing) {
|
||||
let avatarImage;
|
||||
if (this.state.uploadingAvatar) {
|
||||
avatarImage = <Loader />;
|
||||
} else {
|
||||
const GroupAvatar = sdk.getComponent('avatars.GroupAvatar');
|
||||
avatarImage = <GroupAvatar groupId={this.props.groupId}
|
||||
groupAvatarUrl={this.state.profileForm.avatar_url}
|
||||
width={48} height={48} resizeMethod='crop'
|
||||
/>;
|
||||
}
|
||||
|
||||
avatarNode = (
|
||||
<div className="mx_GroupView_avatarPicker">
|
||||
<label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label">
|
||||
{avatarImage}
|
||||
</label>
|
||||
<div className="mx_GroupView_avatarPicker_edit">
|
||||
<label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label">
|
||||
<img src="img/camera.svg"
|
||||
alt={ _t("Upload avatar") } title={ _t("Upload avatar") }
|
||||
width="17" height="15" />
|
||||
</label>
|
||||
<input id="avatarInput" className="mx_GroupView_uploadInput" type="file" onChange={this._onAvatarSelected}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
nameNode = <input type="text"
|
||||
value={this.state.profileForm.name}
|
||||
onChange={this._onNameChange}
|
||||
placeholder={_t('Group Name')}
|
||||
tabIndex="1"
|
||||
/>;
|
||||
shortDescNode = <input type="text"
|
||||
value={this.state.profileForm.short_description}
|
||||
onChange={this._onShortDescChange}
|
||||
placeholder={_t('Description')}
|
||||
tabIndex="2"
|
||||
/>;
|
||||
rightButtons = <span>
|
||||
<AccessibleButton className="mx_GroupView_saveButton mx_RoomHeader_textButton" onClick={this._onSaveClick}>
|
||||
{_t('Save')}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className='mx_GroupView_cancelButton' onClick={this._onCancelClick}>
|
||||
<img src="img/cancel.svg" className='mx_filterFlipColor'
|
||||
width="18" height="18" alt={_t("Cancel")}/>
|
||||
</AccessibleButton>
|
||||
</span>;
|
||||
roomBody = <div>
|
||||
<textarea className="mx_GroupView_editLongDesc" value={this.state.profileForm.long_description}
|
||||
onChange={this._onLongDescChange}
|
||||
tabIndex="3"
|
||||
/>
|
||||
</div>;
|
||||
} else {
|
||||
nameNode = <div className="mx_RoomHeader_name">
|
||||
<span>{this.props.groupId}</span>
|
||||
const groupAvatarUrl = summary.profile ? summary.profile.avatar_url : null;
|
||||
avatarNode = <GroupAvatar
|
||||
groupId={this.props.groupId}
|
||||
groupAvatarUrl={groupAvatarUrl}
|
||||
width={48} height={48}
|
||||
/>;
|
||||
if (summary.profile && summary.profile.name) {
|
||||
nameNode = <div>
|
||||
<span>{summary.profile.name}</span>
|
||||
<span className="mx_GroupView_header_groupid">
|
||||
({this.props.groupId})
|
||||
</span>
|
||||
</div>;
|
||||
} else {
|
||||
nameNode = <span>{this.props.groupId}</span>;
|
||||
}
|
||||
shortDescNode = <span>{summary.profile.short_description}</span>;
|
||||
|
||||
let description = null;
|
||||
if (summary.profile && summary.profile.long_description) {
|
||||
description = sanitizedHtmlNode(summary.profile.long_description);
|
||||
}
|
||||
roomBody = <div>
|
||||
<div className="mx_GroupView_groupDesc">{description}</div>
|
||||
{this._getFeaturedRoomsNode()}
|
||||
{this._getFeaturedUsersNode()}
|
||||
</div>;
|
||||
// disabled until editing works
|
||||
rightButtons = <AccessibleButton className="mx_GroupHeader_button"
|
||||
onClick={this._onEditClick} title={_t("Edit Group")}
|
||||
>
|
||||
<TintableSvg src="img/icons-settings-room.svg" width="16" height="16"/>
|
||||
</AccessibleButton>;
|
||||
|
||||
headerClasses.mx_GroupView_header_view = true;
|
||||
}
|
||||
|
||||
const groupAvatarUrl = summary.profile ? summary.profile.avatar_url : null;
|
||||
|
||||
// settings button is display: none until settings is wired up
|
||||
return (
|
||||
<div className="mx_GroupView">
|
||||
<div className="mx_RoomHeader">
|
||||
<div className="mx_RoomHeader_wrapper">
|
||||
<div className="mx_RoomHeader_avatar">
|
||||
<GroupAvatar
|
||||
groupId={this.props.groupId}
|
||||
groupAvatarUrl={groupAvatarUrl}
|
||||
width={48} height={48}
|
||||
/>
|
||||
<div className={classnames(headerClasses)}>
|
||||
<div className="mx_GroupView_header_leftCol">
|
||||
<div className="mx_GroupView_header_avatar">
|
||||
{avatarNode}
|
||||
</div>
|
||||
<div className="mx_RoomHeader_info">
|
||||
{nameNode}
|
||||
<div className="mx_RoomHeader_topic">
|
||||
{summary.profile.short_description}
|
||||
<div className="mx_GroupView_header_info">
|
||||
<div className="mx_GroupView_header_name">
|
||||
{nameNode}
|
||||
</div>
|
||||
<div className="mx_GroupView_header_shortDesc">
|
||||
{shortDescNode}
|
||||
</div>
|
||||
</div>
|
||||
<AccessibleButton className="mx_RoomHeader_button" onClick={this._onSettingsClick} title={_t("Settings")} style={{display: 'none'}}>
|
||||
<TintableSvg src="img/icons-settings-room.svg" width="16" height="16"/>
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<div className="mx_GroupView_header_rightCol">
|
||||
{rightButtons}
|
||||
</div>
|
||||
</div>
|
||||
{roomBody}
|
||||
|
|
191
src/components/views/elements/Pill.js
Normal file
191
src/components/views/elements/Pill.js
Normal file
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations 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';
|
||||
import classNames from 'classnames';
|
||||
import { Room, RoomMember } from 'matrix-js-sdk';
|
||||
import PropTypes from 'prop-types';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import { MATRIXTO_URL_PATTERN } from '../../../linkify-matrix';
|
||||
import { getDisplayAliasForRoom } from '../../../Rooms';
|
||||
|
||||
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
|
||||
|
||||
// For URLs of matrix.to links in the timeline which have been reformatted by
|
||||
// HttpUtils transformTags to relative links
|
||||
const REGEX_LOCAL_MATRIXTO = /^#\/(?:user|room)\/(([\#\!\@\+]).*)$/;
|
||||
|
||||
const Pill = React.createClass({
|
||||
statics: {
|
||||
isPillUrl: (url) => {
|
||||
return !!REGEX_MATRIXTO.exec(url);
|
||||
},
|
||||
isMessagePillUrl: (url) => {
|
||||
return !!REGEX_LOCAL_MATRIXTO.exec(url);
|
||||
},
|
||||
TYPE_USER_MENTION: 'TYPE_USER_MENTION',
|
||||
TYPE_ROOM_MENTION: 'TYPE_ROOM_MENTION',
|
||||
},
|
||||
|
||||
props: {
|
||||
// The URL to pillify (no validation is done, see isPillUrl and isMessagePillUrl)
|
||||
url: PropTypes.string,
|
||||
// Whether the pill is in a message
|
||||
inMessage: PropTypes.bool,
|
||||
// The room in which this pill is being rendered
|
||||
room: PropTypes.instanceOf(Room),
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
// ID/alias of the room/user
|
||||
resourceId: null,
|
||||
// Type of pill
|
||||
pillType: null,
|
||||
|
||||
// The member related to the user pill
|
||||
member: null,
|
||||
// The room related to the room pill
|
||||
room: null,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount() {
|
||||
this._unmounted = false;
|
||||
let regex = REGEX_MATRIXTO;
|
||||
if (this.props.inMessage) {
|
||||
regex = REGEX_LOCAL_MATRIXTO;
|
||||
}
|
||||
|
||||
// Default to the empty array if no match for simplicity
|
||||
// resource and prefix will be undefined instead of throwing
|
||||
const matrixToMatch = regex.exec(this.props.url) || [];
|
||||
|
||||
const resourceId = matrixToMatch[1]; // The room/user ID
|
||||
const prefix = matrixToMatch[2]; // The first character of prefix
|
||||
|
||||
const pillType = {
|
||||
'@': Pill.TYPE_USER_MENTION,
|
||||
'#': Pill.TYPE_ROOM_MENTION,
|
||||
'!': Pill.TYPE_ROOM_MENTION,
|
||||
}[prefix];
|
||||
|
||||
let member;
|
||||
let room;
|
||||
switch (pillType) {
|
||||
case Pill.TYPE_USER_MENTION: {
|
||||
const localMember = this.props.room.getMember(resourceId);
|
||||
member = localMember;
|
||||
if (!localMember) {
|
||||
member = new RoomMember(null, resourceId);
|
||||
this.doProfileLookup(resourceId, member);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case Pill.TYPE_ROOM_MENTION: {
|
||||
const localRoom = resourceId[0] === '#' ?
|
||||
MatrixClientPeg.get().getRooms().find((r) => {
|
||||
return r.getAliases().includes(resourceId);
|
||||
}) : MatrixClientPeg.get().getRoom(resourceId);
|
||||
room = localRoom;
|
||||
if (!localRoom) {
|
||||
// TODO: This would require a new API to resolve a room alias to
|
||||
// a room avatar and name.
|
||||
// this.doRoomProfileLookup(resourceId, member);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
this.setState({resourceId, pillType, member, room});
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
},
|
||||
|
||||
doProfileLookup: function(userId, member) {
|
||||
MatrixClientPeg.get().getProfileInfo(userId).then((resp) => {
|
||||
if (this._unmounted) {
|
||||
return;
|
||||
}
|
||||
member.name = resp.displayname;
|
||||
member.rawDisplayName = resp.displayname;
|
||||
member.events.member = {
|
||||
getContent: () => {
|
||||
return {avatar_url: resp.avatar_url};
|
||||
},
|
||||
};
|
||||
this.setState({member});
|
||||
}).catch((err) => {
|
||||
console.error('Could not retrieve profile data for ' + userId + ':', err);
|
||||
});
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
|
||||
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
|
||||
|
||||
const resource = this.state.resourceId;
|
||||
|
||||
let avatar = null;
|
||||
let linkText = resource;
|
||||
let pillClass;
|
||||
let userId;
|
||||
switch (this.state.pillType) {
|
||||
case Pill.TYPE_USER_MENTION: {
|
||||
// If this user is not a member of this room, default to the empty member
|
||||
const member = this.state.member;
|
||||
if (member) {
|
||||
userId = member.userId;
|
||||
linkText = member.name;
|
||||
avatar = <MemberAvatar member={member} width={16} height={16}/>;
|
||||
pillClass = 'mx_UserPill';
|
||||
}
|
||||
}
|
||||
break;
|
||||
case Pill.TYPE_ROOM_MENTION: {
|
||||
const room = this.state.room;
|
||||
if (room) {
|
||||
linkText = (room ? getDisplayAliasForRoom(room) : null) || resource;
|
||||
avatar = <RoomAvatar room={room} width={16} height={16}/>;
|
||||
pillClass = 'mx_RoomPill';
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const classes = classNames(pillClass, {
|
||||
"mx_UserPill_me": userId === MatrixClientPeg.get().credentials.userId,
|
||||
});
|
||||
|
||||
if (this.state.pillType) {
|
||||
return this.props.inMessage ?
|
||||
<a className={classes} href={this.props.url} title={resource} data-offset-key={this.props.offsetKey}>
|
||||
{avatar}
|
||||
{linkText}
|
||||
</a> :
|
||||
<span className={classes} title={resource} data-offset-key={this.props.offsetKey}>
|
||||
{avatar}
|
||||
{linkText}
|
||||
</span>;
|
||||
} else {
|
||||
// Deliberately render nothing if the URL isn't recognised
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default Pill;
|
|
@ -170,56 +170,21 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
pillifyLinks: function(nodes) {
|
||||
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
|
||||
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
if (node.tagName === "A" && node.getAttribute("href")) {
|
||||
const href = node.getAttribute("href");
|
||||
// HtmlUtils transforms `matrix.to` links to local links, so match against
|
||||
// user or room app links.
|
||||
const match = /^#\/(user|room)\/(.*)$/.exec(href) || [];
|
||||
const resourceType = match[1]; // "user" or "room"
|
||||
const resourceId = match[2]; // user ID or room ID
|
||||
if (match && resourceType && resourceId) {
|
||||
let avatar;
|
||||
let roomId;
|
||||
let room;
|
||||
let member;
|
||||
let userId;
|
||||
switch (resourceType) {
|
||||
case "user":
|
||||
roomId = this.props.mxEvent.getRoomId();
|
||||
room = MatrixClientPeg.get().getRoom(roomId);
|
||||
userId = resourceId;
|
||||
member = room.getMember(userId) ||
|
||||
new RoomMember(null, userId);
|
||||
avatar = <MemberAvatar member={member} width={16} height={16} name={userId}/>;
|
||||
break;
|
||||
case "room":
|
||||
room = resourceId[0] === '#' ?
|
||||
MatrixClientPeg.get().getRooms().find((r) => {
|
||||
return r.getCanonicalAlias() === resourceId;
|
||||
}) : MatrixClientPeg.get().getRoom(resourceId);
|
||||
if (room) {
|
||||
avatar = <RoomAvatar room={room} width={16} height={16}/>;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (avatar) {
|
||||
const avatarContainer = document.createElement('span');
|
||||
node.className = classNames(
|
||||
"mx_MTextBody_pill",
|
||||
{
|
||||
"mx_UserPill": match[1] === "user",
|
||||
"mx_RoomPill": match[1] === "room",
|
||||
"mx_UserPill_me":
|
||||
userId === MatrixClientPeg.get().credentials.userId,
|
||||
},
|
||||
);
|
||||
ReactDOM.render(avatar, avatarContainer);
|
||||
node.insertBefore(avatarContainer, node.firstChild);
|
||||
}
|
||||
|
||||
// If the link is a (localised) matrix.to link, replace it with a pill
|
||||
const Pill = sdk.getComponent('elements.Pill');
|
||||
if (Pill.isMessagePillUrl(href)) {
|
||||
const pillContainer = document.createElement('span');
|
||||
|
||||
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
|
||||
const pill = <Pill url={href} inMessage={true} room={room}/>;
|
||||
|
||||
ReactDOM.render(pill, pillContainer);
|
||||
node.parentNode.replaceChild(pillContainer, node);
|
||||
}
|
||||
} else if (node.children && node.children.length) {
|
||||
this.pillifyLinks(node.children);
|
||||
|
|
|
@ -26,7 +26,6 @@ import Promise from 'bluebird';
|
|||
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
|
||||
import {RoomMember} from 'matrix-js-sdk';
|
||||
import SlashCommands from '../../../SlashCommands';
|
||||
import KeyCode from '../../../KeyCode';
|
||||
import Modal from '../../../Modal';
|
||||
|
@ -43,10 +42,10 @@ import {Completion} from "../../../autocomplete/Autocompleter";
|
|||
import Markdown from '../../../Markdown';
|
||||
import ComposerHistoryManager from '../../../ComposerHistoryManager';
|
||||
import MessageComposerStore from '../../../stores/MessageComposerStore';
|
||||
import { getDisplayAliasForRoom } from '../../../Rooms';
|
||||
|
||||
import {MATRIXTO_URL_PATTERN} from '../../../linkify-matrix';
|
||||
import {MATRIXTO_URL_PATTERN, MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-matrix';
|
||||
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
|
||||
const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g');
|
||||
|
||||
import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione';
|
||||
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
|
||||
|
@ -187,62 +186,16 @@ export default class MessageComposerInput extends React.Component {
|
|||
RichText.getScopedMDDecorators(this.props);
|
||||
decorators.push({
|
||||
strategy: this.findLinkEntities.bind(this),
|
||||
component: (props) => {
|
||||
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
|
||||
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
|
||||
const {url} = Entity.get(props.entityKey).getData();
|
||||
|
||||
// Default to the empty array if no match for simplicity
|
||||
// resource and prefix will be undefined instead of throwing
|
||||
const matrixToMatch = REGEX_MATRIXTO.exec(url) || [];
|
||||
|
||||
const resource = matrixToMatch[1]; // The room/user ID
|
||||
const prefix = matrixToMatch[2]; // The first character of prefix
|
||||
|
||||
// Default to the room/user ID
|
||||
let linkText = resource;
|
||||
|
||||
const isUserPill = prefix === '@';
|
||||
const isRoomPill = prefix === '#' || prefix === '!';
|
||||
|
||||
const classes = classNames({
|
||||
"mx_UserPill": isUserPill,
|
||||
"mx_RoomPill": isRoomPill,
|
||||
});
|
||||
|
||||
let avatar = null;
|
||||
if (isUserPill) {
|
||||
// If this user is not a member of this room, default to the empty
|
||||
// member. This could be improved by doing an async profile lookup.
|
||||
const member = this.props.room.getMember(resource) ||
|
||||
new RoomMember(null, resource);
|
||||
|
||||
linkText = member.name;
|
||||
|
||||
avatar = member ? <MemberAvatar member={member} width={16} height={16}/> : null;
|
||||
} else if (isRoomPill) {
|
||||
const room = prefix === '#' ?
|
||||
MatrixClientPeg.get().getRooms().find((r) => {
|
||||
return r.getCanonicalAlias() === resource;
|
||||
}) : MatrixClientPeg.get().getRoom(resource);
|
||||
|
||||
linkText = getDisplayAliasForRoom(room) || resource;
|
||||
|
||||
avatar = room ? <RoomAvatar room={room} width={16} height={16}/> : null;
|
||||
}
|
||||
|
||||
if (isUserPill || isRoomPill) {
|
||||
return (
|
||||
<span className={classes}>
|
||||
{avatar}
|
||||
{linkText}
|
||||
</span>
|
||||
);
|
||||
component: (entityProps) => {
|
||||
const Pill = sdk.getComponent('elements.Pill');
|
||||
const {url} = Entity.get(entityProps.entityKey).getData();
|
||||
if (Pill.isPillUrl(url)) {
|
||||
return <Pill url={url} room={this.props.room} offsetKey={entityProps.offsetKey}/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={url}>
|
||||
{props.children}
|
||||
<a href={url} data-offset-key={entityProps.offsetKey}>
|
||||
{entityProps.children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
@ -778,6 +731,35 @@ export default class MessageComposerInput extends React.Component {
|
|||
sendTextFn = this.client.sendEmoteMessage;
|
||||
}
|
||||
|
||||
// Strip MD user (tab-completed) mentions to preserve plaintext mention behaviour
|
||||
contentText = contentText.replace(REGEX_MATRIXTO_MARKDOWN_GLOBAL,
|
||||
(markdownLink, text, resource, prefix, offset) => {
|
||||
// Calculate the offset relative to the current block that the offset is in
|
||||
let sum = 0;
|
||||
const blocks = contentState.getBlocksAsArray();
|
||||
let block;
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
block = blocks[i];
|
||||
sum += block.getLength();
|
||||
if (sum > offset) {
|
||||
sum -= block.getLength();
|
||||
break;
|
||||
}
|
||||
}
|
||||
offset -= sum;
|
||||
|
||||
const entityKey = block.getEntityAt(offset);
|
||||
const entity = entityKey ? Entity.get(entityKey) : null;
|
||||
if (entity && entity.getData().isCompletion && prefix === '@') {
|
||||
// This is a completed mention, so do not insert MD link, just text
|
||||
return text;
|
||||
} else {
|
||||
// This is either a MD link that was typed into the composer or another
|
||||
// type of pill (e.g. room pill)
|
||||
return markdownLink;
|
||||
}
|
||||
});
|
||||
|
||||
let sendMessagePromise;
|
||||
if (contentHTML) {
|
||||
sendMessagePromise = sendHtmlFn.call(
|
||||
|
@ -941,7 +923,10 @@ export default class MessageComposerInput extends React.Component {
|
|||
let entityKey;
|
||||
let mdCompletion;
|
||||
if (href) {
|
||||
entityKey = Entity.create('LINK', 'IMMUTABLE', {url: href});
|
||||
entityKey = Entity.create('LINK', 'IMMUTABLE', {
|
||||
url: href,
|
||||
isCompletion: true,
|
||||
});
|
||||
if (!this.state.isRichtextEnabled) {
|
||||
mdCompletion = `[${completion}](${href})`;
|
||||
}
|
||||
|
|
|
@ -958,5 +958,8 @@
|
|||
"Featured Rooms:": "Featured Rooms:",
|
||||
"Error whilst fetching joined groups": "Error whilst fetching joined groups",
|
||||
"Featured Users:": "Featured Users:",
|
||||
"Automatically replace plain text Emoji": "Automatically replace plain text Emoji"
|
||||
"Edit Group": "Edit Group",
|
||||
"Automatically replace plain text Emoji": "Automatically replace plain text Emoji",
|
||||
"Failed to upload image": "Failed to upload image",
|
||||
"Failed to update group": "Failed to update group"
|
||||
}
|
||||
|
|
|
@ -168,6 +168,8 @@ matrixLinkify.VECTOR_URL_PATTERN = "^(?:https?:\/\/)?(?:"
|
|||
+ ")(#.*)";
|
||||
|
||||
matrixLinkify.MATRIXTO_URL_PATTERN = "^(?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!).*)";
|
||||
matrixLinkify.MATRIXTO_MD_LINK_PATTERN =
|
||||
'\\[([^\\]]*)\\]\\((?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!)[^\\)]*)\\)';
|
||||
matrixLinkify.MATRIXTO_BASE_URL= "https://matrix.to";
|
||||
|
||||
matrixLinkify.options = {
|
||||
|
|
|
@ -9,6 +9,7 @@ import sdk from 'matrix-react-sdk';
|
|||
import UserSettingsStore from '../../../../src/UserSettingsStore';
|
||||
const MessageComposerInput = sdk.getComponent('views.rooms.MessageComposerInput');
|
||||
import MatrixClientPeg from '../../../../src/MatrixClientPeg';
|
||||
import RoomMember from 'matrix-js-sdk';
|
||||
|
||||
function addTextToDraft(text) {
|
||||
const components = document.getElementsByClassName('public-DraftEditor-content');
|
||||
|
@ -31,6 +32,7 @@ describe('MessageComposerInput', () => {
|
|||
testUtils.beforeEach(this);
|
||||
sandbox = testUtils.stubClient(sandbox);
|
||||
client = MatrixClientPeg.get();
|
||||
client.credentials = {userId: '@me:domain.com'};
|
||||
|
||||
parentDiv = document.createElement('div');
|
||||
document.body.appendChild(parentDiv);
|
||||
|
@ -236,4 +238,68 @@ describe('MessageComposerInput', () => {
|
|||
expect(spy.calledOnce).toEqual(true);
|
||||
expect(spy.args[0][1]).toEqual('Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\nFusce congue sapien sed neque molestie volutpat.');
|
||||
});
|
||||
|
||||
it('should strip tab-completed mentions so that only the display name is sent in the plain body in Markdown mode', () => {
|
||||
// Sending a HTML message because we have entities in the composer (because of completions)
|
||||
const spy = sinon.spy(client, 'sendHtmlMessage');
|
||||
mci.enableRichtext(false);
|
||||
mci.setDisplayedCompletion({
|
||||
completion: 'Some Member',
|
||||
selection: mci.state.editorState.getSelection(),
|
||||
href: `https://matrix.to/#/@some_member:domain.bla`,
|
||||
});
|
||||
|
||||
mci.handleReturn(sinon.stub());
|
||||
|
||||
expect(spy.args[0][1]).toEqual(
|
||||
'Some Member',
|
||||
'the plaintext body should only include the display name',
|
||||
);
|
||||
expect(spy.args[0][2]).toEqual(
|
||||
'<a href="https://matrix.to/#/@some_member:domain.bla">Some Member</a>',
|
||||
'the html body should contain an anchor tag with a matrix.to href and display name text',
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip tab-completed mentions so that only the display name is sent in the plain body in RTE mode', () => {
|
||||
// Sending a HTML message because we have entities in the composer (because of completions)
|
||||
const spy = sinon.spy(client, 'sendHtmlMessage');
|
||||
mci.enableRichtext(true);
|
||||
mci.setDisplayedCompletion({
|
||||
completion: 'Some Member',
|
||||
selection: mci.state.editorState.getSelection(),
|
||||
href: `https://matrix.to/#/@some_member:domain.bla`,
|
||||
});
|
||||
|
||||
mci.handleReturn(sinon.stub());
|
||||
|
||||
expect(spy.args[0][1]).toEqual('Some Member');
|
||||
expect(spy.args[0][2]).toEqual('<a href="https://matrix.to/#/@some_member:domain.bla">Some Member</a>');
|
||||
});
|
||||
|
||||
it('should not strip non-tab-completed mentions when manually typing MD', () => {
|
||||
// Sending a HTML message because we have entities in the composer (because of completions)
|
||||
const spy = sinon.spy(client, 'sendHtmlMessage');
|
||||
// Markdown mode enabled
|
||||
mci.enableRichtext(false);
|
||||
addTextToDraft('[My Not-Tab-Completed Mention](https://matrix.to/#/@some_member:domain.bla)');
|
||||
|
||||
mci.handleReturn(sinon.stub());
|
||||
|
||||
expect(spy.args[0][1]).toEqual('[My Not-Tab-Completed Mention](https://matrix.to/#/@some_member:domain.bla)');
|
||||
expect(spy.args[0][2]).toEqual('<a href="https://matrix.to/#/@some_member:domain.bla">My Not-Tab-Completed Mention</a>');
|
||||
});
|
||||
|
||||
it('should not strip arbitrary typed (i.e. not tab-completed) MD links', () => {
|
||||
// Sending a HTML message because we have entities in the composer (because of completions)
|
||||
const spy = sinon.spy(client, 'sendHtmlMessage');
|
||||
// Markdown mode enabled
|
||||
mci.enableRichtext(false);
|
||||
addTextToDraft('[Click here](https://some.lovely.url)');
|
||||
|
||||
mci.handleReturn(sinon.stub());
|
||||
|
||||
expect(spy.args[0][1]).toEqual('[Click here](https://some.lovely.url)');
|
||||
expect(spy.args[0][2]).toEqual('<a href="https://some.lovely.url">Click here</a>');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -238,7 +238,12 @@ export function mkStubRoom(roomId = null) {
|
|||
return {
|
||||
roomId,
|
||||
getReceiptsForEvent: sinon.stub().returns([]),
|
||||
getMember: sinon.stub().returns({}),
|
||||
getMember: sinon.stub().returns({
|
||||
userId: '@member:domain.bla',
|
||||
name: 'Member',
|
||||
roomId: roomId,
|
||||
getAvatarUrl: () => 'mxc://avatar.url/image.png',
|
||||
}),
|
||||
getJoinedMembers: sinon.stub().returns([]),
|
||||
getPendingEvents: () => [],
|
||||
getLiveTimeline: () => stubTimeline,
|
||||
|
|
Loading…
Reference in a new issue