diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts index 8c10bdee28..b58ba37ecb 100644 --- a/src/client/app/common/views/components/index.ts +++ b/src/client/app/common/views/components/index.ts @@ -4,7 +4,7 @@ import signin from './signin.vue'; import signup from './signup.vue'; import forkit from './forkit.vue'; import nav from './nav.vue'; -import postHtml from './post-html.vue'; +import postHtml from './post-html'; import poll from './poll.vue'; import pollEditor from './poll-editor.vue'; import reactionIcon from './reaction-icon.vue'; diff --git a/src/client/app/common/views/components/messaging-room.message.vue b/src/client/app/common/views/components/messaging-room.message.vue index 25ceab85a1..91af26bffe 100644 --- a/src/client/app/common/views/components/messaging-room.message.vue +++ b/src/client/app/common/views/components/messaging-room.message.vue @@ -4,13 +4,13 @@ <img class="avatar" :src="`${message.user.avatarUrl}?thumbnail&size=80`" alt=""/> </router-link> <div class="content"> - <div class="balloon" :data-no-text="message.textHtml == null"> + <div class="balloon" :data-no-text="message.text == null"> <p class="read" v-if="isMe && message.isRead">%i18n:common.tags.mk-messaging-message.is-read%</p> <button class="delete-button" v-if="isMe" title="%i18n:common.delete%"> <img src="/assets/desktop/messaging/delete.png" alt="Delete"/> </button> <div class="content" v-if="!message.isDeleted"> - <mk-post-html class="text" v-if="message.textHtml" ref="text" :html="message.textHtml" :i="os.i"/> + <mk-post-html class="text" v-if="message.text" ref="text" :text="message.text" :i="os.i"/> <div class="file" v-if="message.file"> <a :href="message.file.url" target="_blank" :title="message.file.name"> <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/> @@ -35,35 +35,30 @@ <script lang="ts"> import Vue from 'vue'; import getAcct from '../../../../../common/user/get-acct'; +import parse from '../../../../../common/text/parse'; export default Vue.extend({ - props: ['message'], - data() { - return { - urls: [] - }; + props: { + message: { + required: true + } }, computed: { - acct() { + acct(): string { return getAcct(this.message.user); }, isMe(): boolean { return this.message.userId == (this as any).os.i.id; - } - }, - watch: { - message: { - handler(newMessage, oldMessage) { - if (!oldMessage || newMessage.textHtml !== oldMessage.textHtml) { - this.$nextTick(() => { - const elements = this.$refs.text.$el.getElementsByTagName('a'); - - this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin) - .map(({ href }) => href); - }); - } - }, - immediate: true + }, + urls(): string[] { + if (this.message.text) { + const ast = parse(this.message.text); + return ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } } } }); diff --git a/src/client/app/common/views/components/post-html.ts b/src/client/app/common/views/components/post-html.ts new file mode 100644 index 0000000000..c5c3b72758 --- /dev/null +++ b/src/client/app/common/views/components/post-html.ts @@ -0,0 +1,157 @@ +import Vue from 'vue'; +import * as emojilib from 'emojilib'; +import parse from '../../../../../common/text/parse'; +import getAcct from '../../../../../common/user/get-acct'; +import { url } from '../../../config'; +import MkUrl from './url.vue'; + +const flatten = list => list.reduce( + (a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), [] +); + +export default Vue.component('mk-post-html', { + props: { + text: { + type: String, + required: true + }, + ast: { + type: [], + required: false + }, + shouldBreak: { + type: Boolean, + default: true + }, + i: { + type: Object, + default: null + } + }, + + render(createElement) { + let ast; + + if (this.ast == null) { + // Parse text to ast + ast = parse(this.text); + } else { + ast = this.ast; + } + + // Parse ast to DOM + const els = flatten(ast.map(token => { + switch (token.type) { + case 'text': + const text = token.content.replace(/(\r\n|\n|\r)/g, '\n'); + + if (this.shouldBreak) { + const x = text.split('\n') + .map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]); + x[x.length - 1].pop(); + return x; + } else { + return createElement('span', text.replace(/\n/g, ' ')); + } + + case 'bold': + return createElement('strong', token.bold); + + case 'url': + return createElement(MkUrl, { + props: { + url: token.content, + target: '_blank' + } + }); + + case 'link': + return createElement('a', { + attrs: { + class: 'link', + href: token.url, + target: '_blank', + title: token.url + } + }, token.title); + + case 'mention': + return (createElement as any)('a', { + attrs: { + href: `${url}/@${getAcct(token)}`, + target: '_blank', + dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token) + }, + directives: [{ + name: 'user-preview', + value: token.content + }] + }, token.content); + + case 'hashtag': + return createElement('a', { + attrs: { + href: `${url}/search?q=${token.content}`, + target: '_blank' + } + }, token.content); + + case 'code': + return createElement('pre', [ + createElement('code', { + domProps: { + innerHTML: token.html + } + }) + ]); + + case 'inline-code': + return createElement('code', { + domProps: { + innerHTML: token.html + } + }); + + case 'quote': + const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n'); + + if (this.shouldBreak) { + const x = text2.split('\n') + .map(t => [createElement('span', t), createElement('br')]); + x[x.length - 1].pop(); + return createElement('div', { + attrs: { + class: 'quote' + } + }, x); + } else { + return createElement('span', { + attrs: { + class: 'quote' + } + }, text2.replace(/\n/g, ' ')); + } + + case 'emoji': + const emoji = emojilib.lib[token.emoji]; + return createElement('span', emoji ? emoji.char : token.content); + + default: + console.log('unknown ast type:', token.type); + } + })); + + const _els = []; + els.forEach((el, i) => { + if (el.tag == 'br') { + if (els[i - 1].tag != 'div') { + _els.push(el); + } + } else { + _els.push(el); + } + }); + + return createElement('span', _els); + } +}); diff --git a/src/client/app/common/views/components/post-html.vue b/src/client/app/common/views/components/post-html.vue deleted file mode 100644 index 1c949052b9..0000000000 --- a/src/client/app/common/views/components/post-html.vue +++ /dev/null @@ -1,103 +0,0 @@ -<template><div class="mk-post-html" v-html="html"></div></template> - -<script lang="ts"> -import Vue from 'vue'; -import getAcct from '../../../../../common/user/get-acct'; -import { url } from '../../../config'; - -function markUrl(a) { - while (a.firstChild) { - a.removeChild(a.firstChild); - } - - const schema = document.createElement('span'); - const delimiter = document.createTextNode('//'); - const host = document.createElement('span'); - const pathname = document.createElement('span'); - const query = document.createElement('span'); - const hash = document.createElement('span'); - - schema.className = 'schema'; - schema.textContent = a.protocol; - - host.className = 'host'; - host.textContent = a.host; - - pathname.className = 'pathname'; - pathname.textContent = a.pathname; - - query.className = 'query'; - query.textContent = a.search; - - hash.className = 'hash'; - hash.textContent = a.hash; - - a.appendChild(schema); - a.appendChild(delimiter); - a.appendChild(host); - a.appendChild(pathname); - a.appendChild(query); - a.appendChild(hash); -} - -function markMe(me, a) { - a.setAttribute("data-is-me", me && `${url}/@${getAcct(me)}` == a.href); -} - -function markTarget(a) { - a.setAttribute("target", "_blank"); -} - -export default Vue.component('mk-post-html', { - props: { - html: { - type: String, - required: true - }, - i: { - type: Object, - default: null - } - }, - watch { - html: { - handler() { - this.$nextTick(() => [].forEach.call(this.$el.getElementsByTagName('a'), a => { - if (a.href === a.textContent) { - markUrl(a); - } else { - markMe((this as any).i, a); - } - - markTarget(a); - })); - }, - immediate: true, - } - } -}); -</script> - -<style lang="stylus"> -.mk-post-html - a - word-break break-all - - > .schema - opacity 0.5 - - > .host - font-weight bold - - > .pathname - opacity 0.8 - - > .query - opacity 0.5 - - > .hash - font-style italic - - p - margin 0 -</style> diff --git a/src/client/app/common/views/components/url.vue b/src/client/app/common/views/components/url.vue new file mode 100644 index 0000000000..e6ffe4466d --- /dev/null +++ b/src/client/app/common/views/components/url.vue @@ -0,0 +1,57 @@ +<template> +<a class="mk-url" :href="url" :target="target"> + <span class="schema">{{ schema }}//</span> + <span class="hostname">{{ hostname }}</span> + <span class="port" v-if="port != ''">:{{ port }}</span> + <span class="pathname" v-if="pathname != ''">{{ pathname }}</span> + <span class="query">{{ query }}</span> + <span class="hash">{{ hash }}</span> + %fa:external-link-square-alt% +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['url', 'target'], + data() { + return { + schema: null, + hostname: null, + port: null, + pathname: null, + query: null, + hash: null + }; + }, + created() { + const url = new URL(this.url); + this.schema = url.protocol; + this.hostname = url.hostname; + this.port = url.port; + this.pathname = url.pathname; + this.query = url.search; + this.hash = url.hash; + } +}); +</script> + +<style lang="stylus" scoped> +.mk-url + word-break break-all + > [data-fa] + padding-left 2px + font-size .9em + font-weight 400 + font-style normal + > .schema + opacity 0.5 + > .hostname + font-weight bold + > .pathname + opacity 0.8 + > .query + opacity 0.5 + > .hash + font-style italic +</style> diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue index f379029f9f..09b090bdc1 100644 --- a/src/client/app/common/views/components/welcome-timeline.vue +++ b/src/client/app/common/views/components/welcome-timeline.vue @@ -15,7 +15,7 @@ </div> </header> <div class="text"> - <mk-post-html :html="post.textHtml"/> + <mk-post-html :text="post.text"/> </div> </div> </div> diff --git a/src/client/app/desktop/views/components/post-detail.sub.vue b/src/client/app/desktop/views/components/post-detail.sub.vue index b6148d9b28..1d5649cf92 100644 --- a/src/client/app/desktop/views/components/post-detail.sub.vue +++ b/src/client/app/desktop/views/components/post-detail.sub.vue @@ -16,7 +16,7 @@ </div> </header> <div class="body"> - <mk-post-html v-if="post.textHtml" :html="post.textHtml" :i="os.i" :class="$style.text"/> + <mk-post-html v-if="post.text" :text="post.text" :i="os.i" :class="$style.text"/> <div class="media" v-if="post.media > 0"> <mk-media-list :media-list="post.media"/> </div> diff --git a/src/client/app/desktop/views/components/post-detail.vue b/src/client/app/desktop/views/components/post-detail.vue index e75ebe34b4..70bfdbba35 100644 --- a/src/client/app/desktop/views/components/post-detail.vue +++ b/src/client/app/desktop/views/components/post-detail.vue @@ -38,7 +38,7 @@ </router-link> </header> <div class="body"> - <mk-post-html :class="$style.text" v-if="p.text" ref="text" :text="p.text" :i="os.i"/> + <mk-post-html :class="$style.text" v-if="p.text" :text="p.text" :i="os.i"/> <div class="media" v-if="p.media.length > 0"> <mk-media-list :media-list="p.media"/> </div> @@ -79,6 +79,7 @@ import Vue from 'vue'; import dateStringify from '../../../common/scripts/date-stringify'; import getAcct from '../../../../../common/user/get-acct'; +import parse from '../../../../../common/text/parse'; import MkPostFormWindow from './post-form-window.vue'; import MkRepostFormWindow from './repost-form-window.vue'; @@ -90,6 +91,7 @@ export default Vue.extend({ components: { XSub }, + props: { post: { type: Object, @@ -99,19 +101,15 @@ export default Vue.extend({ default: false } }, - computed: { - acct() { - return getAcct(this.post.user); - } - }, + data() { return { context: [], contextFetching: false, - replies: [], - urls: [] + replies: [] }; }, + computed: { isRepost(): boolean { return (this.post.repost && @@ -131,8 +129,22 @@ export default Vue.extend({ }, title(): string { return dateStringify(this.p.createdAt); + }, + acct(): string { + return getAcct(this.p.user); + }, + urls(): string[] { + if (this.p.text) { + const ast = parse(this.p.text); + return ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } } }, + mounted() { // Get replies if (!this.compact) { @@ -162,21 +174,7 @@ export default Vue.extend({ } } }, - watch: { - post: { - handler(newPost, oldPost) { - if (!oldPost || newPost.text !== oldPost.text) { - this.$nextTick(() => { - const elements = this.$refs.text.$el.getElementsByTagName('a'); - this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin) - .map(({ href }) => href); - }); - } - }, - immediate: true - } - }, methods: { fetchContext() { this.contextFetching = true; diff --git a/src/client/app/desktop/views/components/posts.post.vue b/src/client/app/desktop/views/components/posts.post.vue index f3566c81bf..c31e28d67f 100644 --- a/src/client/app/desktop/views/components/posts.post.vue +++ b/src/client/app/desktop/views/components/posts.post.vue @@ -38,7 +38,7 @@ </p> <div class="text"> <a class="reply" v-if="p.reply">%fa:reply%</a> - <mk-post-html v-if="p.textHtml" ref="text" :html="p.textHtml" :i="os.i" :class="$style.text"/> + <mk-post-html v-if="p.textHtml" :text="p.text" :i="os.i" :class="$style.text"/> <a class="rp" v-if="p.repost">RP:</a> </div> <div class="media" v-if="p.media.length > 0"> @@ -86,6 +86,8 @@ import Vue from 'vue'; import dateStringify from '../../../common/scripts/date-stringify'; import getAcct from '../../../../../common/user/get-acct'; +import parse from '../../../../../common/text/parse'; + import MkPostFormWindow from './post-form-window.vue'; import MkRepostFormWindow from './repost-form-window.vue'; import MkPostMenu from '../../../common/views/components/post-menu.vue'; @@ -107,17 +109,19 @@ export default Vue.extend({ components: { XSub }, + props: ['post'], + data() { return { isDetailOpened: false, connection: null, - connectionId: null, - urls: [] + connectionId: null }; }, + computed: { - acct() { + acct(): string { return getAcct(this.p.user); }, isRepost(): boolean { @@ -141,14 +145,26 @@ export default Vue.extend({ }, url(): string { return `/@${this.acct}/${this.p.id}`; + }, + urls(): string[] { + if (this.p.text) { + const ast = parse(this.p.text); + return ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } } }, + created() { if ((this as any).os.isSignedIn) { this.connection = (this as any).os.stream.getConnection(); this.connectionId = (this as any).os.stream.use(); } }, + mounted() { this.capture(true); @@ -174,6 +190,7 @@ export default Vue.extend({ } } }, + beforeDestroy() { this.decapture(true); @@ -182,21 +199,7 @@ export default Vue.extend({ (this as any).os.stream.dispose(this.connectionId); } }, - watch: { - post: { - handler(newPost, oldPost) { - if (!oldPost || newPost.textHtml !== oldPost.textHtml) { - this.$nextTick(() => { - const elements = this.$refs.text.$el.getElementsByTagName('a'); - this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin) - .map(({ href }) => href); - }); - } - }, - immediate: true - } - }, methods: { capture(withHandler = false) { if ((this as any).os.isSignedIn) { @@ -457,7 +460,7 @@ export default Vue.extend({ font-size 1.1em color #717171 - >>> blockquote + >>> .quote margin 8px padding 6px 12px color #aaa diff --git a/src/client/app/desktop/views/components/sub-post-content.vue b/src/client/app/desktop/views/components/sub-post-content.vue index 58c81e7552..17899af280 100644 --- a/src/client/app/desktop/views/components/sub-post-content.vue +++ b/src/client/app/desktop/views/components/sub-post-content.vue @@ -2,7 +2,7 @@ <div class="mk-sub-post-content"> <div class="body"> <a class="reply" v-if="post.replyId">%fa:reply%</a> - <mk-post-html ref="text" :html="post.textHtml" :i="os.i"/> + <mk-post-html :text="post.text" :i="os.i"/> <a class="rp" v-if="post.repostId" :href="`/post:${post.repostId}`">RP: ...</a> </div> <details v-if="post.media.length > 0"> diff --git a/src/client/app/mobile/views/components/post-detail.vue b/src/client/app/mobile/views/components/post-detail.vue index 77a73426f2..0a4e36fc60 100644 --- a/src/client/app/mobile/views/components/post-detail.vue +++ b/src/client/app/mobile/views/components/post-detail.vue @@ -81,6 +81,8 @@ <script lang="ts"> import Vue from 'vue'; import getAcct from '../../../../../common/user/get-acct'; +import parse from '../../../../../common/text/parse'; + import MkPostMenu from '../../../common/views/components/post-menu.vue'; import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; import XSub from './post-detail.sub.vue'; @@ -89,6 +91,7 @@ export default Vue.extend({ components: { XSub }, + props: { post: { type: Object, @@ -98,19 +101,20 @@ export default Vue.extend({ default: false } }, + data() { return { context: [], contextFetching: false, - replies: [], - urls: [] + replies: [] }; }, + computed: { - acct() { + acct(): string { return getAcct(this.post.user); }, - pAcct() { + pAcct(): string { return getAcct(this.p.user); }, isRepost(): boolean { @@ -128,8 +132,19 @@ export default Vue.extend({ .map(key => this.p.reactionCounts[key]) .reduce((a, b) => a + b) : 0; + }, + urls(): string[] { + if (this.p.text) { + const ast = parse(this.p.text); + return ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } } }, + mounted() { // Get replies if (!this.compact) { @@ -159,21 +174,7 @@ export default Vue.extend({ } } }, - watch: { - post: { - handler(newPost, oldPost) { - if (!oldPost || newPost.text !== oldPost.text) { - this.$nextTick(() => { - const elements = this.$refs.text.$el.getElementsByTagName('a'); - this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin) - .map(({ href }) => href); - }); - } - }, - immediate: true - } - }, methods: { fetchContext() { this.contextFetching = true; diff --git a/src/client/app/mobile/views/components/post.vue b/src/client/app/mobile/views/components/post.vue index 96ec9632f1..f4f845b49a 100644 --- a/src/client/app/mobile/views/components/post.vue +++ b/src/client/app/mobile/views/components/post.vue @@ -37,7 +37,7 @@ <a class="reply" v-if="p.reply"> %fa:reply% </a> - <mk-post-html v-if="p.text" ref="text" :text="p.text" :i="os.i" :class="$style.text"/> + <mk-post-html v-if="p.text" :text="p.text" :i="os.i" :class="$style.text"/> <a class="rp" v-if="p.repost != null">RP:</a> </div> <div class="media" v-if="p.media.length > 0"> @@ -78,6 +78,8 @@ <script lang="ts"> import Vue from 'vue'; import getAcct from '../../../../../common/user/get-acct'; +import parse from '../../../../../common/text/parse'; + import MkPostMenu from '../../../common/views/components/post-menu.vue'; import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; import XSub from './post.sub.vue'; @@ -86,19 +88,21 @@ export default Vue.extend({ components: { XSub }, + props: ['post'], + data() { return { connection: null, - connectionId: null, - urls: [] + connectionId: null }; }, + computed: { - acct() { + acct(): string { return getAcct(this.post.user); }, - pAcct() { + pAcct(): string { return getAcct(this.p.user); }, isRepost(): boolean { @@ -119,14 +123,26 @@ export default Vue.extend({ }, url(): string { return `/@${this.pAcct}/${this.p.id}`; + }, + urls(): string[] { + if (this.p.text) { + const ast = parse(this.p.text); + return ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } } }, + created() { if ((this as any).os.isSignedIn) { this.connection = (this as any).os.stream.getConnection(); this.connectionId = (this as any).os.stream.use(); } }, + mounted() { this.capture(true); @@ -152,6 +168,7 @@ export default Vue.extend({ } } }, + beforeDestroy() { this.decapture(true); @@ -160,21 +177,7 @@ export default Vue.extend({ (this as any).os.stream.dispose(this.connectionId); } }, - watch: { - post: { - handler(newPost, oldPost) { - if (!oldPost || newPost.text !== oldPost.text) { - this.$nextTick(() => { - const elements = this.$refs.text.$el.getElementsByTagName('a'); - this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin) - .map(({ href }) => href); - }); - } - }, - immediate: true - } - }, methods: { capture(withHandler = false) { if ((this as any).os.isSignedIn) { @@ -396,7 +399,7 @@ export default Vue.extend({ font-size 1.1em color #717171 - >>> blockquote + >>> .quote margin 8px padding 6px 12px color #aaa diff --git a/src/client/app/mobile/views/components/sub-post-content.vue b/src/client/app/mobile/views/components/sub-post-content.vue index 955bb406b4..97dd987dd7 100644 --- a/src/client/app/mobile/views/components/sub-post-content.vue +++ b/src/client/app/mobile/views/components/sub-post-content.vue @@ -2,7 +2,7 @@ <div class="mk-sub-post-content"> <div class="body"> <a class="reply" v-if="post.replyId">%fa:reply%</a> - <mk-post-html v-if="post.text" :ast="post.text" :i="os.i"/> + <mk-post-html v-if="post.text" :text="post.text" :i="os.i"/> <a class="rp" v-if="post.repostId">RP: ...</a> </div> <details v-if="post.media.length > 0"> diff --git a/src/common/text/parse/index.ts b/src/common/text/parse/index.ts index 1e2398dc38..b958da81b0 100644 --- a/src/common/text/parse/index.ts +++ b/src/common/text/parse/index.ts @@ -14,7 +14,7 @@ const elements = [ require('./elements/emoji') ]; -export default (source: string) => { +export default (source: string): any[] => { if (source == '') { return null;