diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index cc5649a224..7cf96afb11 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -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; diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index e67d9d4107..72881231f8 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -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 { diff --git a/src/Avatar.js b/src/Avatar.js index 99b558fa93..2e15874b4e 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -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; + }, }; diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index 5a6c5ca581..db663e08a2 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -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", diff --git a/src/components/views/avatars/RoomAvatar.js b/src/components/views/avatars/RoomAvatar.js index 38f088238f..557a4d8dbf 100644 --- a/src/components/views/avatars/RoomAvatar.js +++ b/src/components/views/avatars/RoomAvatar.js @@ -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(), diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 0c249d067b..1f3440d740 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -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" diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 5356572bb9..c4eb6f8c7d 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -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(); diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index d2f73b1dff..731bb8d986 100644 --- a/src/editor/autocomplete.js +++ b/src/editor/autocomplete.js @@ -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; diff --git a/src/editor/deserialize.js b/src/editor/deserialize.js index a7f28badb1..569e166ab0 100644 --- a/src/editor/deserialize.js +++ b/src/editor/deserialize.js @@ -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"); diff --git a/src/editor/parts.js b/src/editor/parts.js index bf792b1ab9..1947be7d80 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -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, + ); }; }