Merge pull request #2997 from matrix-org/bwindels/pill-avatars

Message editing: render avatars for pills in the editor
This commit is contained in:
Bruno Windels 2019-05-21 12:11:06 +00:00 committed by GitHub
commit 2d4d608ed6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 198 additions and 97 deletions

View file

@ -21,6 +21,7 @@ limitations under the License.
// padding around and in the editor.
// Actual values from fiddling around in inspector
margin: -7px -10px -5px -10px;
overflow: visible !important; // override mx_EventTile_content
.mx_MessageEditor_editor {
border-radius: 4px;
@ -33,20 +34,28 @@ limitations under the License.
max-height: 200px;
overflow-x: auto;
span {
display: inline-block;
padding: 0 5px;
border-radius: 4px;
color: white;
}
span.mx_UserPill, span.mx_RoomPill {
padding-left: 21px;
position: relative;
span.user-pill, span.room-pill {
border-radius: 16px;
display: inline-block;
color: $primary-fg-color;
background-color: $other-user-pill-bg-color;
padding-left: 5px;
padding-right: 5px;
// avatar psuedo element
&::before {
position: absolute;
left: 2px;
top: 2px;
content: var(--avatar-letter);
width: 16px;
height: 16px;
background: var(--avatar-background), $avatar-bg-color;
color: $avatar-initial-color;
background-repeat: no-repeat;
background-size: 16px;
border-radius: 8px;
text-align: center;
font-weight: normal;
line-height: 16px;
font-size: 10.4px;
}
}
}
@ -61,7 +70,7 @@ limitations under the License.
z-index: 100;
right: 0;
margin: 0 -110px 0 0;
padding-right: 104px;
padding-right: 147px;
.mx_AccessibleButton {
margin-left: 5px;

View file

@ -40,7 +40,12 @@ limitations under the License.
}
.mx_EventTile_continuation {
padding-top: 0px ! important;
padding-top: 0px !important;
&.mx_EventTile_isEditing {
padding-top: 5px !important;
margin-top: -5px;
}
}
.mx_EventTile_isEditing {

View file

@ -17,6 +17,7 @@ limitations under the License.
'use strict';
import {ContentRepo} from 'matrix-js-sdk';
import MatrixClientPeg from './MatrixClientPeg';
import DMRoomMap from './utils/DMRoomMap';
module.exports = {
avatarUrlForMember: function(member, width, height, resizeMethod) {
@ -58,4 +59,71 @@ module.exports = {
}
return require('../res/img/' + images[total % images.length] + '.png');
},
/**
* returns the first (non-sigil) character of 'name',
* converted to uppercase
* @param {string} name
* @return {string} the first letter
*/
getInitialLetter(name) {
if (name.length < 1) {
return undefined;
}
let idx = 0;
const initial = name[0];
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
idx++;
}
// string.codePointAt(0) would do this, but that isn't supported by
// some browsers (notably PhantomJS).
let chars = 1;
const first = name.charCodeAt(idx);
// check if its the start of a surrogate pair
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
const second = name.charCodeAt(idx+1);
if (second >= 0xDC00 && second <= 0xDFFF) {
chars++;
}
}
const firstChar = name.substring(idx, idx+chars);
return firstChar.toUpperCase();
},
avatarUrlForRoom(room, width, height, resizeMethod) {
const explicitRoomAvatar = room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
if (explicitRoomAvatar) {
return explicitRoomAvatar;
}
let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (otherUserId) {
otherMember = room.getMember(otherUserId);
} else {
// if the room is not marked as a 1:1, but only has max 2 members
// then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember();
}
if (otherMember) {
return otherMember.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
}
return null;
},
};

View file

