Merge branch 'develop' into luke/store-history-as-raw-content

This commit is contained in:
Luke Barnard 2017-07-25 17:19:35 +01:00
commit fc00eaf546
12 changed files with 554 additions and 176 deletions

View file

@ -34,12 +34,9 @@ export type Completion = {
component: ?Component, component: ?Component,
range: SelectionRange, range: SelectionRange,
command: ?string, command: ?string,
// An entity applied during the replacement (using draftjs@0.8.1 Entity.create) // If provided, apply a LINK entity to the completion with the
entity: ? { // data = { url: href }.
type: string, href: ?string,
mutability: string,
data: ?Object,
},
}; };
const PROVIDERS = [ const PROVIDERS = [

View file

@ -22,6 +22,8 @@ import dis from '../../dispatcher';
import { sanitizedHtmlNode } from '../../HtmlUtils'; import { sanitizedHtmlNode } from '../../HtmlUtils';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import AccessibleButton from '../views/elements/AccessibleButton'; import AccessibleButton from '../views/elements/AccessibleButton';
import Modal from '../../Modal';
import classnames from 'classnames';
const RoomSummaryType = PropTypes.shape({ const RoomSummaryType = PropTypes.shape({
room_id: PropTypes.string.isRequired, room_id: PropTypes.string.isRequired,
@ -179,10 +181,13 @@ export default React.createClass({
summary: null, summary: null,
error: null, error: null,
editing: false, editing: false,
saving: false,
uploadingAvatar: false,
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
this._changeAvatarComponent = null;
this._loadGroupFromServer(this.props.groupId); this._loadGroupFromServer(this.props.groupId);
}, },
@ -211,8 +216,83 @@ export default React.createClass({
}); });
}, },
_onSettingsClick: function() { _onEditClick: function() {
this.setState({editing: true}); 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() { _getFeaturedRoomsNode() {
@ -296,60 +376,129 @@ export default React.createClass({
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
const TintableSvg = sdk.getComponent("elements.TintableSvg"); 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 />; return <Loader />;
} else if (this.state.editing) {
return <div />;
} else if (this.state.summary) { } else if (this.state.summary) {
const summary = this.state.summary; const summary = this.state.summary;
let description = null;
if (summary.profile && summary.profile.long_description) { let avatarNode;
description = sanitizedHtmlNode(summary.profile.long_description); let nameNode;
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'
/>;
} }
const roomBody = <div> avatarNode = (
<div className="mx_GroupView_groupDesc">{description}</div> <div className="mx_GroupView_avatarPicker">
{this._getFeaturedRoomsNode()} <label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label">
{this._getFeaturedUsersNode()} {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>; </div>;
} else {
let nameNode; 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) { if (summary.profile && summary.profile.name) {
nameNode = <div className="mx_RoomHeader_name"> nameNode = <div>
<span>{summary.profile.name}</span> <span>{summary.profile.name}</span>
<span className="mx_GroupView_header_groupid"> <span className="mx_GroupView_header_groupid">
({this.props.groupId}) ({this.props.groupId})
</span> </span>
</div>; </div>;
} else { } else {
nameNode = <div className="mx_RoomHeader_name"> nameNode = <span>{this.props.groupId}</span>;
<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>; </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 ( return (
<div className="mx_GroupView"> <div className="mx_GroupView">
<div className="mx_RoomHeader"> <div className={classnames(headerClasses)}>
<div className="mx_RoomHeader_wrapper"> <div className="mx_GroupView_header_leftCol">
<div className="mx_RoomHeader_avatar"> <div className="mx_GroupView_header_avatar">
<GroupAvatar {avatarNode}
groupId={this.props.groupId}
groupAvatarUrl={groupAvatarUrl}
width={48} height={48}
/>
</div> </div>
<div className="mx_RoomHeader_info"> <div className="mx_GroupView_header_info">
<div className="mx_GroupView_header_name">
{nameNode} {nameNode}
<div className="mx_RoomHeader_topic"> </div>
{summary.profile.short_description} <div className="mx_GroupView_header_shortDesc">
{shortDescNode}
</div> </div>
</div> </div>
<AccessibleButton className="mx_RoomHeader_button" onClick={this._onSettingsClick} title={_t("Settings")} style={{display: 'none'}}> </div>
<TintableSvg src="img/icons-settings-room.svg" width="16" height="16"/> <div className="mx_GroupView_header_rightCol">
</AccessibleButton> {rightButtons}
</div> </div>
</div> </div>
{roomBody} {roomBody}

View file

@ -307,6 +307,7 @@ export default React.createClass({
page_element = <GroupView page_element = <GroupView
groupId={this.props.currentGroupId} groupId={this.props.currentGroupId}
/>; />;
//right_panel = <RightPanel userId={this.props.viewUserId} opacity={this.props.rightOpacity} />;
break; break;
} }

View file

@ -50,6 +50,7 @@ export default React.createClass({
loading: false, loading: false,
widgetUrl: this.props.url, widgetUrl: this.props.url,
error: null, error: null,
deleting: false,
}; };
}, },
@ -101,6 +102,7 @@ export default React.createClass({
_onDeleteClick: function() { _onDeleteClick: function() {
console.log("Delete widget %s", this.props.id); console.log("Delete widget %s", this.props.id);
this.setState({deleting: true});
MatrixClientPeg.get().sendStateEvent( MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId, this.props.room.roomId,
'im.vector.modular.widgets', 'im.vector.modular.widgets',
@ -110,6 +112,7 @@ export default React.createClass({
console.log('Deleted widget'); console.log('Deleted widget');
}, (e) => { }, (e) => {
console.error('Failed to delete widget', e); console.error('Failed to delete widget', e);
this.setState({deleting: false});
}); });
}, },
@ -124,6 +127,12 @@ export default React.createClass({
render: function() { render: function() {
let appTileBody; let appTileBody;
// Don't render widget if it is in the process of being deleted
if (this.state.deleting) {
return <div></div>;
}
if (this.state.loading) { if (this.state.loading) {
appTileBody = ( appTileBody = (
<div> Loading... </div> <div> Loading... </div>

View 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;

View file

@ -170,56 +170,21 @@ module.exports = React.createClass({
}, },
pillifyLinks: function(nodes) { pillifyLinks: function(nodes) {
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
for (let i = 0; i < nodes.length; i++) { for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]; const node = nodes[i];
if (node.tagName === "A" && node.getAttribute("href")) { if (node.tagName === "A" && node.getAttribute("href")) {
const href = node.getAttribute("href"); const href = node.getAttribute("href");
// HtmlUtils transforms `matrix.to` links to local links, so match against
// user or room app links. // If the link is a (localised) matrix.to link, replace it with a pill
const match = /^#\/(user|room)\/(.*)$/.exec(href) || []; const Pill = sdk.getComponent('elements.Pill');
const resourceType = match[1]; // "user" or "room" if (Pill.isMessagePillUrl(href)) {
const resourceId = match[2]; // user ID or room ID const pillContainer = document.createElement('span');
if (match && resourceType && resourceId) {
let avatar; const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
let roomId; const pill = <Pill url={href} inMessage={true} room={room}/>;
let room;
let member; ReactDOM.render(pill, pillContainer);
let userId; node.parentNode.replaceChild(pillContainer, node);
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);
}
} }
} else if (node.children && node.children.length) { } else if (node.children && node.children.length) {
this.pillifyLinks(node.children); this.pillifyLinks(node.children);
@ -297,10 +262,9 @@ module.exports = React.createClass({
onEmoteSenderClick: function(event) { onEmoteSenderClick: function(event) {
const mxEvent = this.props.mxEvent; const mxEvent = this.props.mxEvent;
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
dis.dispatch({ dis.dispatch({
action: 'insert_displayname', action: 'insert_mention',
displayname: name.replace(' (IRC)', ''), user_id: mxEvent.getSender(),
}); });
}, },

View file

@ -357,10 +357,10 @@ module.exports = withMatrixClient(React.createClass({
}, },
onSenderProfileClick: function(event) { onSenderProfileClick: function(event) {
var mxEvent = this.props.mxEvent; const mxEvent = this.props.mxEvent;
dis.dispatch({ dis.dispatch({
action: 'insert_displayname', action: 'insert_mention',
displayname: (mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()).replace(' (IRC)', ''), user_id: mxEvent.getSender(),
}); });
}, },

View file

@ -26,7 +26,6 @@ import Promise from 'bluebird';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
import {RoomMember} from 'matrix-js-sdk';
import SlashCommands from '../../../SlashCommands'; import SlashCommands from '../../../SlashCommands';
import KeyCode from '../../../KeyCode'; import KeyCode from '../../../KeyCode';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
@ -43,10 +42,10 @@ import {Completion} from "../../../autocomplete/Autocompleter";
import Markdown from '../../../Markdown'; import Markdown from '../../../Markdown';
import ComposerHistoryManager from '../../../ComposerHistoryManager'; import ComposerHistoryManager from '../../../ComposerHistoryManager';
import MessageComposerStore from '../../../stores/MessageComposerStore'; 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 = 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'; import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione';
const EMOJI_SHORTNAMES = Object.keys(emojioneList); const EMOJI_SHORTNAMES = Object.keys(emojioneList);
@ -187,62 +186,16 @@ export default class MessageComposerInput extends React.Component {
RichText.getScopedMDDecorators(this.props); RichText.getScopedMDDecorators(this.props);
decorators.push({ decorators.push({
strategy: this.findLinkEntities.bind(this), strategy: this.findLinkEntities.bind(this),
component: (props) => { component: (entityProps) => {
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); const Pill = sdk.getComponent('elements.Pill');
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); const {url} = Entity.get(entityProps.entityKey).getData();
const {url} = Entity.get(props.entityKey).getData(); if (Pill.isPillUrl(url)) {
return <Pill url={url} room={this.props.room} offsetKey={entityProps.offsetKey}/>;
// 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>
);
} }
return ( return (
<a href={url}> <a href={url} data-offset-key={entityProps.offsetKey}>
{props.children} {entityProps.children}
</a> </a>
); );
}, },
@ -285,22 +238,20 @@ export default class MessageComposerInput extends React.Component {
case 'focus_composer': case 'focus_composer':
editor.focus(); editor.focus();
break; break;
case 'insert_mention': {
// TODO change this so we insert a complete user alias // Pretend that we've autocompleted this user because keeping two code
// paths for inserting a user pill is not fun
case 'insert_displayname': { const selection = this.state.editorState.getSelection();
contentState = Modifier.replaceText( const member = this.props.room.getMember(payload.user_id);
contentState, const completion = member ? member.name.replace(' (IRC)', '') : payload.user_id;
this.state.editorState.getSelection(), this.setDisplayedCompletion({
`${payload.displayname}: `, completion,
); selection,
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); href: `https://matrix.to/#/${payload.user_id}`,
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); suffix: selection.getStartOffset() === 0 ? ': ' : ' ',
this.onEditorContentChanged(editorState); });
editor.focus();
} }
break; break;
case 'quote': { case 'quote': {
let {body, formatted_body} = payload.event.getContent(); let {body, formatted_body} = payload.event.getContent();
formatted_body = formatted_body || escape(body); formatted_body = formatted_body || escape(body);
@ -775,6 +726,35 @@ export default class MessageComposerInput extends React.Component {
sendTextFn = this.client.sendEmoteMessage; 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; let sendMessagePromise;
if (contentHTML) { if (contentHTML) {
sendMessagePromise = sendHtmlFn.call( sendMessagePromise = sendHtmlFn.call(
@ -933,21 +913,32 @@ export default class MessageComposerInput extends React.Component {
return false; return false;
} }
const {range = {}, completion = '', href = null, suffix = ''} = displayedCompletion; const {range = null, completion = '', href = null, suffix = ''} = displayedCompletion;
let entityKey; let entityKey;
let mdCompletion; let mdCompletion;
if (href) { if (href) {
entityKey = Entity.create('LINK', 'IMMUTABLE', {url: href}); entityKey = Entity.create('LINK', 'IMMUTABLE', {
url: href,
isCompletion: true,
});
if (!this.state.isRichtextEnabled) { if (!this.state.isRichtextEnabled) {
mdCompletion = `[${completion}](${href})`; mdCompletion = `[${completion}](${href})`;
} }
} }
let selection;
if (range) {
selection = RichText.textOffsetsToSelectionState(
range, activeEditorState.getCurrentContent().getBlocksAsArray(),
);
} else {
selection = activeEditorState.getSelection();
}
let contentState = Modifier.replaceText( let contentState = Modifier.replaceText(
activeEditorState.getCurrentContent(), activeEditorState.getCurrentContent(),
RichText.textOffsetsToSelectionState( selection,
range, activeEditorState.getCurrentContent().getBlocksAsArray(),
),
mdCompletion || completion, mdCompletion || completion,
null, null,
entityKey, entityKey,

View file

@ -956,5 +956,8 @@
"Featured Rooms:": "Featured Rooms:", "Featured Rooms:": "Featured Rooms:",
"Error whilst fetching joined groups": "Error whilst fetching joined groups", "Error whilst fetching joined groups": "Error whilst fetching joined groups",
"Featured Users:": "Featured Users:", "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"
} }

View file

@ -168,6 +168,8 @@ matrixLinkify.VECTOR_URL_PATTERN = "^(?:https?:\/\/)?(?:"
+ ")(#.*)"; + ")(#.*)";
matrixLinkify.MATRIXTO_URL_PATTERN = "^(?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!).*)"; 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.MATRIXTO_BASE_URL= "https://matrix.to";
matrixLinkify.options = { matrixLinkify.options = {

View file

@ -9,6 +9,7 @@ import sdk from 'matrix-react-sdk';
import UserSettingsStore from '../../../../src/UserSettingsStore'; import UserSettingsStore from '../../../../src/UserSettingsStore';
const MessageComposerInput = sdk.getComponent('views.rooms.MessageComposerInput'); const MessageComposerInput = sdk.getComponent('views.rooms.MessageComposerInput');
import MatrixClientPeg from '../../../../src/MatrixClientPeg'; import MatrixClientPeg from '../../../../src/MatrixClientPeg';
import RoomMember from 'matrix-js-sdk';
function addTextToDraft(text) { function addTextToDraft(text) {
const components = document.getElementsByClassName('public-DraftEditor-content'); const components = document.getElementsByClassName('public-DraftEditor-content');
@ -31,6 +32,7 @@ describe('MessageComposerInput', () => {
testUtils.beforeEach(this); testUtils.beforeEach(this);
sandbox = testUtils.stubClient(sandbox); sandbox = testUtils.stubClient(sandbox);
client = MatrixClientPeg.get(); client = MatrixClientPeg.get();
client.credentials = {userId: '@me:domain.com'};
parentDiv = document.createElement('div'); parentDiv = document.createElement('div');
document.body.appendChild(parentDiv); document.body.appendChild(parentDiv);
@ -236,4 +238,68 @@ describe('MessageComposerInput', () => {
expect(spy.calledOnce).toEqual(true); 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.'); 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>');
});
}); });

View file

@ -238,7 +238,12 @@ export function mkStubRoom(roomId = null) {
return { return {
roomId, roomId,
getReceiptsForEvent: sinon.stub().returns([]), 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([]), getJoinedMembers: sinon.stub().returns([]),
getPendingEvents: () => [], getPendingEvents: () => [],
getLiveTimeline: () => stubTimeline, getLiveTimeline: () => stubTimeline,