text.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import URI from 'urijs';
  2. import log from '@converse/headless/log';
  3. import { _converse, api, converse } from '@converse/headless/core';
  4. import { containsDirectives, getDirectiveAndLength, getDirectiveTemplate, isQuoteDirective } from './styling.js';
  5. import { convertASCII2Emoji, getCodePointReferences, getEmojiMarkup, getShortnameReferences } from '@converse/headless/plugins/emoji/index.js';
  6. import { html } from 'lit-html';
  7. const u = converse.env.utils;
  8. const isString = (s) => typeof s === 'string';
  9. // We don't render more than two line-breaks, replace extra line-breaks with
  10. // the zero-width whitespace character
  11. const collapseLineBreaks = text => text.replace(/\n\n+/g, m => `\n${"\u200B".repeat(m.length-2)}\n`);
  12. const tpl_mention_with_nick = (o) => html`<span class="mention mention--self badge badge-info">${o.mention}</span>`;
  13. const tpl_mention = (o) => html`<span class="mention">${o.mention}</span>`;
  14. /**
  15. * @class MessageText
  16. * A String subclass that is used to represent the rich text
  17. * of a chat message.
  18. *
  19. * The "rich" parts of the text is represented by lit-html TemplateResult
  20. * objects which are added via the {@link MessageText.addTemplateResult}
  21. * method and saved as metadata.
  22. *
  23. * By default Converse adds TemplateResults to support emojis, hyperlinks,
  24. * images, map URIs and mentions.
  25. *
  26. * 3rd party plugins can listen for the `beforeMessageBodyTransformed`
  27. * and/or `afterMessageBodyTransformed` events and then call
  28. * `addTemplateResult` on the MessageText instance in order to add their own
  29. * rich features.
  30. */
  31. export class MessageText extends String {
  32. /**
  33. * Create a new {@link MessageText} instance.
  34. * @param { String } text - The text to be annotated
  35. * @param { Integer } offset - The offset of this particular piece of text
  36. * from the start of the original message text. This is necessary because
  37. * MessageText instances can be nested when templates call directives
  38. * which create new MessageText instances (as happens with XEP-393 styling directives).
  39. * @param { Array } mentions - An array of mention references
  40. * @param { Object } options
  41. * @param { Object } options.nick - The current user's nickname (only relevant if the message is in a XEP-0045 MUC)
  42. * @param { Boolean } options.render_styling - Whether XEP-0393 message styling should be applied to the message
  43. * @param { Boolean } options.show_images - Whether image URLs should be rendered as <img> tags.
  44. * @param { Function } options.onImgClick - Callback for when an inline rendered image has been clicked
  45. * @param { Function } options.onImgLoad - Callback for when an inline rendered image has been loaded
  46. */
  47. constructor (text, offset=0, mentions=[], options={}) {
  48. super(text);
  49. this.mentions = mentions;
  50. this.nick = options?.nick;
  51. this.offset = offset;
  52. this.onImgClick = options?.onImgClick;
  53. this.onImgLoad = options?.onImgLoad;
  54. this.options = options;
  55. this.payload = [];
  56. this.references = [];
  57. this.render_styling = options?.render_styling;
  58. this.show_images = options?.show_images;
  59. }
  60. /**
  61. * Look for `http` URIs and return templates that render them as URL links
  62. * @param { String } text
  63. * @param { Integer } offset - The index of the passed in text relative to
  64. * the start of the message body text.
  65. */
  66. addHyperlinks (text, offset) {
  67. const objs = [];
  68. try {
  69. const parse_options = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi };
  70. URI.withinString(text, (url, start, end) => {
  71. objs.push({url, start, end})
  72. return url;
  73. } , parse_options);
  74. } catch (error) {
  75. log.debug(error);
  76. return;
  77. }
  78. objs.forEach(url_obj => {
  79. const url_text = text.slice(url_obj.start, url_obj.end);
  80. const filtered_url = u.filterQueryParamsFromURL(url_text);
  81. this.addTemplateResult(
  82. url_obj.start+offset,
  83. url_obj.end+offset,
  84. this.show_images && u.isImageURL(url_text) && u.isImageDomainAllowed(url_text) ?
  85. u.convertToImageTag(filtered_url, this.onImgLoad, this.onImgClick) :
  86. u.convertUrlToHyperlink(filtered_url),
  87. );
  88. });
  89. }
  90. /**
  91. * Look for `geo` URIs and return templates that render them as URL links
  92. * @param { String } text
  93. * @param { Integer } offset - The index of the passed in text relative to
  94. * the start of the message body text.
  95. */
  96. addMapURLs (text, offset) {
  97. const regex = /geo:([\-0-9.]+),([\-0-9.]+)(?:,([\-0-9.]+))?(?:\?(.*))?/g;
  98. const matches = text.matchAll(regex);
  99. for (const m of matches) {
  100. this.addTemplateResult(
  101. m.index+offset,
  102. m.index+m[0].length+offset,
  103. u.convertUrlToHyperlink(m[0].replace(regex, _converse.geouri_replacement))
  104. );
  105. }
  106. }
  107. /**
  108. * Look for emojis (shortnames or unicode) and add templates for rendering them.
  109. * @param { String } text
  110. * @param { Integer } offset - The index of the passed in text relative to
  111. * the start of the message body text.
  112. */
  113. addEmojis (text, offset) {
  114. const references = [...getShortnameReferences(text.toString()), ...getCodePointReferences(text.toString())];
  115. references.forEach(e => {
  116. this.addTemplateResult(
  117. e.begin+offset,
  118. e.end+offset,
  119. getEmojiMarkup(e, {'add_title_wrapper': true})
  120. );
  121. });
  122. }
  123. /**
  124. * Look for mentions included as XEP-0372 references and add templates for
  125. * rendering them.
  126. * @param { String } text
  127. * @param { Integer } local_offset - The index of the passed in text relative to
  128. * the start of this MessageText instance (which is not necessarily the same as the
  129. * offset from the start of the original message stanza's body text).
  130. */
  131. addMentions (text, local_offset) {
  132. const full_offset = local_offset+this.offset;
  133. this.mentions?.forEach(ref => {
  134. const begin = Number(ref.begin)-full_offset;
  135. if (begin < 0 || begin >= full_offset+text.length) {
  136. return;
  137. }
  138. const end = Number(ref.end)-full_offset;
  139. const mention = text.slice(begin, end);
  140. if (mention === this.nick) {
  141. this.addTemplateResult(
  142. begin+local_offset,
  143. end+local_offset,
  144. tpl_mention_with_nick({mention})
  145. );
  146. } else {
  147. this.addTemplateResult(
  148. begin+local_offset,
  149. end+local_offset,
  150. tpl_mention({mention})
  151. );
  152. }
  153. });
  154. }
  155. /**
  156. * Look for XEP-0393 styling directives and add templates for rendering
  157. * them.
  158. */
  159. addStyling () {
  160. let i = 0;
  161. const references = [];
  162. if (containsDirectives(this)) {
  163. while (i < this.length) {
  164. const { d, length } = getDirectiveAndLength(this, i);
  165. if (d && length) {
  166. const is_quote = isQuoteDirective(d);
  167. const end = i+length;
  168. const slice_end = is_quote ? end : end-d.length;
  169. let slice_begin = d === '```' ? i+d.length+1 : i+d.length;
  170. if (is_quote && this[slice_begin] === ' ') {
  171. // Trim leading space inside codeblock
  172. slice_begin += 1;
  173. }
  174. const offset = slice_begin;
  175. const text = this.slice(slice_begin, slice_end);
  176. references.push({
  177. 'begin': i,
  178. 'template': getDirectiveTemplate(d, text, offset, this.mentions, this.options),
  179. end,
  180. });
  181. i = end;
  182. }
  183. i++;
  184. }
  185. }
  186. references.forEach(ref => this.addTemplateResult(ref.begin, ref.end, ref.template));
  187. }
  188. trimMeMessage () {
  189. if (this.offset === 0) {
  190. // Subtract `/me ` from 3rd person messages
  191. if (this.isMeCommand()) {
  192. this.payload[0] = this.payload[0].substring(4);
  193. }
  194. }
  195. }
  196. /**
  197. * Look for plaintext (i.e. non-templated) sections of this MessageText
  198. * instance and add references via the passed in function.
  199. * @param { Function } func
  200. */
  201. addAnnotations (func) {
  202. const payload = this.marshall();
  203. let idx = 0; // The text index of the element in the payload
  204. for (const text of payload) {
  205. if (!text) {
  206. continue
  207. } else if (isString(text)) {
  208. func.call(this, text, idx);
  209. idx += text.length;
  210. } else {
  211. idx = text.end;
  212. }
  213. }
  214. }
  215. /**
  216. * Parse the text and add template references for rendering the "rich" parts.
  217. *
  218. * @param { MessageText } text
  219. * @param { Boolean } show_images - Should URLs of images be rendered as `<img>` tags?
  220. * @param { Function } onImgLoad
  221. * @param { Function } onImgClick
  222. **/
  223. async addTemplates() {
  224. /**
  225. * Synchronous event which provides a hook for transforming a chat message's body text
  226. * before the default transformations have been applied.
  227. * @event _converse#beforeMessageBodyTransformed
  228. * @param { MessageText } text - A {@link MessageText } instance. You
  229. * can call {@link MessageText#addTemplateResult } on it in order to
  230. * add TemplateResult objects meant to render rich parts of the message.
  231. * @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... });
  232. */
  233. await api.trigger('beforeMessageBodyTransformed', this, {'Synchronous': true});
  234. this.render_styling && this.addStyling();
  235. this.addAnnotations(this.addMentions);
  236. this.addAnnotations(this.addHyperlinks);
  237. this.addAnnotations(this.addMapURLs);
  238. await api.emojis.initialize();
  239. this.addAnnotations(this.addEmojis);
  240. /**
  241. * Synchronous event which provides a hook for transforming a chat message's body text
  242. * after the default transformations have been applied.
  243. * @event _converse#afterMessageBodyTransformed
  244. * @param { MessageText } text - A {@link MessageText } instance. You
  245. * can call {@link MessageText#addTemplateResult} on it in order to
  246. * add TemplateResult objects meant to render rich parts of the message.
  247. * @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... });
  248. */
  249. await api.trigger('afterMessageBodyTransformed', this, {'Synchronous': true});
  250. this.payload = this.marshall();
  251. this.trimMeMessage();
  252. this.payload = this.payload.map(item => isString(item) ? item : item.template);
  253. }
  254. /**
  255. * The "rich" markup parts of a chat message are represented by lit-html
  256. * TemplateResult objects.
  257. *
  258. * This method can be used to add new template results to this message's
  259. * text.
  260. *
  261. * @method MessageText.addTemplateResult
  262. * @param { Number } begin - The starting index of the plain message text
  263. * which is being replaced with markup.
  264. * @param { Number } end - The ending index of the plain message text
  265. * which is being replaced with markup.
  266. * @param { Object } template - The lit-html TemplateResult instance
  267. */
  268. addTemplateResult (begin, end, template) {
  269. this.references.push({begin, end, template});
  270. }
  271. isMeCommand () {
  272. const text = this.toString();
  273. if (!text) {
  274. return false;
  275. }
  276. return text.startsWith('/me ');
  277. }
  278. /**
  279. * Take the annotations and return an array of text and TemplateResult
  280. * instances to be rendered to the DOM.
  281. * @method MessageText#marshall
  282. */
  283. marshall () {
  284. let list = [this.toString()];
  285. this.references
  286. .sort((a, b) => b.begin - a.begin)
  287. .forEach(ref => {
  288. const text = list.shift();
  289. list = [
  290. text.slice(0, ref.begin),
  291. ref,
  292. text.slice(ref.end),
  293. ...list
  294. ];
  295. });
  296. return list.reduce((acc, i) => isString(i) ? [...acc, convertASCII2Emoji(collapseLineBreaks(i))] : [...acc, i], []);
  297. }
  298. }