diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js
index 9faae4588b..9f5265cfd3 100644
--- a/src/components/views/elements/MessageEditor.js
+++ b/src/components/views/elements/MessageEditor.js
@@ -233,7 +233,7 @@ export default class MessageEditor extends React.Component {
parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p));
} else {
// otherwise, parse the body of the event
- parts = parseEvent(editState.getEvent(), room, this.context.matrixClient);
+ parts = parseEvent(editState.getEvent(), partCreator);
}
return new EditorModel(
diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js
index 6f480b8d3c..d76956d193 100644
--- a/src/components/views/messages/TextualBody.js
+++ b/src/components/views/messages/TextualBody.js
@@ -214,7 +214,13 @@ module.exports = React.createClass({
// update the current node with one that's now taken its place
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');
let currentTextNode = node;
diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js
index 6cb5974729..2aedf8d7f5 100644
--- a/src/editor/autocomplete.js
+++ b/src/editor/autocomplete.js
@@ -15,22 +15,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import {UserPillPart, RoomPillPart, PlainPart} from "./parts";
-
export default class AutocompleteWrapperModel {
- constructor(updateCallback, getAutocompleterComponent, updateQuery, room, client) {
+ constructor(updateCallback, getAutocompleterComponent, updateQuery, partCreator) {
this._updateCallback = updateCallback;
this._getAutocompleterComponent = getAutocompleterComponent;
this._updateQuery = updateQuery;
+ this._partCreator = partCreator;
this._query = null;
- this._room = room;
- this._client = client;
}
onEscape(e) {
this._getAutocompleterComponent().onEscape(e);
this._updateCallback({
- replacePart: new PlainPart(this._queryPart.text),
+ replacePart: this._partCreator.plain(this._queryPart.text),
caretOffset: this._queryOffset,
close: true,
});
@@ -93,21 +90,22 @@ export default class AutocompleteWrapperModel {
}
_partForCompletion(completion) {
- const firstChr = completion.completionId && completion.completionId[0];
+ const {completionId} = completion;
+ const text = completion.completion;
+ const firstChr = completionId && completionId[0];
switch (firstChr) {
case "@": {
- const displayName = completion.completion;
- const userId = completion.completionId;
- const member = this._room.getMember(userId);
- return new UserPillPart(userId, displayName, member);
- }
- case "#": {
- const displayAlias = completion.completionId;
- return new RoomPillPart(displayAlias, this._client);
+ if (completionId === "@room") {
+ return this._partCreator.atRoomPill(completionId);
+ } else {
+ return this._partCreator.userPill(text, completionId);
+ }
}
+ case "#":
+ return this._partCreator.roomPill(completionId);
// also used for emoji completion
default:
- return new PlainPart(completion.completion);
+ return this._partCreator.plain(text);
}
}
}
diff --git a/src/editor/deserialize.js b/src/editor/deserialize.js
index 2d98bbc41a..7e4e82affe 100644
--- a/src/editor/deserialize.js
+++ b/src/editor/deserialize.js
@@ -16,73 +16,86 @@ limitations under the License.
*/
import { MATRIXTO_URL_PATTERN } from '../linkify-matrix';
-import { PlainPart, UserPillPart, RoomPillPart, NewlinePart } from "./parts";
import { walkDOMDepthFirst } from "./dom";
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 pillMatch = REGEX_MATRIXTO.exec(href) || [];
const resourceId = pillMatch[1]; // The room/user ID
const prefix = pillMatch[2]; // The first character of prefix
switch (prefix) {
case "@":
- return new UserPillPart(
- resourceId,
- a.textContent,
- room.getMember(resourceId),
- );
+ return partCreator.userPill(a.textContent, resourceId);
case "#":
- return new RoomPillPart(resourceId, client);
+ return partCreator.roomPill(resourceId);
default: {
if (href === a.textContent) {
- return new PlainPart(a.textContent);
+ return partCreator.plain(a.textContent);
} else {
- return new PlainPart(`[${a.textContent}](${href})`);
+ return partCreator.plain(`[${a.textContent}](${href})`);
}
}
}
}
-function parseCodeBlock(n) {
+function parseCodeBlock(n, partCreator) {
const parts = [];
const preLines = ("```\n" + n.textContent + "```").split("\n");
preLines.forEach((l, i) => {
- parts.push(new PlainPart(l));
+ parts.push(partCreator.plain(l));
if (i < preLines.length - 1) {
- parts.push(new NewlinePart("\n"));
+ parts.push(partCreator.newline());
}
});
return parts;
}
-function parseElement(n, room, client) {
+function parseElement(n, partCreator) {
switch (n.nodeName) {
case "A":
- return parseLink(n, room, client);
+ return parseLink(n, partCreator);
case "BR":
- return new NewlinePart("\n");
+ return partCreator.newline();
case "EM":
- return new PlainPart(`*${n.textContent}*`);
+ return partCreator.plain(`*${n.textContent}*`);
case "STRONG":
- return new PlainPart(`**${n.textContent}**`);
+ return partCreator.plain(`**${n.textContent}**`);
case "PRE":
- return parseCodeBlock(n);
+ return parseCodeBlock(n, partCreator);
case "CODE":
- return new PlainPart(`\`${n.textContent}\``);
+ return partCreator.plain(`\`${n.textContent}\``);
case "DEL":
- return new PlainPart(`${n.textContent}`);
+ return partCreator.plain(`${n.textContent}`);
case "LI":
if (n.parentElement.nodeName === "OL") {
- return new PlainPart(` 1. `);
+ return partCreator.plain(` 1. `);
} else {
- return new PlainPart(` - `);
+ return partCreator.plain(` - `);
}
default:
// don't textify block nodes we'll decend into
if (!checkDecendInto(n)) {
- return new PlainPart(n.textContent);
+ return partCreator.plain(n.textContent);
}
}
}
@@ -125,22 +138,22 @@ function checkIgnored(n) {
return true;
}
-function prefixQuoteLines(isFirstNode, parts) {
+function prefixQuoteLines(isFirstNode, parts, partCreator) {
const PREFIX = "> ";
// 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 (isFirstNode) {
- parts.splice(0, 0, new PlainPart(PREFIX));
+ parts.splice(0, 0, partCreator.plain(PREFIX));
}
for (let i = 0; i < parts.length; i += 1) {
if (parts[i].type === "newline") {
- parts.splice(i + 1, 0, new PlainPart(PREFIX));
+ parts.splice(i + 1, 0, partCreator.plain(PREFIX));
i += 1;
}
}
}
-function parseHtmlMessage(html, room, client) {
+function parseHtmlMessage(html, partCreator) {
// no nodes from parsing here should be inserted in the document,
// as scripts in event handlers, etc would be executed then.
// we're only taking text, so that is fine
@@ -159,13 +172,13 @@ function parseHtmlMessage(html, room, client) {
const newParts = [];
if (lastNode && (checkBlockNode(lastNode) || checkBlockNode(n))) {
- newParts.push(new NewlinePart("\n"));
+ newParts.push(partCreator.newline());
}
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) {
- const parseResult = parseElement(n, room, client);
+ const parseResult = parseElement(n, partCreator);
if (parseResult) {
if (Array.isArray(parseResult)) {
newParts.push(...parseResult);
@@ -177,14 +190,14 @@ function parseHtmlMessage(html, room, client) {
if (newParts.length && inQuote) {
const isFirstPart = parts.length === 0;
- prefixQuoteLines(isFirstPart, newParts);
+ prefixQuoteLines(isFirstPart, newParts, partCreator);
}
parts.push(...newParts);
// extra newline after quote, only if there something behind it...
if (lastNode && lastNode.nodeName === "BLOCKQUOTE") {
- parts.push(new NewlinePart("\n"));
+ parts.push(partCreator.newline());
}
lastNode = null;
return checkDecendInto(n);
@@ -205,27 +218,25 @@ function parseHtmlMessage(html, room, client) {
return parts;
}
-export function parseEvent(event, room, client) {
+export function parseEvent(event, partCreator) {
const content = event.getContent();
let parts;
if (content.format === "org.matrix.custom.html") {
- parts = parseHtmlMessage(content.formatted_body || "", room, client);
+ parts = parseHtmlMessage(content.formatted_body || "", partCreator);
} else {
const body = content.body || "";
const lines = body.split("\n");
parts = lines.reduce((parts, line, i) => {
const isLast = i === lines.length - 1;
- const text = new PlainPart(line);
- const newLine = !isLast && new NewlinePart("\n");
- if (newLine) {
- return parts.concat(text, newLine);
- } else {
- return parts.concat(text);
+ const newParts = parseAtRoomMentions(line, partCreator);
+ if (!isLast) {
+ newParts.push(partCreator.newline());
}
+ return parts.concat(newParts);
}, []);
}
if (content.msgtype === "m.emote") {
- parts.unshift(new PlainPart("/me "));
+ parts.unshift(partCreator.plain("/me "));
}
return parts;
}
diff --git a/src/editor/parts.js b/src/editor/parts.js
index a122c7ab7a..dc2c1e69a2 100644
--- a/src/editor/parts.js
+++ b/src/editor/parts.js
@@ -107,7 +107,7 @@ class BasePart {
}
}
-export class PlainPart extends BasePart {
+class PlainPart extends BasePart {
acceptsInsertion(chr) {
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) {
return (this.text.length + i) === 0 && chr === "\n";
}
@@ -235,20 +235,10 @@ export class NewlinePart extends BasePart {
}
}
-export class RoomPillPart extends PillPart {
- constructor(displayAlias, client) {
+class RoomPillPart extends PillPart {
+ constructor(displayAlias, room) {
super(displayAlias, displayAlias);
- this._room = this._findRoomByAlias(displayAlias, client);
- }
-
- _findRoomByAlias(alias, client) {
- if (alias[0] === '#') {
- return client.getRooms().find((r) => {
- return r.getAliases().includes(alias);
- });
- } else {
- return client.getRoom(alias);
- }
+ this._room = room;
}
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) {
super(userId, displayName);
this._member = member;
@@ -311,7 +307,7 @@ export class UserPillPart extends PillPart {
}
-export class PillCandidatePart extends PlainPart {
+class PillCandidatePart extends PlainPart {
constructor(text, autoCompleteCreator) {
super(text);
this._autoCompleteCreator = autoCompleteCreator;
@@ -351,8 +347,7 @@ export class PartCreator {
updateCallback,
getAutocompleterComponent,
updateQuery,
- room,
- client,
+ this,
);
};
}
@@ -362,7 +357,7 @@ export class PartCreator {
case "#":
case "@":
case ":":
- return new PillCandidatePart("", this._autoCompleteCreator);
+ return this.pillCandidate("");
case "\n":
return new NewlinePart();
default:
@@ -371,24 +366,57 @@ export class PartCreator {
}
createDefaultPart(text) {
- return new PlainPart(text);
+ return this.plain(text);
}
deserializePart(part) {
switch (part.type) {
case "plain":
- return new PlainPart(part.text);
+ return this.plain(part.text);
case "newline":
- return new NewlinePart(part.text);
+ return this.newline();
+ case "at-room-pill":
+ return this.atRoomPill(part.text);
case "pill-candidate":
- return new PillCandidatePart(part.text, this._autoCompleteCreator);
+ return this.pillCandidate(part.text);
case "room-pill":
- return new RoomPillPart(part.text, this._client);
- case "user-pill": {
- const member = this._room.getMember(part.userId);
- return new UserPillPart(part.userId, part.text, member);
- }
+ return this.roomPill(part.text);
+ case "user-pill":
+ return this.userPill(part.text, part.userId);
}
}
+
+ 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);
+ }
}
diff --git a/src/editor/serialize.js b/src/editor/serialize.js
index 73fbbe5d01..876130074c 100644
--- a/src/editor/serialize.js
+++ b/src/editor/serialize.js
@@ -24,6 +24,7 @@ export function mdSerialize(model) {
return html + "\n";
case "plain":
case "pill-candidate":
+ case "at-room-pill":
return html + part.text;
case "room-pill":
case "user-pill":
@@ -47,6 +48,7 @@ export function textSerialize(model) {
return text + "\n";
case "plain":
case "pill-candidate":
+ case "at-room-pill":
return text + part.text;
case "room-pill":
case "user-pill":
@@ -58,13 +60,11 @@ export function textSerialize(model) {
export function requiresHtml(model) {
return model.parts.some(part => {
switch (part.type) {
- case "newline":
- case "plain":
- case "pill-candidate":
- return false;
case "room-pill":
case "user-pill":
return true;
+ default:
+ return false;
}
});
}