From 34ec87cd4f76bbb48236c94f8db65f669a8c3306 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 25 Sep 2020 19:35:24 +0200 Subject: [PATCH] render messages based on the source html instead of first stripping, and then repopulating html. this makes it possible to do proper manipulation on mention and hashtag links Signed-off-by: Robin Appelman --- src/components/MessageContent.js | 113 +++++++++++++++++++++++++++++++ src/components/TimelinePost.vue | 37 +++------- src/router.js | 2 +- 3 files changed, 123 insertions(+), 29 deletions(-) create mode 100644 src/components/MessageContent.js diff --git a/src/components/MessageContent.js b/src/components/MessageContent.js new file mode 100644 index 00000000..e66e58da --- /dev/null +++ b/src/components/MessageContent.js @@ -0,0 +1,113 @@ +import Vue from 'vue' + +export default Vue.component('MessageContent', { + props: { + source: { + type: Object, + required: true + } + }, + render: function(createElement) { + return formatMessage(createElement, this.source) + } +}) + +/** + * Transform the message source into Vue elements + * + * filters out all tags except
,

, and . + * + * Links that are hashtags or mentions are rewritten to link to the local profile or hashtag page + * All external links have `rel="nofollow noopener noreferrer"` and `target="_blank"` set. + * + * All attributes other than `href` for links are stripped from the source + */ +export function formatMessage(createElement, source) { + let mentions = source.tag.filter(tag => tag.type === 'Mention') + let hashtags = source.tag.filter(tag => tag.type === 'Hashtag') + + let parser = new DOMParser() + let dom = parser.parseFromString(`

`, 'text/html') + let element = dom.getElementById('rootwrapper') + let cleaned = cleanCopy(createElement, element, { mentions, hashtags }) + return cleaned +} + +function domToVue(createElement, node, context) { + switch (node.tagName) { + case 'P': + return cleanCopy(createElement, node, context) + case 'BR': + return cleanCopy(createElement, node, context) + case 'SPAN': + return cleanCopy(createElement, node, context) + case 'A': + return cleanLink(createElement, node, context) + default: + return node.textContent + } +} + +/** + * copy a node without any attributes and cleaning all children + */ +function cleanCopy(createElement, node, context) { + let children = Array.from(node.childNodes).map(node => domToVue(createElement, node, context)) + return createElement(node.tagName, children) +} + +function cleanLink(createElement, node, context) { + let type = getLinkType(node.className) + let attributes = {} + + switch (type) { + case 'mention': + let tag = matchMention(context.mentions, node.getAttribute('href'), node.textContent) + if (tag) { + attributes['href'] = OC.generateUrl(`apps/social/${tag.name}`) + } else { + return node.textContent + } + break + case 'hashtag': + attributes['href'] = OC.generateUrl(`apps/social/timeline/tags/${node.textContent}`) + break + default: + attributes['rel'] = 'nofollow noopener noreferrer' + attributes['target'] = '_blank' + attributes['href'] = node.getAttribute('href') + } + + return createElement('a', { attrs: attributes }, [node.textContent]) +} + +function getLinkType(className) { + let parts = className.split(' ') + if (parts.includes('hashtag')) { + return 'hashtag' + } + if (parts.includes('mention')) { + return 'mention' + } + return '' +} + +function matchMention(tags, mentionHref, mentionText) { + let mentionUrl = new URL(mentionHref) + for (let tag of tags) { + if (mentionText === tag.name) { + return tag + } + + // since the mention link href is not always equal to the href in the tag + // we instead match the server and username separate + let tagUrl = new URL(tag.href) + if (tagUrl.host === mentionUrl.host) { + let [, name] = tag.name.split('@') + if (name === mentionText || '@' + name === mentionText) { + return tag + } + } + } + return null +} diff --git a/src/components/TimelinePost.vue b/src/components/TimelinePost.vue index f3c24164..2169079a 100644 --- a/src/components/TimelinePost.vue +++ b/src/components/TimelinePost.vue @@ -31,7 +31,9 @@ -
+
+ +
@@ -62,6 +64,7 @@ import 'linkifyjs/string' import popoverMenu from './../mixins/popoverMenu' import currentUser from './../mixins/currentUserMixin' import PostAttachment from './PostAttachment.vue' +import MessageContent from './MessageContent' pluginMention(linkify) @@ -69,7 +72,8 @@ export default { name: 'TimelinePost', components: { Avatar, - PostAttachment + PostAttachment, + MessageContent }, mixins: [popoverMenu, currentUser], props: { @@ -104,24 +108,12 @@ export default { timestamp() { return Date.parse(this.item.published) }, - formatedMessage() { + source() { let message = this.item.content if (typeof message === 'undefined') { - return '' + return null } - message = message.linkify({ - formatHref: { - mention: function(href) { - return OC.generateUrl('/apps/social/@' + href.substring(1)) - } - } - }) - if (this.item.hashtags !== undefined) { - message = this.mangleHashtags(message) - } - message = message.replace(/(?:\r\n|\r|\n)/g, '
') - message = this.$twemoji.parse(message) - return message + return JSON.parse(this.item.source) }, avatarUrl() { return OC.generateUrl('/apps/social/api/v1/global/actor/avatar?id=' + this.item.attributedTo) @@ -143,17 +135,6 @@ export default { } }, methods: { - mangleHashtags(msg) { - // Replace hashtag's href parameter with local ones - this.item.hashtags.forEach(tag => { - let patt = new RegExp('#' + tag, 'gi') - msg = msg.replace(patt, function(matched) { - var a = '
' + matched + '' - return a - }) - }) - return msg - }, userDisplayName(actorInfo) { return actorInfo.name !== '' ? actorInfo.name : actorInfo.preferredUsername }, diff --git a/src/router.js b/src/router.js index bb797aaf..d2b281c9 100644 --- a/src/router.js +++ b/src/router.js @@ -104,7 +104,7 @@ export default new Router({ default: Profile, details: ProfileTimeline }, - props: true, + props: true } ] })