Remote custom emojis (#3074)

* Remote custom emojis

* んほおおおおお
This commit is contained in:
MeiMei 2018-11-02 08:59:40 +09:00 committed by syuilo
parent c48cbd95f6
commit 80b5fda292
18 changed files with 193 additions and 24 deletions

View file

@ -24,6 +24,9 @@ export default Vue.component('misskey-flavored-markdown', {
i: { i: {
type: Object, type: Object,
default: null default: null
},
customEmojis: {
required: false,
} }
}, },
@ -186,8 +189,8 @@ export default Vue.component('misskey-flavored-markdown', {
case 'emoji': { case 'emoji': {
//#region カスタム絵文字 //#region カスタム絵文字
const customEmojis = (this.os.getMetaSync() || { emojis: [] }).emojis || []; if (this.customEmojis != null) {
const customEmoji = customEmojis.find(e => e.name == token.emoji || (e.aliases || []).includes(token.emoji)); const customEmoji = this.customEmojis.find(e => e.name == token.emoji || (e.aliases || []).includes(token.emoji));
if (customEmoji) { if (customEmoji) {
return [createElement('img', { return [createElement('img', {
attrs: { attrs: {
@ -198,6 +201,7 @@ export default Vue.component('misskey-flavored-markdown', {
} }
})]; })];
} }
}
//#endregion //#endregion
const emoji = emojilib.lib[token.emoji]; const emoji = emojilib.lib[token.emoji];

View file

@ -14,7 +14,7 @@
</div> </div>
</header> </header>
<div class="text"> <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> </div>
</div> </div>

View file

@ -45,7 +45,7 @@
<div class="text"> <div class="text">
<span v-if="p.isHidden" style="opacity: 0.5">%i18n:@private%</span> <span v-if="p.isHidden" style="opacity: 0.5">%i18n:@private%</span>
<span v-if="p.deletedAt" style="opacity: 0.5">%i18n:@deleted%</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>
<div class="files" v-if="p.files.length > 0"> <div class="files" v-if="p.files.length > 0">
<mk-media-list :media-list="p.files" :raw="true"/> <mk-media-list :media-list="p.files" :raw="true"/>

View file

@ -34,7 +34,7 @@
<div class="text"> <div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">%i18n:@private%</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">%i18n:@private%</span>
<a class="reply" v-if="appearNote.reply">%fa:reply%</a> <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> <a class="rp" v-if="appearNote.renote">RN:</a>
</div> </div>
<div class="files" v-if="appearNote.files.length > 0"> <div class="files" v-if="appearNote.files.length > 0">

View file

@ -4,7 +4,7 @@
<span v-if="note.isHidden" style="opacity: 0.5">%i18n:@private%</span> <span v-if="note.isHidden" style="opacity: 0.5">%i18n:@private%</span>
<span v-if="note.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span> <span v-if="note.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span>
<a class="reply" v-if="note.replyId">%fa:reply%</a> <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> <a class="rp" v-if="note.renoteId" :href="`/notes/${note.renoteId}`">RN: ...</a>
</div> </div>
<details v-if="note.files.length > 0"> <details v-if="note.files.length > 0">

View file

@ -43,7 +43,7 @@
<div class="text"> <div class="text">
<span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span> <span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
<span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</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>
<div class="files" v-if="p.files.length > 0"> <div class="files" v-if="p.files.length > 0">
<mk-media-list :media-list="p.files" :raw="true"/> <mk-media-list :media-list="p.files" :raw="true"/>

View file

@ -30,7 +30,7 @@
<div class="text"> <div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">(%i18n:@private%)</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
<a class="reply" v-if="appearNote.reply">%fa:reply%</a> <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> <a class="rp" v-if="appearNote.renote != null">RN:</a>
</div> </div>
<div class="files" v-if="appearNote.files.length > 0"> <div class="files" v-if="appearNote.files.length > 0">

View file

@ -4,7 +4,7 @@
<span v-if="note.isHidden" style="opacity: 0.5">(%i18n:@private%)</span> <span v-if="note.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
<span v-if="note.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span> <span v-if="note.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span>
<a class="reply" v-if="note.replyId">%fa:reply%</a> <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> <a class="rp" v-if="note.renoteId">RN: ...</a>
</div> </div>
<details v-if="note.files.length > 0"> <details v-if="note.files.length > 0">

22
src/models/emoji.ts Normal file
View file

@ -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 });
};

View file

@ -12,6 +12,7 @@ import { packMany as packFileMany, IDriveFile } from './drive-file';
import Favorite from './favorite'; import Favorite from './favorite';
import Following from './following'; import Following from './following';
import config from '../config'; import config from '../config';
import { packEmojis } from './emoji';
const Note = db.get<INote>('notes'); const Note = db.get<INote>('notes');
Note.createIndex('uri', { sparse: true, unique: true }); Note.createIndex('uri', { sparse: true, unique: true });
@ -228,6 +229,11 @@ export const pack = async (
const id = _note._id; const id = _note._id;
// _note._userを消す前か、_note.userを解決した後でないとホストがわからない
if (_note._user) {
_note.emojis = packEmojis(_note._user.host);
}
// Rename _id to id // Rename _id to id
_note.id = _note._id; _note.id = _note._id;
delete _note._id; delete _note._id;

View file

@ -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);
}

View file

@ -0,0 +1,5 @@
export type IIcon = {
type: string;
mediaType?: string;
url?: string;
};

View file

@ -10,6 +10,9 @@ import { resolvePerson, updatePerson } from './person';
import { resolveImage } from './image'; import { resolveImage } from './image';
import { IRemoteUser, IUser } from '../../../models/user'; import { IRemoteUser, IUser } from '../../../models/user';
import htmlToMFM from '../../../mfm/html-to-mfm'; 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'); 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); 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) { if (actor.updatedAt == null || Date.now() - actor.updatedAt.getTime() > 1000 * 60 * 60 * 24) {
updatePerson(note.attributedTo); updatePerson(note.attributedTo);
@ -135,3 +142,35 @@ export async function resolveNote(value: string | IObject, resolver?: Resolver):
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
return await createNote(uri, resolver); 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: [],
});
})
);
}

