diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts index b58ba37ecb..8c10bdee28 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'; +import postHtml from './post-html.vue'; 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 94f87fd709..25ceab85a1 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 @@
-
+

%i18n:common.tags.mk-messaging-message.is-read%

- +
@@ -38,21 +38,32 @@ import getAcct from '../../../../../common/user/get-acct'; export default Vue.extend({ props: ['message'], + data() { + return { + urls: [] + }; + }, computed: { acct() { return getAcct(this.message.user); }, isMe(): boolean { return this.message.userId == (this as any).os.i.id; - }, - urls(): string[] { - if (this.message.ast) { - return this.message.ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url); - } else { - return null; - } + } + }, + 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 } } }); diff --git a/src/client/app/common/views/components/post-html.ts b/src/client/app/common/views/components/post-html.ts deleted file mode 100644 index 39d783aac5..0000000000 --- a/src/client/app/common/views/components/post-html.ts +++ /dev/null @@ -1,141 +0,0 @@ -import Vue from 'vue'; -import * as emojilib from 'emojilib'; -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: { - ast: { - type: Array, - required: true - }, - shouldBreak: { - type: Boolean, - default: true - }, - i: { - type: Object, - default: null - } - }, - render(createElement) { - const els = flatten((this as any).ast.map(token => { - switch (token.type) { - case 'text': - const text = token.content.replace(/(\r\n|\n|\r)/g, '\n'); - - if ((this as any).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 as any).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 new file mode 100644 index 0000000000..1c949052b9 --- /dev/null +++ b/src/client/app/common/views/components/post-html.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/src/client/app/common/views/components/url.vue b/src/client/app/common/views/components/url.vue deleted file mode 100644 index 14d4fc82f3..0000000000 --- a/src/client/app/common/views/components/url.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - - - diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue index 8f6199732a..f379029f9f 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 @@
- +
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 285b5dedee..b6148d9b28 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 @@
- +
diff --git a/src/client/app/desktop/views/components/post-detail.vue b/src/client/app/desktop/views/components/post-detail.vue index 1811e22bad..e75ebe34b4 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 @@
- +
@@ -109,6 +109,7 @@ export default Vue.extend({ context: [], contextFetching: false, replies: [], + urls: [] }; }, computed: { @@ -130,15 +131,6 @@ export default Vue.extend({ }, title(): string { return dateStringify(this.p.createdAt); - }, - urls(): string[] { - if (this.p.ast) { - return this.p.ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url); - } else { - return null; - } } }, mounted() { @@ -170,6 +162,21 @@ 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 aa1f1db41c..f3566c81bf 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 @@

@@ -112,7 +112,8 @@ export default Vue.extend({ return { isDetailOpened: false, connection: null, - connectionId: null + connectionId: null, + urls: [] }; }, computed: { @@ -140,15 +141,6 @@ export default Vue.extend({ }, url(): string { return `/@${this.acct}/${this.p.id}`; - }, - urls(): string[] { - if (this.p.ast) { - return this.p.ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url); - } else { - return null; - } } }, created() { @@ -190,6 +182,21 @@ 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) { @@ -450,7 +457,7 @@ export default Vue.extend({ font-size 1.1em color #717171 - >>> .quote + >>> blockquote 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 1f5ce38984..58c81e7552 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 @@
diff --git a/src/client/app/mobile/views/components/post-detail.vue b/src/client/app/mobile/views/components/post-detail.vue index 6411011b89..77a73426f2 100644 --- a/src/client/app/mobile/views/components/post-detail.vue +++ b/src/client/app/mobile/views/components/post-detail.vue @@ -38,7 +38,7 @@
- +
{{ tag }}
@@ -103,6 +103,7 @@ export default Vue.extend({ context: [], contextFetching: false, replies: [], + urls: [] }; }, computed: { @@ -127,15 +128,6 @@ export default Vue.extend({ .map(key => this.p.reactionCounts[key]) .reduce((a, b) => a + b) : 0; - }, - urls(): string[] { - if (this.p.ast) { - return this.p.ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url); - } else { - return null; - } } }, mounted() { @@ -167,6 +159,21 @@ 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 52fb095372..96ec9632f1 100644 --- a/src/client/app/mobile/views/components/post.vue +++ b/src/client/app/mobile/views/components/post.vue @@ -37,7 +37,7 @@ %fa:reply% - + RP:
@@ -90,7 +90,8 @@ export default Vue.extend({ data() { return { connection: null, - connectionId: null + connectionId: null, + urls: [] }; }, computed: { @@ -118,15 +119,6 @@ export default Vue.extend({ }, url(): string { return `/@${this.pAcct}/${this.p.id}`; - }, - urls(): string[] { - if (this.p.ast) { - return this.p.ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url); - } else { - return null; - } } }, created() { @@ -168,6 +160,21 @@ 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) { @@ -389,7 +396,7 @@ export default Vue.extend({ font-size 1.1em color #717171 - >>> .quote + >>> blockquote 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 5ff88089aa..955bb406b4 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 @@
diff --git a/src/client/docs/api/entities/post.yaml b/src/client/docs/api/entities/post.yaml index da79866ba1..7077700129 100644 --- a/src/client/docs/api/entities/post.yaml +++ b/src/client/docs/api/entities/post.yaml @@ -27,8 +27,14 @@ props: type: "string" optional: true desc: - ja: "投稿の本文" - en: "The text of this post" + ja: "投稿の本文 (ローカルの場合Markdown風のフォーマット)" + en: "The text of this post (in Markdown like format if local)" + - name: "textHtml" + type: "string" + optional: true + desc: + ja: "投稿の本文 (HTML) (投稿時は無視)" + en: "The text of this post (in HTML. Ignored when posting.)" - name: "mediaIds" type: "id(DriveFile)[]" optional: true diff --git a/src/common/text/html.ts b/src/common/text/html.ts new file mode 100644 index 0000000000..797f3b3f33 --- /dev/null +++ b/src/common/text/html.ts @@ -0,0 +1,83 @@ +import { lib as emojilib } from 'emojilib'; +import { JSDOM } from 'jsdom'; + +const handlers = { + bold({ document }, { bold }) { + const b = document.createElement('b'); + b.textContent = bold; + document.body.appendChild(b); + }, + + code({ document }, { code }) { + const pre = document.createElement('pre'); + const inner = document.createElement('code'); + inner.innerHTML = code; + pre.appendChild(inner); + document.body.appendChild(pre); + }, + + emoji({ document }, { content, emoji }) { + const found = emojilib[emoji]; + const node = document.createTextNode(found ? found.char : content); + document.body.appendChild(node); + }, + + hashtag({ document }, { hashtag }) { + const a = document.createElement('a'); + a.href = '/search?q=#' + hashtag; + a.textContent = hashtag; + }, + + 'inline-code'({ document }, { code }) { + const element = document.createElement('code'); + element.textContent = code; + document.body.appendChild(element); + }, + + link({ document }, { url, title }) { + const a = document.createElement('a'); + a.href = url; + a.textContent = title; + document.body.appendChild(a); + }, + + mention({ document }, { content }) { + const a = document.createElement('a'); + a.href = '/' + content; + a.textContent = content; + document.body.appendChild(a); + }, + + quote({ document }, { quote }) { + const blockquote = document.createElement('blockquote'); + blockquote.textContent = quote; + document.body.appendChild(blockquote); + }, + + text({ document }, { content }) { + for (const text of content.split('\n')) { + const node = document.createTextNode(text); + document.body.appendChild(node); + + const br = document.createElement('br'); + document.body.appendChild(br); + } + }, + + url({ document }, { url }) { + const a = document.createElement('a'); + a.href = url; + a.textContent = url; + document.body.appendChild(a); + } +}; + +export default tokens => { + const { window } = new JSDOM(''); + + for (const token of tokens) { + handlers[token.type](window, token); + } + + return `

${window.document.body.innerHTML}

`; +}; diff --git a/src/common/text/core/syntax-highlighter.ts b/src/common/text/parse/core/syntax-highlighter.ts similarity index 100% rename from src/common/text/core/syntax-highlighter.ts rename to src/common/text/parse/core/syntax-highlighter.ts diff --git a/src/common/text/elements/bold.ts b/src/common/text/parse/elements/bold.ts similarity index 100% rename from src/common/text/elements/bold.ts rename to src/common/text/parse/elements/bold.ts diff --git a/src/common/text/elements/code.ts b/src/common/text/parse/elements/code.ts similarity index 100% rename from src/common/text/elements/code.ts rename to src/common/text/parse/elements/code.ts diff --git a/src/common/text/elements/emoji.ts b/src/common/text/parse/elements/emoji.ts similarity index 100% rename from src/common/text/elements/emoji.ts rename to src/common/text/parse/elements/emoji.ts diff --git a/src/common/text/elements/hashtag.ts b/src/common/text/parse/elements/hashtag.ts similarity index 100% rename from src/common/text/elements/hashtag.ts rename to src/common/text/parse/elements/hashtag.ts diff --git a/src/common/text/elements/inline-code.ts b/src/common/text/parse/elements/inline-code.ts similarity index 100% rename from src/common/text/elements/inline-code.ts rename to src/common/text/parse/elements/inline-code.ts diff --git a/src/common/text/elements/link.ts b/src/common/text/parse/elements/link.ts similarity index 100% rename from src/common/text/elements/link.ts rename to src/common/text/parse/elements/link.ts diff --git a/src/common/text/elements/mention.ts b/src/common/text/parse/elements/mention.ts similarity index 82% rename from src/common/text/elements/mention.ts rename to src/common/text/parse/elements/mention.ts index d05a76649d..2025dfdaad 100644 --- a/src/common/text/elements/mention.ts +++ b/src/common/text/parse/elements/mention.ts @@ -1,7 +1,7 @@ /** * Mention */ -import parseAcct from '../../../common/user/parse-acct'; +import parseAcct from '../../../../common/user/parse-acct'; module.exports = text => { const match = text.match(/^(?:@[a-zA-Z0-9\-]+){1,2}/); diff --git a/src/common/text/elements/quote.ts b/src/common/text/parse/elements/quote.ts similarity index 100% rename from src/common/text/elements/quote.ts rename to src/common/text/parse/elements/quote.ts diff --git a/src/common/text/elements/url.ts b/src/common/text/parse/elements/url.ts similarity index 100% rename from src/common/text/elements/url.ts rename to src/common/text/parse/elements/url.ts diff --git a/src/common/text/index.ts b/src/common/text/parse/index.ts similarity index 100% rename from src/common/text/index.ts rename to src/common/text/parse/index.ts diff --git a/src/models/messaging-message.ts b/src/models/messaging-message.ts index 8bee657c34..974ee54ab8 100644 --- a/src/models/messaging-message.ts +++ b/src/models/messaging-message.ts @@ -3,7 +3,6 @@ import deepcopy = require('deepcopy'); import { pack as packUser } from './user'; import { pack as packFile } from './drive-file'; import db from '../db/mongodb'; -import parse from '../common/text'; const MessagingMessage = db.get('messagingMessages'); export default MessagingMessage; @@ -12,6 +11,7 @@ export interface IMessagingMessage { _id: mongo.ObjectID; createdAt: Date; text: string; + textHtml: string; userId: mongo.ObjectID; recipientId: mongo.ObjectID; isRead: boolean; @@ -60,11 +60,6 @@ export const pack = ( _message.id = _message._id; delete _message._id; - // Parse text - if (_message.text) { - _message.ast = parse(_message.text); - } - // Populate user _message.user = await packUser(_message.userId, me); diff --git a/src/models/post.ts b/src/models/post.ts index 9bc0c1d3b9..6c853e4f81 100644 --- a/src/models/post.ts +++ b/src/models/post.ts @@ -8,7 +8,6 @@ import { pack as packChannel } from './channel'; import Vote from './poll-vote'; import Reaction from './post-reaction'; import { pack as packFile } from './drive-file'; -import parse from '../common/text'; const Post = db.get('posts'); @@ -31,6 +30,7 @@ export type IPost = { repostId: mongo.ObjectID; poll: any; // todo text: string; + textHtml: string; cw: string; userId: mongo.ObjectID; appId: mongo.ObjectID; @@ -103,11 +103,6 @@ export const pack = async ( delete _post.mentions; if (_post.geo) delete _post.geo.type; - // Parse text - if (_post.text) { - _post.ast = parse(_post.text); - } - // Populate user _post.user = packUser(_post.userId, meId); diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts index d8ffa9fdec..3d3b204da5 100644 --- a/src/server/api/endpoints/messaging/messages/create.ts +++ b/src/server/api/endpoints/messaging/messages/create.ts @@ -11,6 +11,8 @@ import DriveFile from '../../../../../models/drive-file'; import { pack } from '../../../../../models/messaging-message'; import publishUserStream from '../../../event'; import { publishMessagingStream, publishMessagingIndexStream, pushSw } from '../../../event'; +import html from '../../../../../common/text/html'; +import parse from '../../../../../common/text/parse'; import config from '../../../../../conf'; /** @@ -74,6 +76,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { fileId: file ? file._id : undefined, recipientId: recipient._id, text: text ? text : undefined, + textHtml: text ? html(parse(text)) : undefined, userId: user._id, isRead: false }); diff --git a/src/server/api/endpoints/posts/create.ts b/src/server/api/endpoints/posts/create.ts index aa7e93c28f..5342f77728 100644 --- a/src/server/api/endpoints/posts/create.ts +++ b/src/server/api/endpoints/posts/create.ts @@ -3,7 +3,8 @@ */ import $ from 'cafy'; import deepEqual = require('deep-equal'); -import parse from '../../../../common/text'; +import html from '../../../../common/text/html'; +import parse from '../../../../common/text/parse'; import { default as Post, IPost, isValidText, isValidCw } from '../../../../models/post'; import { default as User, ILocalAccount, IUser } from '../../../../models/user'; import { default as Channel, IChannel } from '../../../../models/channel'; @@ -259,6 +260,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { repostId: repost ? repost._id : undefined, poll: poll, text: text, + textHtml: tokens === null ? null : html(tokens), cw: cw, tags: tags, userId: user._id, diff --git a/tools/migration/nighthike/7.js b/tools/migration/nighthike/7.js new file mode 100644 index 0000000000..c5055da8ba --- /dev/null +++ b/tools/migration/nighthike/7.js @@ -0,0 +1,16 @@ +// for Node.js interpretation + +const Message = require('../../../built/models/messaging-message').default; +const Post = require('../../../built/models/post').default; +const html = require('../../../built/common/text/html').default; +const parse = require('../../../built/common/text/parse').default; + +Promise.all([Message, Post].map(async model => { + const documents = await model.find(); + + return Promise.all(documents.map(({ _id, text }) => model.update(_id, { + $set: { + textHtml: html(parse(text)) + } + }))); +})).catch(console.error).then(process.exit);