Merge pull request #3108 from matrix-org/bwindels/edit-room-notif-pill

Support @room pills while editing
This commit is contained in:
Bruno Windels 2019-06-18 16:43:14 +00:00 committed by GitHub
commit 63fba611c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 136 additions and 93 deletions

View file

@ -233,7 +233,7 @@ export default class MessageEditor extends React.Component {
parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p)); parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p));
} else { } else {
// otherwise, parse the body of the event // otherwise, parse the body of the event
parts = parseEvent(editState.getEvent(), room, this.context.matrixClient); parts = parseEvent(editState.getEvent(), partCreator);
} }
return new EditorModel( return new EditorModel(

View file

@ -214,7 +214,13 @@ module.exports = React.createClass({
// update the current node with one that's now taken its place // update the current node with one that's now taken its place
node = pillContainer; node = pillContainer;
} }
} else if (node.nodeType === Node.TEXT_NODE) { } else if (
node.nodeType === Node.TEXT_NODE &&
// as applying pills happens outside of react, make sure we're not doubly
// applying @room pills here, as a rerender with the same content won't touch the DOM
// to clear the pills from the last run of pillifyLinks
!node.parentElement.classList.contains("mx_AtRoomPill")
) {
const Pill = sdk.getComponent('elements.Pill'); const Pill = sdk.getComponent('elements.Pill');
let currentTextNode = node; let currentTextNode = node;

View file

@ -15,22 +15,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {UserPillPart, RoomPillPart, PlainPart} from "./parts";
export default class AutocompleteWrapperModel { export default class AutocompleteWrapperModel {
constructor(updateCallback, getAutocompleterComponent, updateQuery, room, client) { constructor(updateCallback, getAutocompleterComponent, updateQuery, partCreator) {
this._updateCallback = updateCallback; this._updateCallback = updateCallback;
this._getAutocompleterComponent = getAutocompleterComponent; this._getAutocompleterComponent = getAutocompleterComponent;
this._updateQuery = updateQuery; this._updateQuery = updateQuery;
this._partCreator = partCreator;
this._query = null; this._query = null;
this._room = room;
this._client = client;
} }
onEscape(e) { onEscape(e) {
this._getAutocompleterComponent().onEscape(e); this._getAutocompleterComponent().onEscape(e);
this._updateCallback({ this._updateCallback({
replacePart: new PlainPart(this._queryPart.text), replacePart: this._partCreator.plain(this._queryPart.text),
caretOffset: this._queryOffset, caretOffset: this._queryOffset,
close: true, close: true,
}); });
@ -93,21 +90,22 @@ export default class AutocompleteWrapperModel {
} }
_partForCompletion(completion) { _partForCompletion(completion) {
const firstChr = completion.completionId && completion.completionId[0]; const {completionId} = completion;
const text = completion.completion;
const firstChr = completionId && completionId[0];
switch (firstChr) { switch (firstChr) {
case "@": { case "@": {
const displayName = completion.completion; if (completionId === "@room") {
const userId = completion.completionId; return this._partCreator.atRoomPill(completionId);
const member = this._room.getMember(userId); } else {
return new UserPillPart(userId, displayName, member); return this._partCreator.userPill(text, completionId);
} }
case "#": {
const displayAlias = completion.completionId;
return new RoomPillPart(displayAlias, this._client);
} }
case "#":
return this._partCreator.roomPill(completionId);
// also used for emoji completion // also used for emoji completion
default: default:
return new PlainPart(completion.completion); return this._partCreator.plain(text);
} }
} }
} }

View file

@ -16,73 +16,86 @@ limitations under the License.
*/ */
import { MATRIXTO_URL_PATTERN } from '../linkify-matrix'; import { MATRIXTO_URL_PATTERN } from '../linkify-matrix';
import { PlainPart, UserPillPart, RoomPillPart, NewlinePart } from "./parts";
import { walkDOMDepthFirst } from "./dom"; import { walkDOMDepthFirst } from "./dom";
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
function parseLink(a, room, client) { function parseAtRoomMentions(text, partCreator) {
const ATROOM = "@room";
const parts = [];
text.split(ATROOM).forEach((textPart, i, arr) => {
if (textPart.length) {
parts.push(partCreator.plain(textPart));
}
// it's safe to never append @room after the last textPart
// as split will report an empty string at the end if
// `text` ended in @room.
const isLast = i === arr.length - 1;
if (!isLast) {
parts.push(partCreator.atRoomPill(ATROOM));
}
});
return parts;
}
function parseLink(a, partCreator) {
const {href} = a; const {href} = a;
const pillMatch = REGEX_MATRIXTO.exec(href) || []; const pillMatch = REGEX_MATRIXTO.exec(href) || [];
const resourceId = pillMatch[1]; // The room/user ID const resourceId = pillMatch[1]; // The room/user ID
const prefix = pillMatch[2]; // The first character of prefix const prefix = pillMatch[2]; // The first character of prefix
switch (prefix) { switch (prefix) {
case "@": case "@":
return new UserPillPart( return partCreator.userPill(a.textContent, resourceId);
resourceId,
a.textContent,
room.getMember(resourceId),
);
case "#": case "#":
return new RoomPillPart(resourceId, client); return partCreator.roomPill(resourceId);
default: { default: {
if (href === a.textContent) { if (href === a.textContent) {
return new PlainPart(a.textContent); return partCreator.plain(a.textContent);
} else { } else {
return new PlainPart(`[${a.textContent}](${href})`); return partCreator.plain(`[${a.textContent}](${href})`);
} }
} }
} }
} }
function parseCodeBlock(n) { function parseCodeBlock(n, partCreator) {
const parts = []; const parts = [];
const preLines = ("```\n" + n.textContent + "```").split("\n"); const preLines = ("```\n" + n.textContent + "```").split("\n");
preLines.forEach((l, i) => { preLines.forEach((l, i) => {
parts.push(new PlainPart(l)); parts.push(partCreator.plain(l));
if (i < preLines.length - 1) { if (i < preLines.length - 1) {
parts.push(new NewlinePart("\n")); parts.push(partCreator.newline());
} }
}); });
return parts; return parts;
} }
function parseElement(n, room, client) { function parseElement(n, partCreator) {
switch (n.nodeName) { switch (n.nodeName) {
case "A": case "A":
return parseLink(n, room, client); return parseLink(n, partCreator);
case "BR": case "BR":
return new NewlinePart("\n"); return partCreator.newline();
case "EM": case "EM":
return new PlainPart(`*${n.textContent}*`); return partCreator.plain(`*${n.textContent}*`);
case "STRONG": case "STRONG":
return new PlainPart(`**${n.textContent}**`); return partCreator.plain(`**${n.textContent}**`);
case "PRE": case "PRE":
return parseCodeBlock(n); return parseCodeBlock(n, partCreator);
case "CODE": case "CODE":
return new PlainPart(`\`${n.textContent}\``); return partCreator.plain(`\`${n.textContent}\``);
case "DEL": case "DEL":
return new PlainPart(`<del>${n.textContent}</del>`); return partCreator.plain(`<del>${n.textContent}</del>`);
case "LI": case "LI":
if (n.parentElement.nodeName === "OL") { if (n.parentElement.nodeName === "OL") {
return new PlainPart(` 1. `); return partCreator.plain(` 1. `);
} else { } else {
return new PlainPart(` - `); return partCreator.plain(` - `);
} }
default: default:
// don't textify block nodes we'll decend into // don't textify block nodes we'll decend into
if (!checkDecendInto(n)) { if (!checkDecendInto(n)) {
return new PlainPart(n.textContent); return partCreator.plain(n.textContent);
} }
} }
} }
@ -125,22 +138,22 @@ function checkIgnored(n) {
return true; return true;
} }
function prefixQuoteLines(isFirstNode, parts) { function prefixQuoteLines(isFirstNode, parts, partCreator) {
const PREFIX = "> "; const PREFIX = "> ";
// a newline (to append a > to) wouldn't be added to parts for the first line // a newline (to append a > to) wouldn't be added to parts for the first line
// if there was no content before the BLOCKQUOTE, so handle that // if there was no content before the BLOCKQUOTE, so handle that
if (isFirstNode) { if (isFirstNode) {
parts.splice(0, 0, new PlainPart(PREFIX)); parts.splice(0, 0, partCreator.plain(PREFIX));
} }
for (let i = 0; i < parts.length; i += 1) { for (let i = 0; i < parts.length; i += 1) {
if (parts[i].type === "newline") { if (parts[i].type === "newline") {
parts.splice(i + 1, 0, new PlainPart(PREFIX)); parts.splice(i + 1, 0, partCreator.plain(PREFIX));
i += 1; i += 1;
} }
} }
} }
function parseHtmlMessage(html, room, client) { function parseHtmlMessage(html, partCreator) {
// no nodes from parsing here should be inserted in the document, // no nodes from parsing here should be inserted in the document,
// as scripts in event handlers, etc would be executed then. // as scripts in event handlers, etc would be executed then.
// we're only taking text, so that is fine // we're only taking text, so that is fine
@ -159,13 +172,13 @@ function parseHtmlMessage(html, room, client) {
const newParts = []; const newParts = [];
if (lastNode && (checkBlockNode(lastNode) || checkBlockNode(n))) { if (lastNode && (checkBlockNode(lastNode) || checkBlockNode(n))) {
newParts.push(new NewlinePart("\n")); newParts.push(partCreator.newline());
} }
if (n.nodeType === Node.TEXT_NODE) { if (n.nodeType === Node.TEXT_NODE) {
newParts.push(new PlainPart(n.nodeValue)); newParts.push(...parseAtRoomMentions(n.nodeValue, partCreator));
} else if (n.nodeType === Node.ELEMENT_NODE) { } else if (n.nodeType === Node.ELEMENT_NODE) {
const parseResult = parseElement(n, room, client); const parseResult = parseElement(n, partCreator);
if (parseResult) { if (parseResult) {
if (Array.isArray(parseResult)) { if (Array.isArray(parseResult)) {
newParts.push(...parseResult); newParts.push(...parseResult);
@ -177,14 +190,14 @@ function parseHtmlMessage(html, room, client) {
if (newParts.length && inQuote) { if (newParts.length && inQuote) {
const isFirstPart = parts.length === 0; const isFirstPart = parts.length === 0;
prefixQuoteLines(isFirstPart, newParts); prefixQuoteLines(isFirstPart, newParts, partCreator);
} }
parts.push(...newParts); parts.push(...newParts);
// extra newline after quote, only if there something behind it... // extra newline after quote, only if there something behind it...
if (lastNode && lastNode.nodeName === "BLOCKQUOTE") { if (lastNode && lastNode.nodeName === "BLOCKQUOTE") {
parts.push(new NewlinePart("\n")); parts.push(partCreator.newline());
} }
lastNode = null; lastNode = null;
return checkDecendInto(n); return checkDecendInto(n);
@ -205,27 +218,25 @@ function parseHtmlMessage(html, room, client) {
return parts; return parts;
} }
export function parseEvent(event, room, client) { export function parseEvent(event, partCreator) {
const content = event.getContent(); const content = event.getContent();
let parts; let parts;
if (content.format === "org.matrix.custom.html") { if (content.format === "org.matrix.custom.html") {
parts = parseHtmlMessage(content.formatted_body || "", room, client); parts = parseHtmlMessage(content.formatted_body || "", partCreator);
} else { } else {
const body = content.body || ""; const body = content.body || "";
const lines = body.split("\n"); const lines = body.split("\n");
parts = lines.reduce((parts, line, i) => { parts = lines.reduce((parts, line, i) => {
const isLast = i === lines.length - 1; const isLast = i === lines.length - 1;
const text = new PlainPart(line); const newParts = parseAtRoomMentions(line, partCreator);
const newLine = !isLast && new NewlinePart("\n"); if (!isLast) {
if (newLine) { newParts.push(partCreator.newline());
return parts.concat(text, newLine);
} else {
return parts.concat(text);
} }
return parts.concat(newParts);
}, []); }, []);
} }
if (content.msgtype === "m.emote") { if (content.msgtype === "m.emote") {
parts.unshift(new PlainPart("/me ")); parts.unshift(partCreator.plain("/me "));
} }
return parts; return parts;
} }

View file

@ -107,7 +107,7 @@ class BasePart {
} }
} }
export class PlainPart extends BasePart { class PlainPart extends BasePart {
acceptsInsertion(chr) { acceptsInsertion(chr) {
return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n"; return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n";
} }
@ -199,7 +199,7 @@ class PillPart extends BasePart {
} }
} }
export class NewlinePart extends BasePart { class NewlinePart extends BasePart {
acceptsInsertion(chr, i) { acceptsInsertion(chr, i) {
return (this.text.length + i) === 0 && chr === "\n"; return (this.text.length + i) === 0 && chr === "\n";
} }
@ -235,20 +235,10 @@ export class NewlinePart extends BasePart {
} }
} }
export class RoomPillPart extends PillPart { class RoomPillPart extends PillPart {
constructor(displayAlias, client) { constructor(displayAlias, room) {
super(displayAlias, displayAlias); super(displayAlias, displayAlias);
this._room = this._findRoomByAlias(displayAlias, client); this._room = room;
}
_findRoomByAlias(alias, client) {
if (alias[0] === '#') {
return client.getRooms().find((r) => {
return r.getAliases().includes(alias);
});
} else {
return client.getRoom(alias);
}
} }
setAvatar(node) { setAvatar(node) {
@ -270,7 +260,13 @@ export class RoomPillPart extends PillPart {
} }
} }
export class UserPillPart extends PillPart { class AtRoomPillPart extends RoomPillPart {
get type() {
return "at-room-pill";
}
}
class UserPillPart extends PillPart {
constructor(userId, displayName, member) { constructor(userId, displayName, member) {
super(userId, displayName); super(userId, displayName);
this._member = member; this._member = member;
@ -311,7 +307,7 @@ export class UserPillPart extends PillPart {
} }
export class PillCandidatePart extends PlainPart { class PillCandidatePart extends PlainPart {
constructor(text, autoCompleteCreator) { constructor(text, autoCompleteCreator) {
super(text); super(text);
this._autoCompleteCreator = autoCompleteCreator; this._autoCompleteCreator = autoCompleteCreator;
@ -351,8 +347,7 @@ export class PartCreator {
updateCallback, updateCallback,
getAutocompleterComponent, getAutocompleterComponent,
updateQuery, updateQuery,
room, this,
client,
); );
}; };
} }
@ -362,7 +357,7 @@ export class PartCreator {
case "#": case "#":
case "@": case "@":
case ":": case ":":
return new PillCandidatePart("", this._autoCompleteCreator); return this.pillCandidate("");
case "\n": case "\n":
return new NewlinePart(); return new NewlinePart();
default: default:
@ -371,24 +366,57 @@ export class PartCreator {
} }
createDefaultPart(text) { createDefaultPart(text) {
return new PlainPart(text); return this.plain(text);
} }
deserializePart(part) { deserializePart(part) {
switch (part.type) { switch (part.type) {
case "plain": case "plain":
return new PlainPart(part.text); return this.plain(part.text);
case "newline": case "newline":
return new NewlinePart(part.text); return this.newline();
case "at-room-pill":
return this.atRoomPill(part.text);
case "pill-candidate": case "pill-candidate":
return new PillCandidatePart(part.text, this._autoCompleteCreator); return this.pillCandidate(part.text);
case "room-pill": case "room-pill":
return new RoomPillPart(part.text, this._client); return this.roomPill(part.text);
case "user-pill": { case "user-pill":
const member = this._room.getMember(part.userId); return this.userPill(part.text, part.userId);
return new UserPillPart(part.userId, part.text, member);
}
} }
} }
plain(text) {
return new PlainPart(text);
}
newline() {
return new NewlinePart("\n");
}
pillCandidate(text) {
return new PillCandidatePart(text, this._autoCompleteCreator);
}
roomPill(alias) {
let room;
if (alias[0] === '#') {
room = this._client.getRooms().find((r) => {
return r.getAliases().includes(alias);
});
} else {
room = this._client.getRoom(alias);
}
return new RoomPillPart(alias, room);
}
atRoomPill(text) {
return new AtRoomPillPart(text, this._room);
}
userPill(displayName, userId) {
const member = this._room.getMember(userId);
return new UserPillPart(userId, displayName, member);
}
} }

View file

@ -24,6 +24,7 @@ export function mdSerialize(model) {
return html + "\n"; return html + "\n";
case "plain": case "plain":
case "pill-candidate": case "pill-candidate":
case "at-room-pill":
return html + part.text; return html + part.text;
case "room-pill": case "room-pill":
case "user-pill": case "user-pill":
@ -47,6 +48,7 @@ export function textSerialize(model) {
return text + "\n"; return text + "\n";
case "plain": case "plain":
case "pill-candidate": case "pill-candidate":
case "at-room-pill":
return text + part.text; return text + part.text;
case "room-pill": case "room-pill":
case "user-pill": case "user-pill":
@ -58,13 +60,11 @@ export function textSerialize(model) {
export function requiresHtml(model) { export function requiresHtml(model) {
return model.parts.some(part => { return model.parts.some(part => {
switch (part.type) { switch (part.type) {
case "newline":
case "plain":
case "pill-candidate":
return false;
case "room-pill": case "room-pill":
case "user-pill": case "user-pill":
return true; return true;
default:
return false;
} }
}); });
} }