From 80b5fda292efd70cc749910e3672d50c9a70a72e Mon Sep 17 00:00:00 2001
From: MeiMei <30769358+mei23@users.noreply.github.com>
Date: Fri, 2 Nov 2018 08:59:40 +0900
Subject: [PATCH]  Remote custom emojis  (#3074)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Remote custom emojis

* んほおおおおお
---
 .../components/misskey-flavored-markdown.ts   | 26 +++++++------
 .../views/components/welcome-timeline.vue     |  2 +-
 .../desktop/views/components/note-detail.vue  |  2 +-
 .../app/desktop/views/components/note.vue     |  2 +-
 .../views/components/sub-note-content.vue     |  2 +-
 .../mobile/views/components/note-detail.vue   |  2 +-
 .../app/mobile/views/components/note.vue      |  2 +-
 .../views/components/sub-note-content.vue     |  2 +-
 src/models/emoji.ts                           | 22 +++++++++++
 src/models/note.ts                            |  6 +++
 .../activitypub/misc/get-emoji-names.ts       |  6 +++
 src/remote/activitypub/models/icon.ts         |  5 +++
 src/remote/activitypub/models/note.ts         | 39 +++++++++++++++++++
 src/remote/activitypub/models/tag.ts          | 12 ++++++
 src/remote/activitypub/renderer/emoji.ts      | 14 +++++++
 src/remote/activitypub/renderer/note.ts       | 37 +++++++++++++++---
 src/server/api/endpoints/meta.ts              |  5 ++-
 src/tools/add-emoji.ts                        | 31 +++++++++++++++
 18 files changed, 193 insertions(+), 24 deletions(-)
 create mode 100644 src/models/emoji.ts
 create mode 100644 src/remote/activitypub/misc/get-emoji-names.ts
 create mode 100644 src/remote/activitypub/models/icon.ts
 create mode 100644 src/remote/activitypub/models/tag.ts
 create mode 100644 src/remote/activitypub/renderer/emoji.ts
 create mode 100644 src/tools/add-emoji.ts

diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.ts b/src/client/app/common/views/components/misskey-flavored-markdown.ts
index 6397767cec..68f3aeed1f 100644
--- a/src/client/app/common/views/components/misskey-flavored-markdown.ts
+++ b/src/client/app/common/views/components/misskey-flavored-markdown.ts
@@ -24,6 +24,9 @@ export default Vue.component('misskey-flavored-markdown', {
 		i: {
 			type: Object,
 			default: null
+		},
+		customEmojis: {
+			required: false,
 		}
 	},
 
@@ -186,17 +189,18 @@ export default Vue.component('misskey-flavored-markdown', {
 
 				case 'emoji': {
 					//#region カスタム絵文字
-					const customEmojis = (this.os.getMetaSync() || { emojis: [] }).emojis || [];
-					const customEmoji = customEmojis.find(e => e.name == token.emoji || (e.aliases || []).includes(token.emoji));
-					if (customEmoji) {
-						return [createElement('img', {
-							attrs: {
-								src: customEmoji.url,
-								alt: token.emoji,
-								title: token.emoji,
-								style: 'height: 2.5em; vertical-align: middle;'
-							}
-						})];
+					if (this.customEmojis != null) {
+						const customEmoji = this.customEmojis.find(e => e.name == token.emoji || (e.aliases || []).includes(token.emoji));
+						if (customEmoji) {
+							return [createElement('img', {
+								attrs: {
+									src: customEmoji.url,
+									alt: token.emoji,
+									title: token.emoji,
+									style: 'height: 2.5em; vertical-align: middle;'
+								}
+							})];
+						}
 					}
 					//#endregion
 
diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue
index 4a66db57b8..669f67288b 100644
--- a/src/client/app/common/views/components/welcome-timeline.vue
+++ b/src/client/app/common/views/components/welcome-timeline.vue
@@ -14,7 +14,7 @@
 					</div>
 				</header>
 				<div class="text">
-					<misskey-flavored-markdown v-if="note.text" :text="note.text"/>
+					<misskey-flavored-markdown v-if="note.text" :text="note.text" :customEmojis="p.emojis"/>
 				</div>
 			</div>
 		</div>
diff --git a/src/client/app/desktop/views/components/note-detail.vue b/src/client/app/desktop/views/components/note-detail.vue
index dce5b12615..1c802d790c 100644
--- a/src/client/app/desktop/views/components/note-detail.vue
+++ b/src/client/app/desktop/views/components/note-detail.vue
@@ -45,7 +45,7 @@
 				<div class="text">
 					<span v-if="p.isHidden" style="opacity: 0.5">%i18n:@private%</span>
 					<span v-if="p.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span>
-					<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/>
+					<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :customEmojis="p.emojis" />
 				</div>
 				<div class="files" v-if="p.files.length > 0">
 					<mk-media-list :media-list="p.files" :raw="true"/>
diff --git a/src/client/app/desktop/views/components/note.vue b/src/client/app/desktop/views/components/note.vue
index c42b863b2a..dd6cba9ce2 100644
--- a/src/client/app/desktop/views/components/note.vue
+++ b/src/client/app/desktop/views/components/note.vue
@@ -34,7 +34,7 @@
 					<div class="text">
 						<span v-if="appearNote.isHidden" style="opacity: 0.5">%i18n:@private%</span>
 						<a class="reply" v-if="appearNote.reply">%fa:reply%</a>
-						<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :class="$style.text"/>
+						<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :class="$style.text" :customEmojis="appearNote.emojis"/>
 						<a class="rp" v-if="appearNote.renote">RN:</a>
 					</div>
 					<div class="files" v-if="appearNote.files.length > 0">
diff --git a/src/client/app/desktop/views/components/sub-note-content.vue b/src/client/app/desktop/views/components/sub-note-content.vue
index d36d1c6745..b5e4e008dc 100644
--- a/src/client/app/desktop/views/components/sub-note-content.vue
+++ b/src/client/app/desktop/views/components/sub-note-content.vue
@@ -4,7 +4,7 @@
 		<span v-if="note.isHidden" style="opacity: 0.5">%i18n:@private%</span>
 		<span v-if="note.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span>
 		<a class="reply" v-if="note.replyId">%fa:reply%</a>
-		<misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i"/>
+		<misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i" :customEmojis="note.emojis"/>
 		<a class="rp" v-if="note.renoteId" :href="`/notes/${note.renoteId}`">RN: ...</a>
 	</div>
 	<details v-if="note.files.length > 0">
diff --git a/src/client/app/mobile/views/components/note-detail.vue b/src/client/app/mobile/views/components/note-detail.vue
index 082f72f1a9..3125255c9e 100644
--- a/src/client/app/mobile/views/components/note-detail.vue
+++ b/src/client/app/mobile/views/components/note-detail.vue
@@ -43,7 +43,7 @@
 				<div class="text">
 					<span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
 					<span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span>
-					<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/>
+					<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :customEmojis="p.emojis"/>
 				</div>
 				<div class="files" v-if="p.files.length > 0">
 					<mk-media-list :media-list="p.files" :raw="true"/>
diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue
index cbac5b6450..e1b8e05c81 100644
--- a/src/client/app/mobile/views/components/note.vue
+++ b/src/client/app/mobile/views/components/note.vue
@@ -30,7 +30,7 @@
 					<div class="text">
 						<span v-if="appearNote.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
 						<a class="reply" v-if="appearNote.reply">%fa:reply%</a>
-						<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :class="$style.text"/>
+						<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :class="$style.text" :customEmojis="appearNote.emojis"/>
 						<a class="rp" v-if="appearNote.renote != null">RN:</a>
 					</div>
 					<div class="files" v-if="appearNote.files.length > 0">
diff --git a/src/client/app/mobile/views/components/sub-note-content.vue b/src/client/app/mobile/views/components/sub-note-content.vue
index 6a90d5bc1a..05d6d1d571 100644
--- a/src/client/app/mobile/views/components/sub-note-content.vue
+++ b/src/client/app/mobile/views/components/sub-note-content.vue
@@ -4,7 +4,7 @@
 		<span v-if="note.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
 		<span v-if="note.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span>
 		<a class="reply" v-if="note.replyId">%fa:reply%</a>
-		<misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i"/>
+		<misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i" :customEmojis="note.emojis"/>
 		<a class="rp" v-if="note.renoteId">RN: ...</a>
 	</div>
 	<details v-if="note.files.length > 0">
diff --git a/src/models/emoji.ts b/src/models/emoji.ts
new file mode 100644
index 0000000000..f0d0b58273
--- /dev/null
+++ b/src/models/emoji.ts
@@ -0,0 +1,22 @@
+import db from '../db/mongodb';
+
+const Emoji = db.get<IEmoji>('emoji');
+
+Emoji.createIndex(['name', 'host'], { unique: true });
+
+export default Emoji;
+
+export type IEmoji = {
+	name: string;
+	host: string;
+	url: string;
+	aliases?: string[];
+	updatedAt?: Date;
+};
+
+export const packEmojis = async (
+	host: string,
+	// MeiTODO: filter
+) => {
+	return await Emoji.find({ host });
+};
diff --git a/src/models/note.ts b/src/models/note.ts
index 09246dea45..684e8c3b1e 100644
--- a/src/models/note.ts
+++ b/src/models/note.ts
@@ -12,6 +12,7 @@ import { packMany as packFileMany, IDriveFile } from './drive-file';
 import Favorite from './favorite';
 import Following from './following';
 import config from '../config';
+import { packEmojis } from './emoji';
 
 const Note = db.get<INote>('notes');
 Note.createIndex('uri', { sparse: true, unique: true });
@@ -228,6 +229,11 @@ export const pack = async (
 
 	const id = _note._id;
 
+	// _note._userを消す前か、_note.userを解決した後でないとホストがわからない
+	if (_note._user) {
+		_note.emojis = packEmojis(_note._user.host);
+	}
+
 	// Rename _id to id
 	_note.id = _note._id;
 	delete _note._id;
diff --git a/src/remote/activitypub/misc/get-emoji-names.ts b/src/remote/activitypub/misc/get-emoji-names.ts
new file mode 100644
index 0000000000..f744d02fed
--- /dev/null
+++ b/src/remote/activitypub/misc/get-emoji-names.ts
@@ -0,0 +1,6 @@
+import parse from '../../../mfm/parse';
+
+export default function(text: string) {
+	if (!text) return [];
+	return parse(text).filter(t => t.type === 'emoji').map(t => (t as any).emoji);
+}
diff --git a/src/remote/activitypub/models/icon.ts b/src/remote/activitypub/models/icon.ts
new file mode 100644
index 0000000000..50794a937d
--- /dev/null
+++ b/src/remote/activitypub/models/icon.ts
@@ -0,0 +1,5 @@
+export type IIcon = {
+	type: string;
+	mediaType?: string;
+	url?: string;
+};
diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts
index d49cf53079..be6c1bcd18 100644
--- a/src/remote/activitypub/models/note.ts
+++ b/src/remote/activitypub/models/note.ts
@@ -10,6 +10,9 @@ import { resolvePerson, updatePerson } from './person';
 import { resolveImage } from './image';
 import { IRemoteUser, IUser } from '../../../models/user';
 import htmlToMFM from '../../../mfm/html-to-mfm';
+import Emoji from '../../../models/emoji';
+import { ITag } from './tag';
+import { toUnicode } from 'punycode';
 
 const log = debug('misskey:activitypub');
 
@@ -93,6 +96,10 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 	// テキストのパース
 	const text = note._misskey_content ? note._misskey_content : htmlToMFM(note.content);
 
+	await extractEmojis(note.tag, actor.host).catch(e => {
+		console.log(`extractEmojis: ${e}`);
+	});
+
 	// ユーザーの情報が古かったらついでに更新しておく
 	if (actor.updatedAt == null || Date.now() - actor.updatedAt.getTime() > 1000 * 60 * 60 * 24) {
 		updatePerson(note.attributedTo);
@@ -135,3 +142,35 @@ export async function resolveNote(value: string | IObject, resolver?: Resolver):
 	// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
 	return await createNote(uri, resolver);
 }
+
+async function extractEmojis(tags: ITag[], host_: string) {
+	const host = toUnicode(host_.toLowerCase());
+
+	if (!tags) return [];
+
+	const eomjiTags = tags.filter(tag => tag.type === 'Emoji' && tag.icon && tag.icon.url);
+
+	return await Promise.all(
+		eomjiTags.map(async tag => {
+			const name = tag.name.replace(/^:/, '').replace(/:$/, '');
+
+			const exists = await Emoji.findOne({
+				host,
+				name
+			});
+
+			if (exists) {
+				return exists;
+			}
+
+			log(`register emoji host=${host}, name=${name}`);
+
+			return await Emoji.insert({
+				host,
+				name,
+				url: tag.icon.url,
+				aliases: [],
+			});
+		})
+	);
+}
diff --git a/src/remote/activitypub/models/tag.ts b/src/remote/activitypub/models/tag.ts
new file mode 100644
index 0000000000..5cdbfa43b1
--- /dev/null
+++ b/src/remote/activitypub/models/tag.ts
@@ -0,0 +1,12 @@
+import { IIcon } from "./icon";
+
+/***
+ * tag (ActivityPub)
+ */
+export type ITag = {
+	id: string;
+	type: string;
+	name?: string;
+	updated?: Date;
+	icon?: IIcon;
+};
diff --git a/src/remote/activitypub/renderer/emoji.ts b/src/remote/activitypub/renderer/emoji.ts
new file mode 100644
index 0000000000..b18337d274
--- /dev/null
+++ b/src/remote/activitypub/renderer/emoji.ts
@@ -0,0 +1,14 @@
+import { IEmoji } from '../../../models/emoji';
+import config from '../../../config';
+
+export default (emoji: IEmoji) => ({
+	id: `${config.url}/emojis/${emoji.name}`,
+	type: 'Emoji',
+	name: `:${emoji.name}:`,
+	updated: emoji.updatedAt != null ? emoji.updatedAt.toISOString() : new Date().toISOString,
+	icon: {
+		type: 'Image',
+		mediaType: 'image/png',	//Mei-TODO
+		url: emoji.url
+	}
+});
diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts
index b3ce1c03e4..a2c591de2e 100644
--- a/src/remote/activitypub/renderer/note.ts
+++ b/src/remote/activitypub/renderer/note.ts
@@ -1,12 +1,16 @@
 import renderDocument from './document';
 import renderHashtag from './hashtag';
 import renderMention from './mention';
+import renderEmoji from './emoji';
 import config from '../../../config';
 import DriveFile, { IDriveFile } from '../../../models/drive-file';
 import Note, { INote } from '../../../models/note';
 import User from '../../../models/user';
 import toHtml from '../misc/get-note-html';
 import parseMfm from '../../../mfm/parse';
+import getEmojiNames from '../misc/get-emoji-names';
+import Emoji, { IEmoji } from '../../../models/emoji';
+import { unique } from '../../../prelude/array';
 
 export default async function renderNote(note: INote, dive = true): Promise<any> {
 	const promisedFiles: Promise<IDriveFile[]> = note.fileIds
@@ -75,10 +79,6 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
 
 	const hashtagTags = (note.tags || []).map(tag => renderHashtag(tag));
 	const mentionTags = mentionedUsers.map(u => renderMention(u));
-	const tag = [
-		...hashtagTags,
-		...mentionTags,
-	];
 
 	const files = await promisedFiles;
 
@@ -108,12 +108,24 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
 		}).join('');
 	}
 
+	const content = toHtml(Object.assign({}, note, { text }));
+
+	const emojiNames = unique(getEmojiNames(content));
+	const emojis = await getEmojis(emojiNames);
+	const apemojis = emojis.map(emoji => renderEmoji(emoji));
+
+	const tag = [
+		...hashtagTags,
+		...mentionTags,
+		...apemojis,
+	];
+
 	return {
 		id: `${config.url}/notes/${note._id}`,
 		type: 'Note',
 		attributedTo,
 		summary: note.cw,
-		content: toHtml(Object.assign({}, note, { text })),
+		content,
 		_misskey_content: text,
 		published: note.createdAt.toISOString(),
 		to,
@@ -124,3 +136,18 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
 		tag
 	};
 }
+
+async function getEmojis(names: string[]): Promise<IEmoji[]> {
+	if (names == null || names.length < 1) return [];
+
+	const emojis = await Promise.all(
+		names.map(async name => {
+			return await Emoji.findOne({
+				name,
+				host: null
+			});
+		})
+	);
+
+	return emojis.filter(emoji => emoji != null);
+}
diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts
index 8bc0293832..87b6774b23 100644
--- a/src/server/api/endpoints/meta.ts
+++ b/src/server/api/endpoints/meta.ts
@@ -2,6 +2,7 @@ import * as os from 'os';
 import config from '../../../config';
 import Meta from '../../../models/meta';
 import { ILocalUser } from '../../../models/user';
+import Emoji from '../../../models/emoji';
 
 const pkg = require('../../../../package.json');
 const client = require('../../../../built/client/meta.json');
@@ -22,6 +23,8 @@ export const meta = {
 export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => {
 	const meta: any = (await Meta.findOne()) || {};
 
+	const emojis = await Emoji.find({ host: null });
+
 	res({
 		maintainer: config.maintainer,
 
@@ -50,7 +53,7 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 		hidedTags: (me && me.isAdmin) ? meta.hidedTags : undefined,
 		bannerUrl: meta.bannerUrl,
 		maxNoteTextLength: config.maxNoteTextLength,
-		emojis: meta.emojis,
+		emojis: emojis,
 
 		features: {
 			registration: !meta.disableRegistration,
diff --git a/src/tools/add-emoji.ts b/src/tools/add-emoji.ts
new file mode 100644
index 0000000000..875af55c14
--- /dev/null
+++ b/src/tools/add-emoji.ts
@@ -0,0 +1,31 @@
+import * as debug from 'debug';
+import Emoji from "../models/emoji";
+
+debug.enable('*');
+
+async function main(name: string, url: string, alias?: string): Promise<any> {
+	const aliases = alias != null ? [ alias ] : [];
+
+	await Emoji.insert({
+		host: null,
+		name,
+		url,
+		aliases,
+		updatedAt: new Date()
+	});
+}
+
+const args = process.argv.slice(2);
+const name = args[0];
+const url = args[1];
+
+if (!name) throw 'require name';
+if (!url) throw 'require url';
+
+main(name, url).then(() => {
+	console.log('success');
+	process.exit(0);
+}).catch(e => {
+	console.warn(e);
+	process.exit(1);
+});