Merge pull request #3108 from matrix-org/bwindels/edit-room-notif-pill
Support @room pills while editingpull/21833/head
						commit
						63fba611c0
					
				|  | @ -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( | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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(`<del>${n.textContent}</del>`); | ||||
|             return partCreator.plain(`<del>${n.textContent}</del>`); | ||||
|         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; | ||||
| } | ||||
|  |  | |||
|  | @ -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); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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; | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 Bruno Windels
						Bruno Windels