@ -133,38 +133,6 @@ module.exports = React.createClass({
}
},
/**
* returns the first (non-sigil) character of 'name',
* converted to uppercase
*/
_getInitialLetter: function(name) {
if (name.length < 1) {
return undefined;
}
let idx = 0;
const initial = name[0];
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
idx++;
}
// string.codePointAt(0) would do this, but that isn't supported by
// some browsers (notably PhantomJS).
let chars = 1;
const first = name.charCodeAt(idx);
// check if its the start of a surrogate pair
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
const second = name.charCodeAt(idx+1);
if (second >= 0xDC00 && second <= 0xDFFF) {
chars++;
}
}
const firstChar = name.substring(idx, idx+chars);
return firstChar.toUpperCase();
},
render: function() {
const imageUrl = this.state.imageUrls[this.state.urlsIndex];
@ -175,7 +143,7 @@ module.exports = React.createClass({
} = this.props;
if (imageUrl === this.state.defaultImageUrl) {
const initialLetter = this._getInitialLetter(name);
const initialLetter = AvatarLogic.getInitialLetter(name);
const textNode = (
<span className="mx_BaseAvatar_initial" aria-hidden="true"
style={{ fontSize: (width * 0.65) + "px",

View file

@ -19,7 +19,7 @@ import {ContentRepo} from "matrix-js-sdk";
import MatrixClientPeg from "../../../MatrixClientPeg";
import Modal from '../../../Modal';
import sdk from "../../../index";
import DMRoomMap from '../../../utils/DMRoomMap';
import Avatar from '../../../Avatar';
module.exports = React.createClass({
displayName: 'RoomAvatar',
@ -89,7 +89,6 @@ module.exports = React.createClass({
props.resizeMethod,
), // highest priority
this.getRoomAvatarUrl(props),
this.getOneToOneAvatar(props), // lowest priority
].filter(function(url) {
return (url != null && url != "");
});
@ -98,41 +97,14 @@ module.exports = React.createClass({
getRoomAvatarUrl: function(props) {
if (!props.room) return null;
return props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
return Avatar.avatarUrlForRoom(
props.room,
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
);
},
getOneToOneAvatar: function(props) {
const room = props.room;
if (!room) {
return null;
}
let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (otherUserId) {
otherMember = room.getMember(otherUserId);
} else {
// if the room is not marked as a 1:1, but only has max 2 members
// then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember();
}
if (otherMember) {
return otherMember.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
);
}
return null;
},
onRoomAvatarClick: function() {
const avatarUrl = this.props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),

View file

@ -27,6 +27,7 @@ import Autocomplete from '../rooms/Autocomplete';
import {PartCreator} from '../../../editor/parts';
import {renderModel} from '../../../editor/render';
import {MatrixEvent, MatrixClient} from 'matrix-js-sdk';
import classNames from 'classnames';
export default class MessageEditor extends React.Component {
static propTypes = {
@ -40,16 +41,17 @@ export default class MessageEditor extends React.Component {
constructor(props, context) {
super(props, context);
const room = this.context.matrixClient.getRoom(this.props.event.getRoomId());
const partCreator = new PartCreator(
() => this._autocompleteRef,
query => this.setState({query}),
room,
);
this.model = new EditorModel(
parseEvent(this.props.event),
parseEvent(this.props.event, room),
partCreator,
this._updateEditorState,
);
const room = this.context.matrixClient.getRoom(this.props.event.getRoomId());
this.state = {
autoComplete: null,
room,
@ -176,7 +178,7 @@ export default class MessageEditor extends React.Component {
</div>;
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <div className="mx_MessageEditor">
return <div className={classNames("mx_MessageEditor", this.props.className)}>
{ autoComplete }
<div
className="mx_MessageEditor_editor"

View file

@ -471,7 +471,7 @@ module.exports = React.createClass({
render: function() {
if (this.props.isEditing) {
const MessageEditor = sdk.getComponent('elements.MessageEditor');
return <MessageEditor event={this.props.mxEvent} />;
return <MessageEditor event={this.props.mxEvent} className="mx_EventTile_content" />;
}
const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent();

View file

@ -17,11 +17,12 @@ limitations under the License.
import {UserPillPart, RoomPillPart, PlainPart} from "./parts";
export default class AutocompleteWrapperModel {
constructor(updateCallback, getAutocompleterComponent, updateQuery) {
constructor(updateCallback, getAutocompleterComponent, updateQuery, room) {
this._updateCallback = updateCallback;
this._getAutocompleterComponent = getAutocompleterComponent;
this._updateQuery = updateQuery;
this._query = null;
this._room = room;
}
onEscape(e) {
@ -83,7 +84,8 @@ export default class AutocompleteWrapperModel {
case "@": {
const displayName = completion.completion;
const userId = completion.completionId;
return new UserPillPart(userId, displayName);
const member = this._room.getMember(userId);
return new UserPillPart(userId, displayName, member);
}
case "#": {
const displayAlias = completion.completionId;

View file

@ -17,7 +17,7 @@ limitations under the License.
import { MATRIXTO_URL_PATTERN } from '../linkify-matrix';
import { PlainPart, UserPillPart, RoomPillPart, NewlinePart } from "./parts";
function parseHtmlMessage(html) {
function parseHtmlMessage(html, room) {
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
// no nodes from parsing here should be inserted in the document,
// as scripts in event handlers, etc would be executed then.
@ -37,8 +37,8 @@ function parseHtmlMessage(html) {
const resourceId = pillMatch[1]; // The room/user ID
const prefix = pillMatch[2]; // The first character of prefix
switch (prefix) {
case "@": return new UserPillPart(resourceId, n.textContent);
case "#": return new RoomPillPart(resourceId, n.textContent);
case "@": return new UserPillPart(resourceId, n.textContent, room.getMember(resourceId));
case "#": return new RoomPillPart(resourceId);
default: return new PlainPart(n.textContent);
}
}
@ -54,10 +54,10 @@ function parseHtmlMessage(html) {
return parts;
}
export function parseEvent(event) {
export function parseEvent(event, room) {
const content = event.getContent();
if (content.format === "org.matrix.custom.html") {
return parseHtmlMessage(content.formatted_body || "");
return parseHtmlMessage(content.formatted_body || "", room);
} else {
const body = content.body || "";
const lines = body.split("\n");

View file

@ -15,6 +15,8 @@ limitations under the License.
*/
import AutocompleteWrapperModel from "./autocomplete";
import Avatar from "../Avatar";
import MatrixClientPeg from "../MatrixClientPeg";
class BasePart {
constructor(text = "") {
@ -150,21 +152,21 @@ class PillPart extends BasePart {
toDOMNode() {
const container = document.createElement("span");
container.className = this.type;
container.className = this.className;
container.appendChild(document.createTextNode(this.text));
this.setAvatar(container);
return container;
}
updateDOMNode(node) {
const textNode = node.childNodes[0];
if (textNode.textContent !== this.text) {
// console.log("changing pill text from", textNode.textContent, "to", this.text);
textNode.textContent = this.text;
}
if (node.className !== this.type) {
// console.log("turning", node.className, "into", this.type);
node.className = this.type;
if (node.className !== this.className) {
node.className = this.className;
}
this.setAvatar(node);
}
canUpdateDOMNode(node) {
@ -174,6 +176,20 @@ class PillPart extends BasePart {
node.childNodes[0].nodeType === Node.TEXT_NODE;
}
// helper method for subclasses
_setAvatarVars(node, avatarUrl, initialLetter) {
const avatarBackground = `url('${avatarUrl}')`;
const avatarLetter = `'${initialLetter}'`;
// check if the value is changing,
// otherwise the avatars flicker on every keystroke while updating.
if (node.style.getPropertyValue("--avatar-background") !== avatarBackground) {
node.style.setProperty("--avatar-background", avatarBackground);
}
if (node.style.getPropertyValue("--avatar-letter") !== avatarLetter) {
node.style.setProperty("--avatar-letter", avatarLetter);
}
}
get canEdit() {
return false;
}
@ -218,17 +234,71 @@ export class NewlinePart extends BasePart {
export class RoomPillPart extends PillPart {
constructor(displayAlias) {
super(displayAlias, displayAlias);
this._room = this._findRoomByAlias(displayAlias);
}
_findRoomByAlias(alias) {
const client = MatrixClientPeg.get();
if (alias[0] === '#') {
return client.getRooms().find((r) => {
return r.getAliases().includes(alias);
});
} else {
return client.getRoom(alias);
}
}
setAvatar(node) {
let initialLetter = "";
let avatarUrl = Avatar.avatarUrlForRoom(this._room, 16 * window.devicePixelRatio, 16 * window.devicePixelRatio);
if (!avatarUrl) {
initialLetter = Avatar.getInitialLetter(this._room.name);
avatarUrl = `../../${Avatar.defaultAvatarUrlForString(this._room.roomId)}`;
}
this._setAvatarVars(node, avatarUrl, initialLetter);
}
get type() {
return "room-pill";
}
get className() {
return "mx_RoomPill mx_Pill";
}
}
export class UserPillPart extends PillPart {
constructor(userId, displayName, member) {
super(userId, displayName);
this._member = member;
}
setAvatar(node) {
const name = this._member.name || this._member.userId;
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this._member.userId);
let avatarUrl = Avatar.avatarUrlForMember(
this._member,
16 * window.devicePixelRatio,
16 * window.devicePixelRatio);
let initialLetter = "";
if (avatarUrl === defaultAvatarUrl) {
// the url from defaultAvatarUrlForString is meant to go in an img element,
// which has the base of the document. we're using it in css,
// which has the base of the theme css file, two levels deeper than the document,
// so go up to the level of the document.
avatarUrl = `../../${avatarUrl}`;
initialLetter = Avatar.getInitialLetter(name);
}
this._setAvatarVars(node, avatarUrl, initialLetter);
}
get type() {
return "user-pill";
}
get className() {
return "mx_UserPill mx_Pill";
}
}
@ -256,9 +326,14 @@ export class PillCandidatePart extends PlainPart {
}
export class PartCreator {
constructor(getAutocompleterComponent, updateQuery) {
constructor(getAutocompleterComponent, updateQuery, room) {
this._autoCompleteCreator = (updateCallback) => {
return new AutocompleteWrapperModel(updateCallback, getAutocompleterComponent, updateQuery);
return new AutocompleteWrapperModel(
updateCallback,
getAutocompleterComponent,
updateQuery,
room,
);
};
}