View file

@ -0,0 +1,12 @@
import { IIcon } from "./icon";
/***
* tag (ActivityPub)
*/
export type ITag = {
id: string;
type: string;
name?: string;
updated?: Date;
icon?: IIcon;
};

View file

@ -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
}
});

View file

@ -1,12 +1,16 @@
import renderDocument from './document'; import renderDocument from './document';
import renderHashtag from './hashtag'; import renderHashtag from './hashtag';
import renderMention from './mention'; import renderMention from './mention';
import renderEmoji from './emoji';
import config from '../../../config'; import config from '../../../config';
import DriveFile, { IDriveFile } from '../../../models/drive-file'; import DriveFile, { IDriveFile } from '../../../models/drive-file';
import Note, { INote } from '../../../models/note'; import Note, { INote } from '../../../models/note';
import User from '../../../models/user'; import User from '../../../models/user';
import toHtml from '../misc/get-note-html'; import toHtml from '../misc/get-note-html';
import parseMfm from '../../../mfm/parse'; 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> { export default async function renderNote(note: INote, dive = true): Promise<any> {
const promisedFiles: Promise<IDriveFile[]> = note.fileIds 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 hashtagTags = (note.tags || []).map(tag => renderHashtag(tag));
const mentionTags = mentionedUsers.map(u => renderMention(u)); const mentionTags = mentionedUsers.map(u => renderMention(u));
const tag = [
...hashtagTags,
...mentionTags,
];
const files = await promisedFiles; const files = await promisedFiles;
@ -108,12 +108,24 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
}).join(''); }).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 { return {
id: `${config.url}/notes/${note._id}`, id: `${config.url}/notes/${note._id}`,
type: 'Note', type: 'Note',
attributedTo, attributedTo,
summary: note.cw, summary: note.cw,
content: toHtml(Object.assign({}, note, { text })), content,
_misskey_content: text, _misskey_content: text,
published: note.createdAt.toISOString(), published: note.createdAt.toISOString(),
to, to,
@ -124,3 +136,18 @@ export default async function renderNote(note: INote, dive = true): Promise<any>
tag 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);
}

View file

@ -2,6 +2,7 @@ import * as os from 'os';
import config from '../../../config'; import config from '../../../config';
import Meta from '../../../models/meta'; import Meta from '../../../models/meta';
import { ILocalUser } from '../../../models/user'; import { ILocalUser } from '../../../models/user';
import Emoji from '../../../models/emoji';
const pkg = require('../../../../package.json'); const pkg = require('../../../../package.json');
const client = require('../../../../built/client/meta.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) => { export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => {
const meta: any = (await Meta.findOne()) || {}; const meta: any = (await Meta.findOne()) || {};
const emojis = await Emoji.find({ host: null });
res({ res({
maintainer: config.maintainer, 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, hidedTags: (me && me.isAdmin) ? meta.hidedTags : undefined,
bannerUrl: meta.bannerUrl, bannerUrl: meta.bannerUrl,
maxNoteTextLength: config.maxNoteTextLength, maxNoteTextLength: config.maxNoteTextLength,
emojis: meta.emojis, emojis: emojis,
features: { features: {
registration: !meta.disableRegistration, registration: !meta.disableRegistration,

31
src/tools/add-emoji.ts Normal file
View file

@ -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);
});