mirror of
https://github.com/element-hq/element-web
synced 2024-11-22 17:25:50 +03:00
Merge pull request #2997 from matrix-org/bwindels/pill-avatars
Message editing: render avatars for pills in the editor
This commit is contained in:
commit
2d4d608ed6
10 changed files with 198 additions and 97 deletions
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 it’s 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;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 it’s 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",
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue